torikago 0.0.1

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,86 @@
1
+ require "pathname"
2
+ require "yaml"
3
+
4
+ module Torikago
5
+ # Runtime entrypoint for all cross-module calls. It validates the package API
6
+ # manifest before dispatching into the target module container.
7
+ class Gateway
8
+ class << self
9
+ def call(...)
10
+ Torikago.gateway.call(...)
11
+ end
12
+ end
13
+
14
+ def initialize(registry:, configuration:, manifest_loader: nil)
15
+ @registry = registry
16
+ @configuration = configuration
17
+ @manifest_loader = manifest_loader || method(:load_manifest)
18
+ @manifests = {}
19
+ end
20
+
21
+ def call(public_api_class_name, *args, **kwargs)
22
+ target_module = infer_target_module(public_api_class_name)
23
+ caller_module = CurrentExecution.current_box
24
+
25
+ # Validation happens before resolving the container so denied calls do not
26
+ # accidentally boot or load the target module.
27
+ validate_public_api!(target_module, public_api_class_name, caller_module)
28
+ registry.resolve(target_module).call(public_api_class_name, *args, **kwargs)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :configuration, :manifest_loader, :manifests, :registry
34
+
35
+ def validate_public_api!(target_module, public_api_class_name, caller_module)
36
+ target_name = target_module.to_sym
37
+ definition = configuration.fetch(target_name)
38
+ manifest = manifests.fetch(target_name) do
39
+ manifests[target_name] = manifest_loader.call(definition)
40
+ end
41
+
42
+ public_api_entry = exported_package_apis(manifest).fetch(public_api_class_name, nil)
43
+ if public_api_entry.nil?
44
+ raise PublicApiError, "package api export not declared for #{target_name}: #{public_api_class_name}"
45
+ end
46
+
47
+ return if caller_module.nil?
48
+
49
+ caller_name = caller_module.to_sym
50
+ # A module may always call its own public API; allowed_callers only governs
51
+ # calls crossing from one module box into another.
52
+ return if caller_name == target_name
53
+ return if dependency_allowed?(public_api_entry, caller_name)
54
+
55
+ raise DependencyError,
56
+ "module dependency not allowed: #{caller_name} -> #{target_name}##{public_api_class_name}"
57
+ end
58
+
59
+ def load_manifest(definition)
60
+ manifest_path = package_api_manifest_path(definition)
61
+
62
+ unless manifest_path.exist?
63
+ raise DependencyError,
64
+ "package_api manifest not found for #{definition.name}: #{manifest_path}"
65
+ end
66
+
67
+ YAML.safe_load(manifest_path.read, permitted_classes: [], aliases: false) || {}
68
+ end
69
+
70
+ def package_api_manifest_path(definition)
71
+ Pathname(definition.root).join("package_api.yml")
72
+ end
73
+
74
+ def infer_target_module(public_api_class_name)
75
+ public_api_class_name.split("::").first.downcase.to_sym
76
+ end
77
+
78
+ def exported_package_apis(manifest)
79
+ manifest.fetch("exports") { manifest.fetch("public_api", {}) }
80
+ end
81
+
82
+ def dependency_allowed?(public_api_entry, caller_name)
83
+ Array(public_api_entry["allowed_callers"]).map(&:to_sym).include?(caller_name)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,101 @@
1
+ require "pathname"
2
+ require "yaml"
3
+
4
+ module Torikago
5
+ # Regenerates package_api.yml from files under a module's public API
6
+ # entrypoint while preserving caller permissions already chosen by humans.
7
+ class PackageApiUpdater
8
+ def initialize(configuration:)
9
+ @configuration = configuration
10
+ end
11
+
12
+ def call(module_name = nil)
13
+ definitions_for(module_name).each_with_object({}) do |definition, updates|
14
+ manifest_path = definition.root.join("package_api.yml")
15
+ existing_manifest = load_manifest(manifest_path)
16
+ updated_manifest = build_manifest(definition, existing_manifest)
17
+
18
+ manifest_path.write(render_manifest(updated_manifest))
19
+ updates[definition.name] = manifest_path
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :configuration
26
+
27
+ def definitions_for(module_name)
28
+ return configuration.each_definition.to_a if module_name.nil?
29
+
30
+ [configuration.fetch(module_name)]
31
+ end
32
+
33
+ def load_manifest(path)
34
+ return {} unless path.exist?
35
+
36
+ YAML.safe_load(path.read, permitted_classes: [], aliases: false) || {}
37
+ end
38
+
39
+ def build_manifest(definition, existing_manifest)
40
+ existing_public_api = exported_package_apis(existing_manifest)
41
+
42
+ public_api_entries = discover_public_api_classes(definition).each_with_object({}) do |class_name, entries|
43
+ existing_entry = existing_public_api.fetch(class_name, {})
44
+
45
+ # update-package-api owns discovery, not policy. Keep allowed_callers so
46
+ # the command does not silently widen or narrow module dependencies.
47
+ entries[class_name] = {
48
+ "allowed_callers" => Array(existing_entry["allowed_callers"]).map(&:to_s)
49
+ }
50
+ end
51
+
52
+ { "exports" => public_api_entries }
53
+ end
54
+
55
+ def exported_package_apis(manifest)
56
+ manifest.fetch("exports") { manifest.fetch("public_api", {}) }
57
+ end
58
+
59
+ def render_manifest(manifest)
60
+ <<~YAML
61
+ # This file declares the Package APIs exported by this module.
62
+ #
63
+ # Each key under exports is a class that may be called through:
64
+ #
65
+ # Torikago::Gateway.call("ModuleName::SomeQuery")
66
+ #
67
+ # allowed_callers lists other modules that may call that export. The
68
+ # host app and the module itself are allowed implicitly.
69
+ #
70
+ YAML
71
+ .then { |header| header + YAML.dump(manifest) }
72
+ end
73
+
74
+ def discover_public_api_classes(definition)
75
+ Dir[public_api_root(definition).join("**/*.rb").to_s].sort.map do |path|
76
+ relative_path = Pathname(path).relative_path_from(public_api_root(definition)).to_s
77
+ class_name_from(relative_path)
78
+ end
79
+ end
80
+
81
+ def public_api_root(definition)
82
+ return definition.root.join("app/package_api") if definition.entrypoint.nil?
83
+
84
+ # Match EngineContainer and Checker: directory entrypoints are roots,
85
+ # while file entrypoints imply implementations live beside the file.
86
+ candidate = definition.root.join(definition.entrypoint)
87
+ return candidate if candidate.directory?
88
+ return candidate unless candidate.extname == ".rb"
89
+
90
+ candidate.dirname
91
+ end
92
+
93
+ def class_name_from(relative_path)
94
+ relative_path.delete_suffix(".rb").split("/").map { |segment| camelize(segment) }.join("::")
95
+ end
96
+
97
+ def camelize(segment)
98
+ segment.split("_").map(&:capitalize).join
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,34 @@
1
+ module Torikago
2
+ # Lazily builds one EngineContainer per registered module and reuses it for
3
+ # subsequent Gateway calls.
4
+ class Registry
5
+ def initialize(configuration:, &container_factory)
6
+ @configuration = configuration
7
+ @containers = {}
8
+ @container_factory = container_factory || method(:build_container)
9
+ end
10
+
11
+ def resolve(name)
12
+ normalized_name = name.to_sym
13
+
14
+ @containers.fetch(normalized_name) do
15
+ definition = @configuration.fetch(name)
16
+ container = @container_factory.call(definition)
17
+
18
+ @containers[definition.name] = container
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def build_container(definition)
25
+ EngineContainer.new(
26
+ name: definition.name,
27
+ module_root: definition.root,
28
+ entrypoint: definition.entrypoint,
29
+ setup: definition.setup,
30
+ gemfile: definition.gemfile
31
+ )
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module Torikago
2
+ VERSION = "0.0.1"
3
+ end
data/lib/torikago.rb ADDED
@@ -0,0 +1,71 @@
1
+ require_relative "torikago/configuration"
2
+ require_relative "torikago/current_execution"
3
+ require_relative "torikago/checker"
4
+ require_relative "torikago/cli"
5
+ require_relative "torikago/engine_container"
6
+ require_relative "torikago/errors"
7
+ require_relative "torikago/gateway"
8
+ require_relative "torikago/package_api_updater"
9
+ require_relative "torikago/registry"
10
+ require_relative "torikago/version"
11
+
12
+ module Torikago
13
+ class << self
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ # Rebuild runtime collaborators after configuration changes so newly
21
+ # registered modules are visible to future Gateway calls.
22
+ reset_runtime_state!
23
+ configuration
24
+ end
25
+
26
+ def gateway
27
+ @gateway ||= Gateway.new(registry: registry, configuration: configuration)
28
+ end
29
+
30
+ def registry
31
+ @registry ||= Registry.new(configuration: configuration)
32
+ end
33
+
34
+ def version
35
+ VERSION
36
+ end
37
+
38
+ private
39
+
40
+ def rails_app?
41
+ return false unless defined?(Rails)
42
+ return false unless Rails.respond_to?(:application)
43
+
44
+ !Rails.application.nil?
45
+ end
46
+
47
+ def ruby_box_enabled?
48
+ ENV["RUBY_BOX"] == "1"
49
+ end
50
+
51
+ def warn_if_ruby_box_is_disabled_in_rails!
52
+ return unless rails_app?
53
+ return if ruby_box_enabled?
54
+
55
+ # The gem can run without Ruby::Box for local development, but a Rails app
56
+ # using torikago usually expects runtime isolation to be active.
57
+ warn "[warn] torikago is loaded in a Rails app without RUBY_BOX=1; Ruby::Box isolation is disabled."
58
+ end
59
+
60
+ def reset_runtime_state!
61
+ @gateway = nil
62
+ @registry = nil
63
+ end
64
+ end
65
+
66
+ def version
67
+ VERSION
68
+ end
69
+ end
70
+
71
+ Torikago.send(:warn_if_ruby_box_is_disabled_in_rails!)
@@ -0,0 +1,9 @@
1
+ require "minitest/autorun"
2
+
3
+ ENV["RUBY_BOX"] = "1"
4
+ ENV["RAILS_ENV"] = "test"
5
+
6
+ lib_dir = File.expand_path("../lib", __dir__)
7
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
8
+
9
+ require "torikago"
@@ -0,0 +1,164 @@
1
+ require_relative "../test_helper"
2
+ require "fileutils"
3
+ require "tmpdir"
4
+
5
+ class TorikagoCheckerTest < Minitest::Test
6
+ def test_check_returns_no_errors_for_declared_calls
7
+ with_project do |root, configuration|
8
+ FileUtils.mkdir_p(File.join(root, "modules/foo"))
9
+ File.write(
10
+ File.join(root, "modules/foo/service.rb"),
11
+ <<~RUBY
12
+ class FooService
13
+ def call
14
+ Torikago::Gateway.call("Bar::SubmitOrderCommand")
15
+ end
16
+ end
17
+ RUBY
18
+ )
19
+
20
+ checker = Torikago::Checker.new(
21
+ configuration: configuration,
22
+ source_roots: [File.join(root, "modules")]
23
+ )
24
+
25
+ result = Dir.chdir(root) { checker.call }
26
+
27
+ assert result.ok?
28
+ assert_equal [], result.errors
29
+ assert_equal 2, result.scanned_file_count
30
+ assert_equal 1, result.gateway_call_count
31
+ assert_equal 2, result.manifest_count
32
+ end
33
+ end
34
+
35
+ def test_check_reports_undeclared_calls_and_missing_public_api_files
36
+ with_project do |root, configuration|
37
+ FileUtils.mkdir_p(File.join(root, "modules/foo"))
38
+ File.write(
39
+ File.join(root, "modules/foo/service.rb"),
40
+ <<~RUBY
41
+ class FooService
42
+ def call
43
+ Torikago::Gateway.call("Bar::MissingCommand")
44
+ end
45
+ end
46
+ RUBY
47
+ )
48
+
49
+ File.write(
50
+ File.join(root, "modules/bar/package_api.yml"),
51
+ <<~YAML
52
+ exports:
53
+ Bar::MissingCommand:
54
+ allowed_callers:
55
+ - foo
56
+ YAML
57
+ )
58
+
59
+ checker = Torikago::Checker.new(
60
+ configuration: configuration,
61
+ source_roots: [File.join(root, "modules")]
62
+ )
63
+
64
+ result = Dir.chdir(root) { checker.call }
65
+
66
+ refute result.ok?
67
+ assert_equal 1, result.errors.size
68
+ assert_match(/matching file/, result.errors.first)
69
+ end
70
+ end
71
+
72
+ def test_check_uses_a_configured_entrypoint_directory_when_matching_manifest_files
73
+ with_project(entrypoint: "components/public_api") do |root, configuration|
74
+ FileUtils.mkdir_p(File.join(root, "modules/foo"))
75
+ File.write(
76
+ File.join(root, "modules/foo/service.rb"),
77
+ <<~RUBY
78
+ class FooService
79
+ def call
80
+ Torikago::Gateway.call("Bar::SubmitOrderCommand")
81
+ end
82
+ end
83
+ RUBY
84
+ )
85
+
86
+ checker = Torikago::Checker.new(
87
+ configuration: configuration,
88
+ source_roots: [File.join(root, "modules")]
89
+ )
90
+
91
+ result = Dir.chdir(root) { checker.call }
92
+
93
+ assert result.ok?
94
+ assert_equal [], result.errors
95
+ end
96
+ end
97
+
98
+ def test_check_does_not_match_manifest_entries_against_parent_when_configured_entrypoint_directory_is_missing
99
+ Dir.mktmpdir("torikago-checker") do |root|
100
+ foo_root = File.join(root, "modules/foo")
101
+ FileUtils.mkdir_p(File.join(foo_root, "app/models/foo"))
102
+ File.write(File.join(foo_root, "app/models/foo/widget.rb"), "")
103
+ File.write(
104
+ File.join(foo_root, "package_api.yml"),
105
+ <<~YAML
106
+ exports:
107
+ Models::Foo::Widget:
108
+ allowed_callers: []
109
+ YAML
110
+ )
111
+
112
+ configuration = Torikago::Configuration.new
113
+ configuration.register(:foo, root: foo_root, entrypoint: "app/package_api")
114
+
115
+ checker = Torikago::Checker.new(
116
+ configuration: configuration,
117
+ source_roots: [File.join(root, "modules")]
118
+ )
119
+
120
+ result = Dir.chdir(root) { checker.call }
121
+
122
+ refute result.ok?
123
+ assert_equal 1, result.errors.size
124
+ assert_match(/matching file/, result.errors.first)
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def with_project(entrypoint: nil)
131
+ Dir.mktmpdir("torikago-checker") do |root|
132
+ foo_root = File.join(root, "modules/foo")
133
+ bar_root = File.join(root, "modules/bar")
134
+ public_api_root = entrypoint ? File.join(bar_root, entrypoint, "bar") : File.join(bar_root, "app/package_api/bar")
135
+ FileUtils.mkdir_p(public_api_root)
136
+
137
+ File.write(
138
+ File.join(bar_root, "package_api.yml"),
139
+ <<~YAML
140
+ exports:
141
+ Bar::SubmitOrderCommand:
142
+ allowed_callers:
143
+ - foo
144
+ YAML
145
+ )
146
+
147
+ File.write(
148
+ File.join(public_api_root, "submit_order_command.rb"),
149
+ <<~RUBY
150
+ class Bar::SubmitOrderCommand
151
+ def call
152
+ end
153
+ end
154
+ RUBY
155
+ )
156
+
157
+ configuration = Torikago::Configuration.new
158
+ configuration.register(:foo, root: foo_root)
159
+ configuration.register(:bar, root: bar_root, entrypoint: entrypoint)
160
+
161
+ yield root, configuration
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,143 @@
1
+ require_relative "../test_helper"
2
+ require "stringio"
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ class TorikagoCliTest < Minitest::Test
8
+ def test_help_option_prints_usage
9
+ stdout = StringIO.new
10
+ stderr = StringIO.new
11
+
12
+ exit_code = Torikago::CLI.new(stdout: stdout, stderr: stderr).run(["--help"])
13
+
14
+ assert_equal 0, exit_code
15
+ assert_match(/usage: torikago COMMAND/, stdout.string)
16
+ assert_match(/init/, stdout.string)
17
+ assert_match(/configured public API entrypoint/, stdout.string)
18
+ assert_equal "", stderr.string
19
+ end
20
+
21
+ def test_init_interactively_generates_manifests_and_initializer
22
+ Dir.mktmpdir("torikago-init") do |root|
23
+ FileUtils.mkdir_p(File.join(root, "config/initializers"))
24
+ FileUtils.mkdir_p(File.join(root, "modules/foo/app/package_api/foo"))
25
+ FileUtils.mkdir_p(File.join(root, "modules/bar/components/public_api/bar"))
26
+
27
+ File.write(
28
+ File.join(root, "modules/foo/app/package_api/foo/list_products_query.rb"),
29
+ <<~RUBY
30
+ class Foo::ListProductsQuery
31
+ end
32
+ RUBY
33
+ )
34
+
35
+ File.write(
36
+ File.join(root, "modules/bar/components/public_api/bar/submit_order_command.rb"),
37
+ <<~RUBY
38
+ class Bar::SubmitOrderCommand
39
+ end
40
+ RUBY
41
+ )
42
+
43
+ stdin = StringIO.new("modules\ncomponents/public_api\n\nY\n")
44
+ stdout = StringIO.new
45
+ stderr = StringIO.new
46
+
47
+ exit_code = Dir.chdir(root) do
48
+ Torikago::CLI.new(stdin: stdin, stdout: stdout, stderr: stderr).run(["init"])
49
+ end
50
+
51
+ assert_equal 0, exit_code
52
+ assert_equal "", stderr.string
53
+
54
+ foo_manifest = YAML.safe_load(File.read(File.join(root, "modules/foo/package_api.yml")))
55
+ bar_manifest = YAML.safe_load(File.read(File.join(root, "modules/bar/package_api.yml")))
56
+ initializer = File.read(File.join(root, "config/initializers/torikago.rb"))
57
+
58
+ assert_equal({ "allowed_callers" => [] }, foo_manifest.dig("exports", "Foo::ListProductsQuery"))
59
+ assert_equal({ "allowed_callers" => [] }, bar_manifest.dig("exports", "Bar::SubmitOrderCommand"))
60
+ assert_match(/root: Rails\.root\.join\("modules\/bar"\)/, initializer)
61
+ assert_match(/entrypoint: "components\/public_api"/, initializer)
62
+ assert_match(/root: Rails\.root\.join\("modules\/foo"\)/, initializer)
63
+ assert_match(/entrypoint: "app\/package_api"/, initializer)
64
+ assert_match(/Run `torikago update-package-api` now\?/, stdout.string)
65
+ assert_match(/updated 2 package_api manifests/, stdout.string)
66
+ end
67
+ end
68
+
69
+ def test_check_prints_summary_information
70
+ with_cli_project do |root|
71
+ stdout = StringIO.new
72
+ stderr = StringIO.new
73
+
74
+ exit_code = Dir.chdir(root) do
75
+ Torikago::CLI.new(stdout: stdout, stderr: stderr).run(["check"])
76
+ end
77
+
78
+ assert_equal 0, exit_code
79
+ assert_match(/scanned 2 Ruby files/, stdout.string)
80
+ assert_match(/found 1 Gateway\.call usages/, stdout.string)
81
+ assert_match(/validated 2 package_api manifests/, stdout.string)
82
+ assert_equal "", stderr.string
83
+ end
84
+ end
85
+
86
+ def test_update_package_api_prints_updated_count
87
+ with_cli_project do |root|
88
+ stdout = StringIO.new
89
+ stderr = StringIO.new
90
+
91
+ exit_code = Dir.chdir(root) do
92
+ Torikago::CLI.new(stdout: stdout, stderr: stderr).run(["update-package-api", "bar"])
93
+ end
94
+
95
+ assert_equal 0, exit_code
96
+ assert_match(/updated .*modules\/bar\/package_api\.yml/, stdout.string)
97
+ assert_match(/updated 1 package_api manifest/, stdout.string)
98
+ assert_equal "", stderr.string
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def with_cli_project
105
+ Dir.mktmpdir("torikago-cli") do |root|
106
+ FileUtils.mkdir_p(File.join(root, "modules/foo"))
107
+ FileUtils.mkdir_p(File.join(root, "modules/bar/app/package_api/bar"))
108
+
109
+ File.write(
110
+ File.join(root, "modules/foo/service.rb"),
111
+ <<~RUBY
112
+ class FooService
113
+ def call
114
+ Torikago::Gateway.call("Bar::SubmitOrderCommand")
115
+ end
116
+ end
117
+ RUBY
118
+ )
119
+
120
+ File.write(
121
+ File.join(root, "modules/bar/package_api.yml"),
122
+ <<~YAML
123
+ exports:
124
+ Bar::SubmitOrderCommand:
125
+ allowed_callers:
126
+ - foo
127
+ YAML
128
+ )
129
+
130
+ File.write(
131
+ File.join(root, "modules/bar/app/package_api/bar/submit_order_command.rb"),
132
+ <<~RUBY
133
+ class Bar::SubmitOrderCommand
134
+ def call
135
+ end
136
+ end
137
+ RUBY
138
+ )
139
+
140
+ yield root
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,74 @@
1
+ require_relative "../test_helper"
2
+
3
+ class TorikagoConfigurationTest < Minitest::Test
4
+ def setup
5
+ @configuration = Torikago::Configuration.new
6
+ end
7
+
8
+ def test_register_stores_definition_for_a_module
9
+ @configuration.register(
10
+ :foo,
11
+ root: "/modules/foo",
12
+ entrypoint: "lib/foo/box_runtime.rb",
13
+ setup: "config/box_setup.rb",
14
+ gemfile: "Gemfile"
15
+ )
16
+
17
+ definition = @configuration.fetch(:foo)
18
+
19
+ assert_equal :foo, definition.name
20
+ assert_equal "/modules/foo", definition.root.to_s
21
+ assert_equal "lib/foo/box_runtime.rb", definition.entrypoint
22
+ assert_equal "config/box_setup.rb", definition.setup
23
+ assert_equal "Gemfile", definition.gemfile
24
+ end
25
+
26
+ def test_registered_distinguishes_registered_and_unregistered_modules
27
+ @configuration.register(
28
+ :foo,
29
+ root: "/modules/foo",
30
+ entrypoint: "lib/foo/box_runtime.rb"
31
+ )
32
+
33
+ assert @configuration.registered?(:foo)
34
+ refute @configuration.registered?(:bar)
35
+ end
36
+
37
+ def test_module_names_are_normalized_between_string_and_symbol
38
+ @configuration.register(
39
+ "foo",
40
+ root: "/modules/foo",
41
+ entrypoint: "lib/foo/box_runtime.rb"
42
+ )
43
+
44
+ assert @configuration.registered?(:foo)
45
+ assert @configuration.registered?("foo")
46
+ assert_equal :foo, @configuration.fetch("foo").name
47
+ end
48
+
49
+ def test_fetch_fails_clearly_for_an_unknown_module
50
+ error = assert_raises(KeyError) do
51
+ @configuration.fetch(:missing)
52
+ end
53
+
54
+ assert_match(/missing/, error.message)
55
+ end
56
+
57
+ def test_register_rejects_duplicate_module_names
58
+ @configuration.register(
59
+ :foo,
60
+ root: "/modules/foo",
61
+ entrypoint: "lib/foo/box_runtime.rb"
62
+ )
63
+
64
+ error = assert_raises(ArgumentError) do
65
+ @configuration.register(
66
+ "foo",
67
+ root: "/modules/another_foo",
68
+ entrypoint: "lib/foo/alternative_runtime.rb"
69
+ )
70
+ end
71
+
72
+ assert_match(/foo/, error.message)
73
+ end
74
+ end