ruby-rego 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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.reek.yml +80 -0
  3. data/.vscode/extensions.json +19 -0
  4. data/.vscode/launch.json +35 -0
  5. data/.vscode/settings.json +25 -0
  6. data/.vscode/tasks.json +117 -0
  7. data/.yardopts +12 -0
  8. data/ARCHITECTURE.md +39 -0
  9. data/CHANGELOG.md +25 -0
  10. data/CODE_OF_CONDUCT.md +10 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +183 -0
  13. data/RELEASING.md +37 -0
  14. data/Rakefile +38 -0
  15. data/SECURITY.md +26 -0
  16. data/Steepfile +10 -0
  17. data/TODO.md +35 -0
  18. data/benchmark/builtin_calls.rb +29 -0
  19. data/benchmark/complex_policy.rb +19 -0
  20. data/benchmark/comprehensions.rb +19 -0
  21. data/benchmark/simple_rules.rb +20 -0
  22. data/examples/README.md +27 -0
  23. data/examples/sample_config.yaml +2 -0
  24. data/examples/simple_policy.rego +7 -0
  25. data/examples/validation_policy.rego +11 -0
  26. data/exe/rego-validate +6 -0
  27. data/lib/ruby/rego/ast/base.rb +95 -0
  28. data/lib/ruby/rego/ast/binary_op.rb +64 -0
  29. data/lib/ruby/rego/ast/call.rb +27 -0
  30. data/lib/ruby/rego/ast/composite.rb +48 -0
  31. data/lib/ruby/rego/ast/comprehension.rb +63 -0
  32. data/lib/ruby/rego/ast/every.rb +37 -0
  33. data/lib/ruby/rego/ast/import.rb +32 -0
  34. data/lib/ruby/rego/ast/literal.rb +70 -0
  35. data/lib/ruby/rego/ast/module.rb +32 -0
  36. data/lib/ruby/rego/ast/package.rb +22 -0
  37. data/lib/ruby/rego/ast/query.rb +63 -0
  38. data/lib/ruby/rego/ast/reference.rb +58 -0
  39. data/lib/ruby/rego/ast/rule.rb +114 -0
  40. data/lib/ruby/rego/ast/unary_op.rb +42 -0
  41. data/lib/ruby/rego/ast/variable.rb +22 -0
  42. data/lib/ruby/rego/ast.rb +17 -0
  43. data/lib/ruby/rego/builtins/aggregates.rb +124 -0
  44. data/lib/ruby/rego/builtins/base.rb +95 -0
  45. data/lib/ruby/rego/builtins/collections/array_ops.rb +103 -0
  46. data/lib/ruby/rego/builtins/collections/object_ops.rb +120 -0
  47. data/lib/ruby/rego/builtins/collections/set_ops.rb +51 -0
  48. data/lib/ruby/rego/builtins/collections.rb +137 -0
  49. data/lib/ruby/rego/builtins/comparisons/casts.rb +139 -0
  50. data/lib/ruby/rego/builtins/comparisons.rb +84 -0
  51. data/lib/ruby/rego/builtins/numeric_helpers.rb +56 -0
  52. data/lib/ruby/rego/builtins/registry.rb +199 -0
  53. data/lib/ruby/rego/builtins/registry_helpers.rb +27 -0
  54. data/lib/ruby/rego/builtins/strings/case_ops.rb +22 -0
  55. data/lib/ruby/rego/builtins/strings/concat.rb +19 -0
  56. data/lib/ruby/rego/builtins/strings/formatting.rb +35 -0
  57. data/lib/ruby/rego/builtins/strings/helpers.rb +62 -0
  58. data/lib/ruby/rego/builtins/strings/number_helpers.rb +48 -0
  59. data/lib/ruby/rego/builtins/strings/search.rb +63 -0
  60. data/lib/ruby/rego/builtins/strings/split.rb +19 -0
  61. data/lib/ruby/rego/builtins/strings/substring.rb +22 -0
  62. data/lib/ruby/rego/builtins/strings/trim.rb +42 -0
  63. data/lib/ruby/rego/builtins/strings/trim_helpers.rb +62 -0
  64. data/lib/ruby/rego/builtins/strings.rb +58 -0
  65. data/lib/ruby/rego/builtins/types.rb +89 -0
  66. data/lib/ruby/rego/call_name.rb +55 -0
  67. data/lib/ruby/rego/cli.rb +1122 -0
  68. data/lib/ruby/rego/compiled_module.rb +114 -0
  69. data/lib/ruby/rego/compiler.rb +1097 -0
  70. data/lib/ruby/rego/environment/overrides.rb +33 -0
  71. data/lib/ruby/rego/environment/reference_resolution.rb +86 -0
  72. data/lib/ruby/rego/environment.rb +230 -0
  73. data/lib/ruby/rego/environment_pool.rb +71 -0
  74. data/lib/ruby/rego/error_handling.rb +58 -0
  75. data/lib/ruby/rego/error_payload.rb +34 -0
  76. data/lib/ruby/rego/errors.rb +196 -0
  77. data/lib/ruby/rego/evaluator/assignment_support.rb +126 -0
  78. data/lib/ruby/rego/evaluator/binding_helpers.rb +60 -0
  79. data/lib/ruby/rego/evaluator/comprehension_evaluator.rb +182 -0
  80. data/lib/ruby/rego/evaluator/expression_dispatch.rb +45 -0
  81. data/lib/ruby/rego/evaluator/expression_evaluator.rb +492 -0
  82. data/lib/ruby/rego/evaluator/object_literal_evaluator.rb +52 -0
  83. data/lib/ruby/rego/evaluator/operator_evaluator.rb +163 -0
  84. data/lib/ruby/rego/evaluator/query_node_builder.rb +38 -0
  85. data/lib/ruby/rego/evaluator/reference_key_resolver.rb +50 -0
  86. data/lib/ruby/rego/evaluator/reference_resolver.rb +352 -0
  87. data/lib/ruby/rego/evaluator/rule_evaluator/bindings.rb +70 -0
  88. data/lib/ruby/rego/evaluator/rule_evaluator.rb +550 -0
  89. data/lib/ruby/rego/evaluator/rule_value_provider.rb +56 -0
  90. data/lib/ruby/rego/evaluator/variable_collector.rb +221 -0
  91. data/lib/ruby/rego/evaluator.rb +174 -0
  92. data/lib/ruby/rego/lexer/number_reader.rb +68 -0
  93. data/lib/ruby/rego/lexer/stream.rb +137 -0
  94. data/lib/ruby/rego/lexer/string_reader.rb +90 -0
  95. data/lib/ruby/rego/lexer/template_string_reader.rb +62 -0
  96. data/lib/ruby/rego/lexer.rb +206 -0
  97. data/lib/ruby/rego/location.rb +73 -0
  98. data/lib/ruby/rego/memoization.rb +67 -0
  99. data/lib/ruby/rego/parser/collections.rb +173 -0
  100. data/lib/ruby/rego/parser/expressions.rb +216 -0
  101. data/lib/ruby/rego/parser/precedence.rb +42 -0
  102. data/lib/ruby/rego/parser/query.rb +139 -0
  103. data/lib/ruby/rego/parser/references.rb +115 -0
  104. data/lib/ruby/rego/parser/rules.rb +310 -0
  105. data/lib/ruby/rego/parser.rb +210 -0
  106. data/lib/ruby/rego/policy.rb +50 -0
  107. data/lib/ruby/rego/result.rb +91 -0
  108. data/lib/ruby/rego/token.rb +206 -0
  109. data/lib/ruby/rego/unifier.rb +451 -0
  110. data/lib/ruby/rego/value.rb +379 -0
  111. data/lib/ruby/rego/version.rb +7 -0
  112. data/lib/ruby/rego/with_modifiers/with_modifier.rb +37 -0
  113. data/lib/ruby/rego/with_modifiers/with_modifier_applier.rb +48 -0
  114. data/lib/ruby/rego/with_modifiers/with_modifier_builtin_override.rb +128 -0
  115. data/lib/ruby/rego/with_modifiers/with_modifier_context.rb +120 -0
  116. data/lib/ruby/rego/with_modifiers/with_modifier_path_key_resolver.rb +42 -0
  117. data/lib/ruby/rego/with_modifiers/with_modifier_path_override.rb +99 -0
  118. data/lib/ruby/rego/with_modifiers/with_modifier_root_scope.rb +58 -0
  119. data/lib/ruby/rego.rb +72 -0
  120. data/sig/objspace.rbs +4 -0
  121. data/sig/psych.rbs +7 -0
  122. data/sig/rego_validate.rbs +382 -0
  123. data/sig/ruby/rego.rbs +2150 -0
  124. metadata +172 -0
@@ -0,0 +1,1122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "yaml"
6
+ require "ruby/rego"
7
+
8
+ # CLI entrypoints and helpers for rego-validate.
9
+ module RegoValidate
10
+ # CLI option values.
11
+ Options = Struct.new(:policy, :config, :query, :format, :help, :yaml_aliases, :profile, keyword_init: true)
12
+
13
+ # CLI option values.
14
+ class Options
15
+ # Check whether help output was requested.
16
+ #
17
+ # @return [Boolean]
18
+ def help?
19
+ help
20
+ end
21
+
22
+ # Check whether profiling output was requested.
23
+ #
24
+ # @return [Boolean]
25
+ def profile?
26
+ profile
27
+ end
28
+ end
29
+
30
+ # Parsed options plus parser state and error details.
31
+ ParseResult = Struct.new(:options, :parser, :error, keyword_init: true)
32
+
33
+ # Parsed options plus parser state and error details.
34
+ class ParseResult
35
+ # Check whether parsing succeeded.
36
+ #
37
+ # @return [Boolean]
38
+ def success?
39
+ !error
40
+ end
41
+
42
+ # Report the parse error using the configured output format.
43
+ #
44
+ # @param stdout [IO]
45
+ # @param stderr [IO]
46
+ # @return [void]
47
+ def report_error(stdout:, stderr:)
48
+ reporter = ErrorReporter.new(stdout: stdout, stderr: stderr, format: options.format)
49
+ reporter.error(error_message, parser)
50
+ end
51
+
52
+ private
53
+
54
+ def error_message
55
+ error ? error.message : "Invalid command-line options"
56
+ end
57
+ end
58
+
59
+ # Captures the outcome of loading a config file.
60
+ ConfigLoadResult = Struct.new(:value, :success, keyword_init: true)
61
+
62
+ # Captures the outcome of loading a config file.
63
+ class ConfigLoadResult
64
+ # Check whether loading succeeded.
65
+ #
66
+ # @return [Boolean]
67
+ def success?
68
+ success
69
+ end
70
+ end
71
+
72
+ # Policy evaluation outcome with optional error message.
73
+ EvaluationResult = Struct.new(:outcome, :error_message, keyword_init: true)
74
+
75
+ # Policy evaluation outcome with optional error message.
76
+ class EvaluationResult
77
+ # Check whether evaluation succeeded.
78
+ #
79
+ # @return [Boolean]
80
+ def success?
81
+ !!outcome && error_message.to_s.empty?
82
+ end
83
+ end
84
+
85
+ # Normalized policy evaluation outcome.
86
+ Outcome = Struct.new(:success, :value, :errors, keyword_init: true)
87
+
88
+ # Normalized policy evaluation outcome.
89
+ class Outcome
90
+ # Check whether the outcome indicates success.
91
+ #
92
+ # @return [Boolean]
93
+ def success?
94
+ success
95
+ end
96
+ end
97
+
98
+ # Command-line interface for validating inputs against a Rego policy.
99
+ class CLI
100
+ # Create a CLI instance.
101
+ #
102
+ # @param argv [Array<String>] command-line arguments
103
+ # @param stdout [IO] output stream
104
+ # @param stderr [IO] error stream
105
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
106
+ @argv = argv
107
+ @stdout = stdout
108
+ @stderr = stderr
109
+ @options = Options.new(format: "text", help: false, yaml_aliases: false, profile: false)
110
+ end
111
+
112
+ # Run the CLI and return an exit status.
113
+ #
114
+ # @return [Integer]
115
+ def run
116
+ perform_run
117
+ rescue Ruby::Rego::Error => e
118
+ handle_rego_error(e)
119
+ rescue StandardError => e
120
+ handle_unexpected_error(e)
121
+ end
122
+
123
+ private
124
+
125
+ attr_reader :argv, :options, :stdout, :stderr
126
+
127
+ def perform_run
128
+ parse_result = OptionsParser.new(argv).parse
129
+ return handle_parse_error(parse_result) unless parse_result.success?
130
+
131
+ apply_parse_result(parse_result)
132
+ end
133
+
134
+ def apply_parse_result(parse_result)
135
+ parser = parse_result.parser
136
+ @options = parse_result.options
137
+ return handle_help(parser) if options.help?
138
+ return 2 unless required_options_present?(parser)
139
+
140
+ handle_evaluation(parser)
141
+ end
142
+
143
+ def handle_evaluation(parser)
144
+ evaluation = evaluate_policy(parser, profiler: options.profile? ? Profiler.new(stderr: stderr) : nil)
145
+ outcome = evaluation.outcome
146
+ return 2 unless evaluation.success? && outcome
147
+
148
+ emit_outcome(outcome)
149
+ end
150
+
151
+ def emit_outcome(outcome)
152
+ OutcomeEmitter.new(stdout, format: options.format).emit(outcome)
153
+ outcome.success? ? 0 : 1
154
+ end
155
+
156
+ def handle_parse_error(parse_result)
157
+ parse_result.report_error(stdout: stdout, stderr: stderr)
158
+ 2
159
+ end
160
+
161
+ def required_options_present?(parser)
162
+ missing = OptionsValidator.new(options).missing_required
163
+ return true if missing.empty?
164
+
165
+ reporter.error("Missing required options: #{missing.join(", ")}", parser)
166
+ false
167
+ end
168
+
169
+ def evaluate_policy(parser, profiler: nil)
170
+ policy_source, config_result = SourceLoader.new(options: options, reporter: reporter, parser: parser).load
171
+ return EvaluationResult.new unless policy_source && config_result.success?
172
+
173
+ evaluation = PolicyEvaluator.new(policy_source, config_result.value, options.query, profiler: profiler).evaluate
174
+ report_evaluation_error(evaluation, parser)
175
+ evaluation
176
+ end
177
+
178
+ def report_evaluation_error(evaluation, parser)
179
+ message = evaluation.error_message
180
+ return unless message
181
+
182
+ reporter.error(message, parser)
183
+ end
184
+
185
+ def handle_help(parser)
186
+ if options.format == "json"
187
+ stdout.puts(JSON.generate({ success: true, help: parser.to_s }))
188
+ else
189
+ stdout.puts(parser)
190
+ end
191
+ 0
192
+ end
193
+
194
+ def reporter
195
+ ErrorReporter.new(stdout: stdout, stderr: stderr, format: options.format)
196
+ end
197
+
198
+ def handle_rego_error(error)
199
+ reporter.rego_error(error)
200
+ 2
201
+ end
202
+
203
+ def handle_unexpected_error(error)
204
+ reporter.error("Unexpected error: #{error.message}")
205
+ 2
206
+ end
207
+ end
208
+
209
+ # Parses CLI arguments into a structured options object.
210
+ class OptionsParser
211
+ VALID_FORMATS = %w[text json].freeze
212
+
213
+ # Create an options parser.
214
+ #
215
+ # @param argv [Array<String>] command-line arguments
216
+ def initialize(argv)
217
+ @argv = argv
218
+ end
219
+
220
+ # Parse arguments into an options result.
221
+ #
222
+ # @return [ParseResult]
223
+ def parse
224
+ ParseResultBuilder.new(argv).call
225
+ end
226
+
227
+ private
228
+
229
+ attr_reader :argv
230
+
231
+ # Builds ParseResult objects from argv values.
232
+ class ParseResultBuilder
233
+ # @param argv [Array<String>]
234
+ def initialize(argv)
235
+ @argv = argv
236
+ end
237
+
238
+ # @return [ParseResult]
239
+ def call
240
+ # @type var options: Options
241
+ options = Options.new(format: "text", help: false, yaml_aliases: false, profile: false)
242
+ parse_with(options)
243
+ end
244
+
245
+ private
246
+
247
+ attr_reader :argv
248
+
249
+ def parse_with(options)
250
+ parser = OptionDefinitions.new(options).build
251
+ parser.parse!(@argv)
252
+ ParseResult.new(options: options, parser: parser)
253
+ rescue OptionParser::ParseError => e
254
+ ParseResult.new(options: options, parser: parser, error: e)
255
+ end
256
+ end
257
+
258
+ # Builds option definitions for OptionParser.
259
+ class OptionDefinitions
260
+ OPTION_BUILDERS = %i[
261
+ add_policy_option
262
+ add_config_option
263
+ add_query_option
264
+ add_format_option
265
+ add_profile_option
266
+ add_yaml_aliases_option
267
+ add_help_option
268
+ ].freeze
269
+
270
+ # @param options [Options]
271
+ def initialize(options)
272
+ @options = options
273
+ end
274
+
275
+ # @return [OptionParser]
276
+ def build
277
+ OptionParser.new do |opts|
278
+ opts.banner = "Usage: rego-validate --policy POLICY_FILE --config CONFIG_FILE [options]"
279
+ apply_options(opts)
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ attr_reader :options
286
+
287
+ def apply_options(opts)
288
+ OPTION_BUILDERS.each { |builder| send(builder, opts) }
289
+ end
290
+
291
+ def add_policy_option(opts)
292
+ opts.on("--policy FILE", "Rego policy file (required)") do |file|
293
+ options.policy = file
294
+ end
295
+ end
296
+
297
+ def add_config_option(opts)
298
+ opts.on("--config FILE", "YAML/JSON config file (required)") do |file|
299
+ options.config = file
300
+ end
301
+ end
302
+
303
+ def add_query_option(opts)
304
+ opts.on("--query QUERY", "Query path (optional, defaults to violations/errors)") do |query|
305
+ options.query = query
306
+ end
307
+ end
308
+
309
+ def add_format_option(opts)
310
+ message = "Output format: #{OptionsParser::VALID_FORMATS.join(", ")} (default: text)"
311
+ opts.on("--format FORMAT", OptionsParser::VALID_FORMATS, message) do |format|
312
+ options.format = format
313
+ end
314
+ end
315
+
316
+ def add_profile_option(opts)
317
+ opts.on("--profile", "Emit evaluation profiling to stderr") do
318
+ options.profile = true
319
+ end
320
+ end
321
+
322
+ def add_help_option(opts)
323
+ opts.on("-h", "--help", "Show this help") do
324
+ options.help = true
325
+ end
326
+ end
327
+
328
+ def add_yaml_aliases_option(opts)
329
+ opts.on("--yaml-aliases", "Allow YAML aliases in config files") do
330
+ options.yaml_aliases = true
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ # Checks presence of required CLI options.
337
+ class OptionsValidator
338
+ # Create a validator for parsed options.
339
+ #
340
+ # @param options [Options]
341
+ def initialize(options)
342
+ @options = options
343
+ end
344
+
345
+ # List missing required flags.
346
+ #
347
+ # @return [Array<String>]
348
+ def missing_required
349
+ # @type var missing: Array[String]
350
+ missing = []
351
+ missing << "--policy" unless options.policy
352
+ missing << "--config" unless options.config
353
+ missing
354
+ end
355
+
356
+ private
357
+
358
+ attr_reader :options
359
+ end
360
+
361
+ # Loads policy and input configuration files.
362
+ class ConfigLoader
363
+ JSON_EXTENSIONS = [".json"].freeze
364
+
365
+ # Create a config loader.
366
+ #
367
+ # @param reporter [ErrorReporter]
368
+ # @param parser [OptionParser]
369
+ def initialize(reporter:, parser:, yaml_aliases:)
370
+ @reporter = reporter
371
+ @parser = parser
372
+ @json_extensions = JSON_EXTENSIONS
373
+ @yaml_aliases = yaml_aliases
374
+ end
375
+
376
+ # Read the policy file content.
377
+ #
378
+ # @param path [String]
379
+ # @return [String, nil]
380
+ def read_policy(path)
381
+ read_file(path, "policy")
382
+ end
383
+
384
+ # Read and parse the config file content.
385
+ #
386
+ # @param path [String]
387
+ # @return [ConfigLoadResult]
388
+ def read_config(path)
389
+ content = read_file(path, "config")
390
+ return ConfigLoadResult.new(success: false) unless content
391
+
392
+ parse_config(content, path)
393
+ end
394
+
395
+ private
396
+
397
+ attr_reader :reporter, :parser, :json_extensions, :yaml_aliases
398
+
399
+ def read_file(path, label)
400
+ File.read(path)
401
+ rescue Errno::ENOENT
402
+ report_file_error(label, "not found", path)
403
+ nil
404
+ rescue Errno::EACCES
405
+ report_file_error(label, "not readable", path)
406
+ nil
407
+ end
408
+
409
+ def parse_config(content, path)
410
+ value = parse_config_value(content, path)
411
+ ConfigLoadResult.new(value: value, success: true)
412
+ rescue JSON::ParserError, Psych::BadAlias, Psych::SyntaxError => e
413
+ reporter.error("Invalid config file: #{e.message}", parser)
414
+ ConfigLoadResult.new(success: false)
415
+ end
416
+
417
+ def parse_config_value(content, path)
418
+ json_config?(path) ? JSON.parse(content) : YAML.safe_load(content, aliases: yaml_aliases)
419
+ end
420
+
421
+ def report_file_error(label, reason, path)
422
+ reporter.error("#{label.capitalize} file #{reason}: #{path}", parser)
423
+ end
424
+
425
+ def json_config?(path)
426
+ json_extensions.include?(File.extname(path).downcase)
427
+ end
428
+ end
429
+
430
+ # Loads policy and config sources based on CLI options.
431
+ class SourceLoader
432
+ # @param options [Options]
433
+ # @param reporter [ErrorReporter]
434
+ # @param parser [OptionParser]
435
+ def initialize(options:, reporter:, parser:)
436
+ @options = options
437
+ @loader = ConfigLoader.new(reporter: reporter, parser: parser, yaml_aliases: options.yaml_aliases)
438
+ end
439
+
440
+ # @return [Array<(String, ConfigLoadResult)>]
441
+ def load
442
+ policy_source = load_policy_source
443
+ return [nil, ConfigLoadResult.new(success: false)] unless policy_source
444
+
445
+ [policy_source, load_config]
446
+ end
447
+
448
+ private
449
+
450
+ attr_reader :options, :loader
451
+
452
+ def load_policy_source
453
+ policy_path = options.policy
454
+ return nil unless policy_path
455
+
456
+ loader.read_policy(policy_path)
457
+ end
458
+
459
+ def load_config
460
+ config_path = options.config
461
+ return ConfigLoadResult.new(success: false) unless config_path
462
+
463
+ loader.read_config(config_path)
464
+ end
465
+ end
466
+
467
+ # Resolves default queries based on available rules.
468
+ class DefaultQueryResolver
469
+ DEFAULT_RULE_NAMES = %w[deny violations violation errors error].freeze
470
+ FALLBACK_RULE_NAMES = %w[allow].freeze
471
+
472
+ # @param compiled_module [Ruby::Rego::CompiledModule]
473
+ def initialize(compiled_module)
474
+ @compiled_module = compiled_module
475
+ @rule_names = DEFAULT_RULE_NAMES + FALLBACK_RULE_NAMES
476
+ end
477
+
478
+ # @return [String, nil]
479
+ def resolve
480
+ rule_name = rule_names.find do |name|
481
+ rule_available?(name)
482
+ end
483
+ return nil unless rule_name
484
+
485
+ base = ["data", *package_path].join(".")
486
+ "#{base}.#{rule_name}"
487
+ end
488
+
489
+ private
490
+
491
+ attr_reader :compiled_module, :rule_names
492
+
493
+ def rule_available?(name)
494
+ compiled_module.has_rule?(name)
495
+ end
496
+
497
+ def package_path
498
+ compiled_module.package_path
499
+ end
500
+ end
501
+ private_constant :DefaultQueryResolver
502
+
503
+ # Captures timing and memory statistics for policy evaluation.
504
+ class Profiler
505
+ # Holds a single profiler sample.
506
+ Sample = Struct.new(:label, :duration_ms, :allocations, :memory_bytes, :top_objects, keyword_init: true)
507
+
508
+ # Rendering helpers for profiler samples.
509
+ class Sample
510
+ def report_line
511
+ parts = [
512
+ " #{label}: #{format_duration}",
513
+ "allocs +#{allocations}",
514
+ "mem #{format_bytes}"
515
+ ]
516
+ parts.join(", ")
517
+ end
518
+
519
+ def top_objects_line
520
+ return nil if top_objects.empty?
521
+
522
+ " top allocations: #{top_objects.join(", ")}"
523
+ end
524
+
525
+ private
526
+
527
+ def format_duration
528
+ format("%.2f ms", duration_ms)
529
+ end
530
+
531
+ def format_bytes
532
+ ByteFormatter.new(memory_bytes).render
533
+ end
534
+ end
535
+
536
+ # Formats byte sizes for profiler output.
537
+ class ByteFormatter
538
+ def initialize(bytes)
539
+ @sign = bytes.negative? ? "-" : "+"
540
+ @size = bytes.abs
541
+ end
542
+
543
+ def render
544
+ unit, value = if size < 1024
545
+ ["B", size.to_s]
546
+ elsif size < 1024 * 1024
547
+ ["KB", Kernel.format("%.2f", size / 1024.0)]
548
+ else
549
+ ["MB", Kernel.format("%.2f", size / (1024.0 * 1024.0))]
550
+ end
551
+ "#{sign}#{value} #{unit}"
552
+ end
553
+
554
+ private
555
+
556
+ attr_reader :sign, :size
557
+ end
558
+
559
+ # Captures a memory snapshot for diffing.
560
+ class Snapshot
561
+ class << self
562
+ def capture
563
+ require "objspace"
564
+ build_snapshot(memsize: ObjectSpace.memsize_of_all, objects: ObjectSpace.count_objects)
565
+ rescue LoadError, NoMethodError
566
+ build_snapshot(memsize: 0, objects: empty_object_counts)
567
+ end
568
+
569
+ def capture_before
570
+ capture
571
+ end
572
+
573
+ def capture_after
574
+ capture
575
+ end
576
+
577
+ private
578
+
579
+ def build_snapshot(memsize:, objects:)
580
+ new(
581
+ allocated: GC.stat[:total_allocated_objects],
582
+ memsize: memsize,
583
+ objects: objects
584
+ )
585
+ end
586
+
587
+ def empty_object_counts
588
+ {} # @type var objects: Hash[Symbol, Integer]
589
+ end
590
+ end
591
+
592
+ def initialize(allocated:, memsize:, objects:)
593
+ @allocated = allocated
594
+ @memsize = memsize
595
+ @objects = objects
596
+ end
597
+
598
+ attr_reader :allocated, :memsize, :objects
599
+
600
+ def delta(other)
601
+ Delta.new(
602
+ allocations: other.allocated - allocated,
603
+ memory_bytes: other.memsize - memsize,
604
+ object_deltas: object_delta_map(other.objects)
605
+ )
606
+ end
607
+
608
+ private
609
+
610
+ def object_delta_map(after_objects)
611
+ deltas = {} # @type var deltas: Hash[Symbol, Integer]
612
+ after_objects.each { |key, count| add_delta(deltas, key, count) }
613
+ deltas
614
+ end
615
+
616
+ def add_delta(deltas, key, count)
617
+ return if Delta.skip_key?(key)
618
+
619
+ delta = count - (objects[key] || 0)
620
+ deltas[key] = delta if delta.positive?
621
+ end
622
+ end
623
+
624
+ # Computes deltas between snapshots.
625
+ class Delta
626
+ SKIP_KEYS = %i[TOTAL FREE].freeze
627
+
628
+ def self.skip_key?(key)
629
+ SKIP_KEYS.include?(key)
630
+ end
631
+
632
+ def initialize(allocations:, memory_bytes:, object_deltas:)
633
+ @allocations = allocations
634
+ @memory_bytes = memory_bytes
635
+ @object_deltas = object_deltas
636
+ end
637
+
638
+ attr_reader :allocations, :memory_bytes, :object_deltas
639
+
640
+ def top_objects(limit: 3)
641
+ object_deltas
642
+ .sort_by { |(_, count)| -count }
643
+ .first(limit)
644
+ .map { |(key, count)| "#{key} +#{count}" }
645
+ end
646
+ end
647
+
648
+ # Tracks measurement state for a single sample.
649
+ class Measurement
650
+ def initialize(label:, before:, start:)
651
+ @label = label
652
+ @before = before
653
+ @start = start
654
+ end
655
+
656
+ def finish(after:, finish:)
657
+ delta = before.delta(after)
658
+ Sample.new(
659
+ label: label,
660
+ duration_ms: ((finish - start) * 1000.0),
661
+ allocations: delta.allocations,
662
+ memory_bytes: delta.memory_bytes,
663
+ top_objects: delta.top_objects
664
+ )
665
+ end
666
+
667
+ private
668
+
669
+ attr_reader :before, :label, :start
670
+ end
671
+
672
+ # @param stderr [IO]
673
+ def initialize(stderr: $stderr)
674
+ @stderr = stderr
675
+ @samples = [] # @type var @samples: Array[Sample]
676
+ @clock = Process.method(:clock_gettime)
677
+ end
678
+
679
+ # @param label [String]
680
+ # @return [Object]
681
+ def measure(label)
682
+ measurement = start_measurement(label)
683
+ result = yield
684
+ result
685
+ ensure
686
+ finish_measurement(measurement)
687
+ end
688
+
689
+ # @return [void]
690
+ def report
691
+ return if samples.empty?
692
+
693
+ stderr.puts("Profile:")
694
+ report_samples
695
+ report_hotspot
696
+ end
697
+
698
+ private
699
+
700
+ attr_reader :clock, :samples, :stderr
701
+
702
+ def report_samples
703
+ samples.each do |sample|
704
+ stderr.puts(sample.report_line)
705
+ top_line = sample.top_objects_line
706
+ stderr.puts(top_line) if top_line
707
+ end
708
+ end
709
+
710
+ def report_hotspot
711
+ hotspot = samples.max_by(&:duration_ms)
712
+ stderr.puts(" hotspot: #{hotspot.label}") if hotspot
713
+ end
714
+
715
+ def start_measurement(label)
716
+ Measurement.new(
717
+ label: label,
718
+ before: Snapshot.capture_before,
719
+ start: clock_time
720
+ )
721
+ end
722
+
723
+ def finish_measurement(measurement)
724
+ return unless measurement
725
+
726
+ sample = measurement.finish(after: Snapshot.capture_after, finish: clock_time)
727
+ samples << sample
728
+ end
729
+
730
+ def clock_time
731
+ clock.call(Process::CLOCK_MONOTONIC)
732
+ end
733
+ end
734
+
735
+ # Compiles and evaluates policies with a resolved query.
736
+ class PolicyEvaluator
737
+ # Create a policy evaluator.
738
+ #
739
+ # @param policy_source [String]
740
+ # @param input [Object]
741
+ # @param query [String, nil]
742
+ def initialize(policy_source, input, query, profiler: nil)
743
+ @policy_source = policy_source
744
+ @input = input
745
+ @query = query
746
+ @profiler = profiler
747
+ end
748
+
749
+ # Compile and evaluate the policy using the resolved query.
750
+ #
751
+ # @return [EvaluationResult]
752
+ def evaluate
753
+ compiled_module = measure("compile") { Ruby::Rego.compile(policy_source) }
754
+ query_path = resolve_query(compiled_module)
755
+ return EvaluationResult.new(error_message: "No default validation rule found. Provide --query.") unless query_path
756
+
757
+ build_evaluation(compiled_module, query_path)
758
+ ensure
759
+ profiler&.report
760
+ end
761
+
762
+ private
763
+
764
+ attr_reader :policy_source, :input, :query, :profiler
765
+
766
+ def resolve_query(compiled_module)
767
+ query || DefaultQueryResolver.new(compiled_module).resolve
768
+ end
769
+
770
+ def build_evaluation(compiled_module, query_path)
771
+ result = measure("evaluate") { evaluate_compiled(compiled_module, query_path) }
772
+ outcome = OutcomeBuilder.new(result, query_path).build
773
+ EvaluationResult.new(outcome: outcome)
774
+ end
775
+
776
+ def evaluate_compiled(compiled_module, query_path)
777
+ Ruby::Rego::Evaluator.new(compiled_module, input: input, data: nil).evaluate(query_path)
778
+ rescue Ruby::Rego::Error
779
+ raise
780
+ rescue StandardError => e
781
+ raise Ruby::Rego::Error.new("Rego evaluation failed: #{e.message}", location: nil), cause: e
782
+ end
783
+
784
+ def measure(label, &)
785
+ return yield unless profiler
786
+
787
+ profiler.measure(label, &)
788
+ end
789
+ end
790
+
791
+ # Builds a normalized outcome payload from evaluation results.
792
+ class OutcomeBuilder
793
+ # Create an outcome builder.
794
+ #
795
+ # @param result [Ruby::Rego::Result]
796
+ # @param query [String]
797
+ def initialize(result, query)
798
+ @result = result
799
+ @query = query
800
+ end
801
+
802
+ # Build the normalized outcome.
803
+ #
804
+ # @return [Outcome]
805
+ def build
806
+ return undefined_outcome if result.undefined?
807
+
808
+ build_defined_outcome
809
+ end
810
+
811
+ private
812
+
813
+ attr_reader :result, :query
814
+
815
+ def build_defined_outcome
816
+ value = result.value.to_ruby
817
+ errors = errors_for(value)
818
+ Outcome.new(success: errors.empty?, value: value, errors: errors)
819
+ end
820
+
821
+ def errors_for(value)
822
+ errors = errors_from_value(value)
823
+ result_errors = result.errors
824
+ errors.concat(result_errors.map(&:to_s)) unless result_errors.empty?
825
+ errors
826
+ end
827
+
828
+ def undefined_outcome
829
+ Outcome.new(success: false, value: nil, errors: [format_rule_error("undefined")])
830
+ end
831
+
832
+ def errors_from_value(value)
833
+ return [] if value == true
834
+
835
+ errors_for_non_true(value)
836
+ end
837
+
838
+ def errors_for_non_true(value)
839
+ scalar = scalar_error(value)
840
+ return scalar unless value
841
+ return collection_errors(value) if value.is_a?(Array) || value.is_a?(Set)
842
+ return hash_errors(value) if value.is_a?(Hash)
843
+
844
+ scalar
845
+ end
846
+
847
+ def scalar_error(value)
848
+ [format_rule_error(value)]
849
+ end
850
+
851
+ def collection_errors(value)
852
+ value.to_a.map { |item| format_rule_error(item) }
853
+ end
854
+
855
+ def hash_errors(value)
856
+ return [] if value.empty?
857
+
858
+ [format_rule_error(value)]
859
+ end
860
+
861
+ def format_rule_error(value)
862
+ "Rule '#{rule_name}' returned: #{value.inspect}"
863
+ end
864
+
865
+ def rule_name
866
+ @rule_name ||= query.to_s.split(".").last
867
+ end
868
+ end
869
+
870
+ # Emits human-readable or JSON output.
871
+ class OutcomeEmitter
872
+ # Emits JSON-formatted validation output.
873
+ class JsonFormatter
874
+ # @param stdout [IO]
875
+ def initialize(stdout)
876
+ @stdout = stdout
877
+ end
878
+
879
+ # @param outcome [Outcome]
880
+ # @return [void]
881
+ def emit(outcome)
882
+ payload = OutcomePayload.new(outcome).to_h
883
+ stdout.puts(JSON.generate(payload))
884
+ end
885
+
886
+ private
887
+
888
+ attr_reader :stdout
889
+ end
890
+
891
+ # Emits human-readable validation output.
892
+ class TextFormatter
893
+ # @param stdout [IO]
894
+ def initialize(stdout)
895
+ @stdout = stdout
896
+ end
897
+
898
+ # @param outcome [Outcome]
899
+ # @return [void]
900
+ def emit(outcome)
901
+ return stdout.puts("✓ Validation passed") if outcome.success?
902
+
903
+ stdout.puts("✗ Validation failed:")
904
+ outcome.errors.each { |error| stdout.puts(" - #{error}") }
905
+ end
906
+
907
+ private
908
+
909
+ attr_reader :stdout
910
+ end
911
+
912
+ # Builds a JSON-serializable payload from an outcome.
913
+ class OutcomePayload
914
+ # @param outcome [Outcome]
915
+ def initialize(outcome)
916
+ @outcome = outcome
917
+ end
918
+
919
+ # @return [Hash{Symbol => Object}]
920
+ def to_h
921
+ return { success: true, result: normalize_json(outcome.value) } if outcome.success?
922
+
923
+ { success: false, errors: outcome.errors }
924
+ end
925
+
926
+ private
927
+
928
+ attr_reader :outcome
929
+
930
+ def normalize_json(value)
931
+ case value
932
+ when Array
933
+ normalize_array(value)
934
+ when Hash
935
+ normalize_hash(value)
936
+ when Set
937
+ normalize_set(value)
938
+ else
939
+ value
940
+ end
941
+ end
942
+
943
+ def normalize_array(values)
944
+ values.map { |item| normalize_json(item) }
945
+ end
946
+
947
+ def normalize_hash(values)
948
+ values.transform_values { |item| normalize_json(item) }
949
+ end
950
+
951
+ def normalize_set(values)
952
+ values.to_a.map { |item| normalize_json(item) }
953
+ end
954
+ end
955
+
956
+ FORMATTERS = {
957
+ "json" => JsonFormatter,
958
+ "text" => TextFormatter
959
+ }.freeze
960
+
961
+ # Create an emitter for CLI output.
962
+ #
963
+ # @param stdout [IO]
964
+ # @param format [String]
965
+ def initialize(stdout, format: "text")
966
+ @formatter = FORMATTERS.fetch(format, TextFormatter).new(stdout)
967
+ end
968
+
969
+ # Emit the outcome payload.
970
+ #
971
+ # @param outcome [Outcome]
972
+ # @return [void]
973
+ def emit(outcome)
974
+ formatter.emit(outcome)
975
+ end
976
+
977
+ private
978
+
979
+ attr_reader :formatter
980
+ end
981
+
982
+ # Formats and emits CLI errors to stderr/stdout.
983
+ class ErrorReporter
984
+ # Serializes error details for JSON output.
985
+ class ErrorPayload
986
+ # Build a payload for a CLI error message.
987
+ #
988
+ # @param message [String]
989
+ # @return [ErrorPayload]
990
+ def self.from_cli_error(message)
991
+ new(message: message, type: "CLIError")
992
+ end
993
+
994
+ # Build a payload for a Rego error.
995
+ #
996
+ # @param error [Ruby::Rego::Error]
997
+ # @return [ErrorPayload]
998
+ def self.from_rego_error(error)
999
+ new(message: error.message, type: error.class.name, location: error.location)
1000
+ end
1001
+
1002
+ # @param message [String]
1003
+ # @param type [String]
1004
+ # @param location [Ruby::Rego::Location, nil]
1005
+ def initialize(message:, type:, location: nil)
1006
+ @message = message
1007
+ @type = type
1008
+ @location = location
1009
+ end
1010
+
1011
+ # @return [Hash{Symbol => Object}]
1012
+ def to_h
1013
+ payload = { success: false, error: message, type: type }
1014
+ return payload unless location
1015
+
1016
+ payload.merge(
1017
+ location: location.to_s,
1018
+ line: location.line,
1019
+ column: location.column
1020
+ )
1021
+ end
1022
+
1023
+ private
1024
+
1025
+ attr_reader :message, :type, :location
1026
+ end
1027
+
1028
+ # Emits JSON-formatted error output.
1029
+ class JsonFormatter
1030
+ # @param stdout [IO]
1031
+ # @param stderr [IO]
1032
+ def initialize(stdout:, stderr:)
1033
+ @stdout = stdout
1034
+ @stderr = stderr
1035
+ end
1036
+
1037
+ # @param message [String]
1038
+ # @param parser [OptionParser, nil]
1039
+ # @return [void]
1040
+ def error(message, _parser = nil)
1041
+ payload = ErrorPayload.from_cli_error(message).to_h
1042
+ stdout.puts(JSON.generate(payload))
1043
+ end
1044
+
1045
+ # @param error [Ruby::Rego::Error]
1046
+ # @return [void]
1047
+ def rego_error(error)
1048
+ payload = ErrorPayload.from_rego_error(error).to_h
1049
+ stdout.puts(JSON.generate(payload))
1050
+ end
1051
+
1052
+ private
1053
+
1054
+ attr_reader :stdout, :stderr
1055
+ end
1056
+
1057
+ # Emits text error output for CLI.
1058
+ class TextFormatter
1059
+ # @param stdout [IO]
1060
+ # @param stderr [IO]
1061
+ def initialize(stdout:, stderr:)
1062
+ @stdout = stdout
1063
+ @stderr = stderr
1064
+ end
1065
+
1066
+ # @param message [String]
1067
+ # @param parser [OptionParser, nil]
1068
+ # @return [void]
1069
+ def error(message, parser = nil)
1070
+ stderr.puts("Error: #{message}")
1071
+ stderr.puts(parser) if parser
1072
+ end
1073
+
1074
+ # @param error [Ruby::Rego::Error]
1075
+ # @return [void]
1076
+ def rego_error(error)
1077
+ location = error.location
1078
+ stderr.puts("Error: #{error.message}")
1079
+ stderr.puts(" at #{location}") if location
1080
+ end
1081
+
1082
+ private
1083
+
1084
+ attr_reader :stdout, :stderr
1085
+ end
1086
+
1087
+ FORMATTERS = {
1088
+ "json" => JsonFormatter,
1089
+ "text" => TextFormatter
1090
+ }.freeze
1091
+
1092
+ # Create an error reporter.
1093
+ #
1094
+ # @param stdout [IO]
1095
+ # @param stderr [IO]
1096
+ # @param format [String]
1097
+ def initialize(stdout:, stderr:, format: "text")
1098
+ @formatter = FORMATTERS.fetch(format, TextFormatter).new(stdout: stdout, stderr: stderr)
1099
+ end
1100
+
1101
+ # Emit a generic CLI error.
1102
+ #
1103
+ # @param message [String]
1104
+ # @param parser [OptionParser, nil]
1105
+ # @return [void]
1106
+ def error(message, parser = nil)
1107
+ @formatter.error(message, parser)
1108
+ end
1109
+
1110
+ # Emit a Ruby::Rego error with location details.
1111
+ #
1112
+ # @param error [Ruby::Rego::Error]
1113
+ # @return [void]
1114
+ def rego_error(error)
1115
+ @formatter.rego_error(error)
1116
+ end
1117
+
1118
+ private
1119
+
1120
+ attr_reader :formatter
1121
+ end
1122
+ end