brutal 1.6.0.beta4 → 2.0.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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Brutal
6
+ # Runs the code of a manifest, in two ways.
7
+ #
8
+ # {record} runs the recording program in a child process forked through
9
+ # Aw, as `ruby` would run the test file: compiled at the top level, with
10
+ # `self` as `main`, the magic comments of the header, `__FILE__`, `$0` and
11
+ # `__dir__` those of the test file, no arguments and nothing on the
12
+ # standard input. What the code does to the process stays in the child,
13
+ # and only data come back.
14
+ #
15
+ # {run} runs a test file with the Ruby that runs brutal, in a new process,
16
+ # as `ruby test_NAME.rb` would.
17
+ #
18
+ # @api private
19
+ module Runner
20
+ CLASS = ::Kernel.instance_method(:class)
21
+ VARIABLE = ::Kernel.instance_method(:instance_variable_get)
22
+ SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Integer, ::Float, ::Symbol].freeze
23
+ DEPTH = 100
24
+ CREATE = ::File::RDWR | ::File::CREAT | ::File::EXCL
25
+ LIMIT = 65_536
26
+
27
+ private_constant :CLASS, :VARIABLE, :SCALARS, :DEPTH, :CREATE, :LIMIT
28
+
29
+ # Raised in the child for a value that cannot leave it as data.
30
+ class Unsupported < ::StandardError
31
+ end
32
+
33
+ # What the recording program calls, as `$__brutal__`. Its state is read
34
+ # without calling its methods, which the code may redefine.
35
+ class Recorder
36
+ def initialize
37
+ @phase = nil
38
+ @snapshots = []
39
+ end
40
+
41
+ # @param phase [Integer] the index of the example about to run, or of the footer
42
+ # @return [nil]
43
+ def enter(phase)
44
+ @phase = phase
45
+ nil
46
+ end
47
+
48
+ # @param value [Object] an observed value
49
+ # @return [nil]
50
+ def observe(value)
51
+ @snapshots << Runner.snapshot(value)
52
+ nil
53
+ end
54
+ end
55
+
56
+ # Runs the recording program in a child process.
57
+ #
58
+ # @param source [String]
59
+ # @param path [String] the absolute path of the test file
60
+ # @return [Array((Integer, nil), Array<Array(Symbol, Object)>, (String, nil))] the last phase (nil for the
61
+ # header), the snapshots, and the error, if any
62
+ # @raise [Brutal::Error] when the source does not compile, or the child ends without a result
63
+ def self.record(source, path)
64
+ program = compile(source, path)
65
+ fork do
66
+ recorder = Recorder.new
67
+ $__brutal__ = recorder # rubocop:disable Style/GlobalVars
68
+ ::ARGV.clear
69
+ $PROGRAM_NAME = path
70
+ $stdin.reopen(::File::NULL)
71
+ error = evaluate(program)
72
+ [VARIABLE.bind_call(recorder, :@phase), VARIABLE.bind_call(recorder, :@snapshots), error]
73
+ end
74
+ end
75
+
76
+ # Runs a test file with `RbConfig.ruby`, from the current directory, with nothing
77
+ # on its standard input and its standard output discarded. Its standard
78
+ # error goes to a file created next to it and removed at once: a process
79
+ # the test leaves running cannot keep brutal waiting.
80
+ #
81
+ # @param path [String]
82
+ # @return [String, nil] why it failed, from its standard error, or nil when it passed
83
+ def self.run(path)
84
+ log = ::File.join(::File.dirname(path), ".#{::File.basename(path)}.#{::Process.pid}.err")
85
+ pid = nil
86
+ ::File.open(log, CREATE, 0o600, binmode: true) do |errors|
87
+ ::File.unlink(log)
88
+ pid = ::Process.spawn(::RbConfig.ruby, path, in: ::File::NULL, out: ::File::NULL, err: errors)
89
+ _, status = ::Process.wait2(pid)
90
+ pid = nil
91
+ errors.rewind
92
+ failure(errors.read(LIMIT).to_s, status) unless status.success?
93
+ end
94
+ ensure
95
+ reap(pid)
96
+ end
97
+
98
+ # The first line of the standard error that is not a warning, and how
99
+ # the process ended.
100
+ def self.failure(errors, status)
101
+ line = ::String.new(errors, encoding: ::Encoding::UTF_8).scrub.lines.find { |text| !text.include?(": warning: ") }
102
+ "#{line&.chomp || "no message"} (#{status(status)})"
103
+ end
104
+
105
+ # Converts an observed value to data made of core classes only.
106
+ #
107
+ # @param value [Object]
108
+ # @return [Array(Symbol, Object)] `[:value, data]` or `[:unsupported, reason]`
109
+ def self.snapshot(value)
110
+ [:value, plain(value, 0)]
111
+ rescue Unsupported => e
112
+ [:unsupported, e.message]
113
+ end
114
+
115
+ # Runs a block in a child process.
116
+ def self.fork(&)
117
+ raise Error, "brutal needs fork(2), which this Ruby does not have" unless ::Aw.supported?
118
+
119
+ ::Aw.fork!(&)
120
+ rescue ::Aw::ChildError => e
121
+ raise Error, "the process running the code ended without a result (#{status(e.status)})"
122
+ end
123
+
124
+ # Compiles the source, in the parent: nothing runs.
125
+ def self.compile(source, path)
126
+ ::RubyVM::InstructionSequence.compile(source, path, path, 1)
127
+ rescue ::SyntaxError => e
128
+ raise Error, "the code does not compile: #{e.message}"
129
+ end
130
+
131
+ # Runs the program at the top level; returns what it raised, as text.
132
+ def self.evaluate(program)
133
+ program.eval
134
+ nil
135
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
136
+ describe(e)
137
+ end
138
+
139
+ # The class and the message of an exception, built defensively: its
140
+ # methods may raise.
141
+ def self.describe(error)
142
+ name = CLASS.bind_call(error).name || "an anonymous exception"
143
+ message = error.message.to_s.encode(::Encoding::UTF_8, invalid: :replace, undef: :replace)
144
+ "#{name}: #{message}"
145
+ rescue ::Exception # rubocop:disable Lint/RescueException
146
+ "#{name || "an exception"}, whose message cannot be read"
147
+ end
148
+
149
+ # A copy of the value made of the classes Literal writes, by their exact
150
+ # class: an instance of a subclass, or of a class the parent does not
151
+ # know, cannot come back.
152
+ def self.plain(value, depth)
153
+ raise Unsupported, "is nested more than #{DEPTH} levels deep, or contains itself" if depth > DEPTH
154
+ raise Unsupported, "is a BasicObject" unless ::Kernel === value # rubocop:disable Style/CaseEquality
155
+
156
+ copy(value, CLASS.bind_call(value), depth + 1)
157
+ end
158
+
159
+ # A copy of a value of the given class.
160
+ def self.copy(value, klass, depth)
161
+ return value if SCALARS.include?(klass)
162
+ return ::String.new(value) if klass.equal?(::String)
163
+ return value.map { |item| plain(item, depth) } if klass.equal?(::Array)
164
+ return value.to_h { |key, item| [plain(key, depth), plain(item, depth)] } if klass.equal?(::Hash)
165
+
166
+ raise Unsupported, "is an instance of #{klass.name || "an anonymous class"}"
167
+ end
168
+
169
+ # How a process ended.
170
+ def self.status(status)
171
+ return "killed by SIG#{::Signal.signame(status.termsig)}" if status.signaled?
172
+
173
+ "exit status #{status.exitstatus}"
174
+ end
175
+
176
+ # Kills and reaps a process left running by an interruption.
177
+ def self.reap(pid)
178
+ return if pid.nil?
179
+
180
+ ::Process.kill(:KILL, pid)
181
+ ::Process.wait(pid)
182
+ end
183
+
184
+ private_class_method :failure, :fork, :compile, :evaluate, :describe, :plain, :copy, :status, :reap
185
+ end
186
+ end
data/lib/brutal.rb CHANGED
@@ -1,40 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- %w[
4
- format
5
- manifest
6
- ].each { |filename| require_relative(File.join("brutal", filename)) }
3
+ require "aw"
7
4
 
8
- # The Brutal namespace.
9
- class Brutal
10
- attr_reader :engine
5
+ # Writes plain Ruby test files from YAML manifests. A manifest gives a
6
+ # subject, contexts to combine and actuals to observe; brutal runs every
7
+ # combination in a child process, records what the code returns, and writes
8
+ # a test file that checks these values with `eql?`, without any framework.
9
+ #
10
+ # The test file describes what the code does, not what it should do: read
11
+ # it before trusting it.
12
+ #
13
+ # @example
14
+ # Brutal.generate("test/calc_brutal.yaml").path # => "test/test_calc.rb"
15
+ module Brutal
16
+ # Raised for a manifest brutal cannot turn into a test file: the message
17
+ # says why, without the path of the manifest.
18
+ class Error < ::StandardError
19
+ end
20
+
21
+ # A test file to write.
22
+ #
23
+ # @!attribute [r] path
24
+ # @return [String] where to write it: `test_NAME.rb`, next to the manifest
25
+ # @!attribute [r] source
26
+ # @return [String] its content
27
+ Test = ::Data.define(:path, :source)
11
28
 
12
- def initialize(format)
13
- @engine = Format.const_get(format)
29
+ # Reads a manifest, runs its code in a child process to record the values
30
+ # it returns, and builds the test file. Nothing is written.
31
+ #
32
+ # @param manifest_path [String] the path of a `NAME_brutal.yaml` file
33
+ # @return [Brutal::Test]
34
+ # @raise [Brutal::Error] when the manifest is not valid, the code does not
35
+ # compile, raises, ends its process or skips a check, a value cannot be
36
+ # written as a literal, or this Ruby cannot fork
37
+ # @raise [SystemCallError] when the manifest cannot be read
38
+ def self.generate(manifest_path)
39
+ program = Program.new(Manifest.read(manifest_path))
40
+ path = Output.target(manifest_path)
41
+ absolute = ::File.join(::File.realpath(::File.dirname(path)), ::File.basename(path))
42
+ Test.new(path:, source: program.test(literals(program, Runner.record(program.record, absolute))))
14
43
  end
15
44
 
16
- def call(pathname)
17
- manifest = Manifest.parse_file(pathname)
18
- scaffold = brutalizer(manifest)
19
- pathname = new_pathname(pathname)
20
- pathname.write(scaffold)
45
+ # Writes a test file, once the Ruby that runs brutal has run it, in a new
46
+ # process, and it passed: the code runs a second time. An unchanged file is
47
+ # run too.
48
+ #
49
+ # @param test [Brutal::Test]
50
+ # @return [void]
51
+ # @raise [Brutal::Error] when the test file fails, or cannot replace the file in place
52
+ # @raise [SystemCallError] when the file cannot be written
53
+ def self.write(test)
54
+ Output.write(test.path, test.source) do |temporary|
55
+ error = Runner.run(temporary)
56
+ # The name of the temporary file, which is ASCII, gives way to the test
57
+ # file's: the directory is the same, whatever the encoding of its name.
58
+ error &&= error.gsub(::File.basename(temporary), ::File.basename(test.path))
59
+ raise Error, "the test file fails when ruby runs it: #{error}" if error
60
+ end
21
61
  end
22
62
 
23
- private
63
+ # The literal of each observed value, once the recording ran to its end.
64
+ def self.literals(program, recording)
65
+ phase, snapshots, error = recording
66
+ raise Error, "#{program.phase(phase)}: #{error}" if error
67
+ unless snapshots.size == program.size
68
+ raise Error, "observed values: #{snapshots.size}, expected: #{program.size}; a check was skipped or repeated"
69
+ end
24
70
 
25
- def brutalizer(conf)
26
- engine.new(conf.header, conf.before, conf.subject, conf.after, conf.footer, *conf.actuals, **conf.contexts)
27
- end
71
+ snapshots.each_with_index.map do |(kind, data), index|
72
+ literal, reason = literal(kind, data)
73
+ raise Error, "#{program.label(index)} #{reason}, which brutal cannot write as a literal" if reason
28
74
 
29
- def new_pathname(pathname)
30
- pathname.dirname + "#{new_prefix}#{pathname.basename.sub(Manifest::File::Name::SUFFIX_REGEX, new_suffix)}"
75
+ literal
76
+ end
31
77
  end
32
78
 
33
- def new_prefix
34
- engine.const_get(:Filename).const_get(:PREFIX)
35
- end
79
+ # `[literal, nil]`, or `[nil, reason]` for a value that has no literal.
80
+ def self.literal(kind, data)
81
+ return [nil, data] if kind == :unsupported
36
82
 
37
- def new_suffix
38
- engine.const_get(:Filename).const_get(:SUFFIX)
83
+ [Literal.dump(data), nil]
84
+ rescue Error => e
85
+ [nil, e.message]
39
86
  end
87
+
88
+ private_class_method :literals, :literal
40
89
  end
90
+
91
+ require_relative "brutal/cli"
92
+ require_relative "brutal/literal"
93
+ require_relative "brutal/manifest"
94
+ require_relative "brutal/output"
95
+ require_relative "brutal/program"
96
+ require_relative "brutal/runner"
metadata CHANGED
@@ -1,128 +1,32 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brutal
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0.beta4
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-09-19 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: bundler
14
+ name: aw
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :development
19
+ version: '1.0'
20
+ type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
27
- - !ruby/object:Gem::Dependency
28
- name: rake
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
- - !ruby/object:Gem::Dependency
42
- name: rubocop-gitlab-security
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: rubocop-md
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
- - !ruby/object:Gem::Dependency
70
- name: rubocop-performance
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
- - !ruby/object:Gem::Dependency
84
- name: rubocop-rake
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '0'
97
- - !ruby/object:Gem::Dependency
98
- name: rubocop-thread_safety
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '0'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '0'
111
- - !ruby/object:Gem::Dependency
112
- name: yard
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - ">="
116
- - !ruby/object:Gem::Version
117
- version: '0'
118
- type: :development
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - ">="
123
- - !ruby/object:Gem::Version
124
- version: '0'
125
- description: A code-first approach to automate the writing of unit tests.
26
+ version: '1.0'
27
+ description: 'Writes framework-free Ruby test files from YAML manifests: runs every
28
+ combination of contexts in a child process, and checks each observed value with
29
+ eql?.'
126
30
  email: contact@cyril.email
127
31
  executables:
128
32
  - brutal
@@ -133,19 +37,22 @@ files:
133
37
  - README.md
134
38
  - bin/brutal
135
39
  - lib/brutal.rb
136
- - lib/brutal/command_line_arguments_parser.rb
137
- - lib/brutal/format.rb
138
- - lib/brutal/format/ruby.rb
139
- - lib/brutal/format/ruby/filename.rb
40
+ - lib/brutal/cli.rb
41
+ - lib/brutal/literal.rb
140
42
  - lib/brutal/manifest.rb
141
- - lib/brutal/manifest/file.rb
142
- - lib/brutal/manifest/file/name.rb
143
- homepage: https://github.com/fixrb/brutal
43
+ - lib/brutal/output.rb
44
+ - lib/brutal/program.rb
45
+ - lib/brutal/runner.rb
46
+ homepage: https://fixrb.dev/gems/brutal/
144
47
  licenses:
145
48
  - MIT
146
49
  metadata:
50
+ bug_tracker_uri: https://github.com/fixrb/brutal/issues
51
+ changelog_uri: https://github.com/fixrb/brutal#changes-in-20
52
+ documentation_uri: https://rubydoc.info/gems/brutal
147
53
  rubygems_mfa_required: 'true'
148
- post_install_message:
54
+ source_code_uri: https://github.com/fixrb/brutal
55
+ post_install_message:
149
56
  rdoc_options: []
150
57
  require_paths:
151
58
  - lib
@@ -153,15 +60,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
153
60
  requirements:
154
61
  - - ">="
155
62
  - !ruby/object:Gem::Version
156
- version: 2.7.0
63
+ version: 3.3.0
157
64
  required_rubygems_version: !ruby/object:Gem::Requirement
158
65
  requirements:
159
- - - ">"
66
+ - - ">="
160
67
  - !ruby/object:Gem::Version
161
- version: 1.3.1
68
+ version: '0'
162
69
  requirements: []
163
- rubygems_version: 3.1.6
164
- signing_key:
70
+ rubygems_version: 3.5.22
71
+ signing_key:
165
72
  specification_version: 4
166
- summary: A code-first approach to automate the writing of unit tests.
73
+ summary: Plain Ruby tests, written from what the code does
167
74
  test_files: []
@@ -1,115 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative File.join("manifest", "file", "name")
4
-
5
- require "pathname"
6
-
7
- class Brutal
8
- # Accept an arbitrary number of arguments passed from the command-line.
9
- class CommandLineArgumentsParser
10
- MANIFEST_FILENAME_PATTERN = ::File.join("**", Manifest::File::Name::SUFFIX_PATTERN)
11
- CURRENT_DIRECTORY_CONTEXT = "."
12
- GEM_NAME = "brutal"
13
- HELP_OPTION = "--help"
14
- VERSION_OPTION = "--version"
15
- RUBY_FORMAT_OPTION = "--format=ruby"
16
- DEFAULT_FORMAT_OPTION = RUBY_FORMAT_OPTION
17
-
18
- FORMAT_OPTIONS = {
19
- RUBY_FORMAT_OPTION => :Ruby
20
- }.freeze
21
-
22
- DEFAULT_FORMAT = FORMAT_OPTIONS.fetch(DEFAULT_FORMAT_OPTION)
23
- DEFAULT_FORMATS = [DEFAULT_FORMAT].freeze
24
-
25
- attr_reader :pathnames
26
-
27
- def initialize(*args)
28
- args.each do |arg|
29
- help! if arg == HELP_OPTION
30
- version! if arg == VERSION_OPTION
31
- end
32
-
33
- @formats = []
34
- @pathnames = []
35
-
36
- args << CURRENT_DIRECTORY_CONTEXT unless any_path_given?(*args)
37
- args.each { |arg| parse!(arg) }
38
- end
39
-
40
- def call
41
- [formats, pathnames]
42
- end
43
-
44
- private
45
-
46
- def formats
47
- @formats.empty? ? DEFAULT_FORMATS : @formats
48
- end
49
-
50
- def parse!(arg)
51
- case arg
52
- when RUBY_FORMAT_OPTION
53
- @formats << FORMAT_OPTIONS.fetch(RUBY_FORMAT_OPTION)
54
- else
55
- load!(::Pathname.new(arg))
56
- end
57
- end
58
-
59
- def help!
60
- puts help_message
61
- exit
62
- end
63
-
64
- def help_message
65
- <<~TXT
66
- Usage: #{$PROGRAM_NAME} [options] [files or directories]
67
-
68
- --format=FORMAT Choose "ruby" (default).
69
- --help Display this help.
70
- --version Display the version.
71
- TXT
72
- end
73
-
74
- def load!(pathname)
75
- if pathname.directory?
76
- pathname.glob(MANIFEST_FILENAME_PATTERN).each { |filename| load!(filename) }
77
- elsif pathname.file?
78
- load_file!(pathname)
79
- else
80
- raise ::ArgumentError, "#{pathname} is neither a file nor a directory."
81
- end
82
- end
83
-
84
- def load_file!(pathname)
85
- if pathname.fnmatch?(MANIFEST_FILENAME_PATTERN, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH)
86
- @pathnames << pathname
87
- else
88
- warn "Skipping #{pathname} because not matched against #{MANIFEST_FILENAME_PATTERN}."
89
- end
90
- end
91
-
92
- def any_path_given?(*args)
93
- path_args(*args).any?
94
- end
95
-
96
- def path_args(*args)
97
- args - FORMAT_OPTIONS.keys
98
- end
99
-
100
- def version!
101
- abort "Gem not found on the system. Unknown version." if not_loaded_spec?
102
-
103
- puts loaded_spec.version
104
- exit
105
- end
106
-
107
- def loaded_spec
108
- ::Gem.loaded_specs[GEM_NAME]
109
- end
110
-
111
- def not_loaded_spec?
112
- loaded_spec.nil?
113
- end
114
- end
115
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Brutal
4
- module Format
5
- class Ruby
6
- module Filename
7
- # Brutal Ruby format file prefix.
8
- PREFIX = "test_"
9
-
10
- # Brutal Ruby format file suffix.
11
- SUFFIX = ".rb"
12
- end
13
- end
14
- end
15
- end