appmap 0.31.0 → 0.34.2

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -1
  3. data/.rbenv-gemsets +1 -0
  4. data/CHANGELOG.md +22 -0
  5. data/README.md +38 -4
  6. data/Rakefile +10 -3
  7. data/appmap.gemspec +5 -0
  8. data/ext/appmap/appmap.c +26 -0
  9. data/ext/appmap/extconf.rb +6 -0
  10. data/lib/appmap.rb +23 -10
  11. data/lib/appmap/class_map.rb +13 -7
  12. data/lib/appmap/config.rb +54 -30
  13. data/lib/appmap/cucumber.rb +19 -2
  14. data/lib/appmap/event.rb +25 -16
  15. data/lib/appmap/hook.rb +52 -77
  16. data/lib/appmap/hook/method.rb +103 -0
  17. data/lib/appmap/open.rb +57 -0
  18. data/lib/appmap/rails/action_handler.rb +7 -7
  19. data/lib/appmap/rails/sql_handler.rb +10 -8
  20. data/lib/appmap/rspec.rb +1 -1
  21. data/lib/appmap/trace.rb +7 -7
  22. data/lib/appmap/util.rb +19 -0
  23. data/lib/appmap/version.rb +1 -1
  24. data/spec/abstract_controller4_base_spec.rb +1 -1
  25. data/spec/abstract_controller_base_spec.rb +9 -2
  26. data/spec/fixtures/hook/instance_method.rb +4 -0
  27. data/spec/fixtures/hook/singleton_method.rb +21 -12
  28. data/spec/hook_spec.rb +140 -44
  29. data/spec/open_spec.rb +19 -0
  30. data/spec/record_sql_rails_pg_spec.rb +56 -33
  31. data/test/cli_test.rb +12 -2
  32. data/test/fixtures/openssl_recorder/Gemfile +3 -0
  33. data/test/fixtures/openssl_recorder/appmap.yml +3 -0
  34. data/{spec/fixtures/hook/openssl_sign.rb → test/fixtures/openssl_recorder/lib/openssl_cert_sign.rb} +11 -4
  35. data/test/fixtures/openssl_recorder/lib/openssl_encrypt.rb +34 -0
  36. data/test/fixtures/openssl_recorder/lib/openssl_key_sign.rb +28 -0
  37. data/test/openssl_test.rb +203 -0
  38. data/test/test_helper.rb +1 -0
  39. metadata +58 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54b9a05aeb3eea84572b115a6de1106e6743b74046048e3c168f99a75aa9e669
4
- data.tar.gz: eb8a690376d833c4bec9f0789c5499f413f7dcb3b1ef111257785bd344861f64
3
+ metadata.gz: 81605d2c95140e963991e04af89235ee903cce0e3a86b2477f0f80961704e2d6
4
+ data.tar.gz: 7e526d9ffbc559bb3968b8a973dcd757147676d17a5028f737192fd8956d1b43
5
5
  SHA512:
6
- metadata.gz: 453f06041220ecd0a4fc563ae007e7f8247d3dfcccf49657cbf47ddc53d0175e6834d1dbd89a2338a032d6cfae866929216b58a11bb6f0fe3fd2ea69508d7b2a
7
- data.tar.gz: 77f372ad255c3c664c690affa2962958cebd1e8f34579809e4d975fdf57404889d8506f9153d44a95afda297ffe7b1654529252a5293487477e803bdf0cc9608
6
+ metadata.gz: 6a34ba2662d48a7e0083e16d6abe4693b57711f9ac0020da6631ca6c40172aeb8f3ef734edd504cec00f2f437742e45fc0cf631b425cb960c9d51239556bc461
7
+ data.tar.gz: 156f126aac6f63e819c175d6b4d481679cbced9184007871c12fb174f187198afd8e7788befc4ab0ff35b10293e1e163eee25a985b35bd93853c03aa8268d192
data/.gitignore CHANGED
@@ -14,4 +14,4 @@ Gemfile.lock
14
14
  appmap.json
15
15
  .vscode
16
16
  .byebug_history
17
-
17
+ /lib/appmap/appmap.bundle
@@ -0,0 +1 @@
1
+ appmap-ruby
@@ -1,3 +1,25 @@
1
+ # v0.34.2
2
+ * Add an extension that gets the name of the owner of a singleton method without calling
3
+ any methods that may have been redefined (e.g. `#to_s` or `.name`).
4
+
5
+ # v0.34.1
6
+ * Ensure that capturing events doesn't change the behavior of a hooked method that uses
7
+ `Time.now`. For example, if a test expects that `Time.now` will be called a certain
8
+ number of times by a hooked method, that expectation will now be met.
9
+ * Make sure `appmap/cucumber` requires `appmap`.
10
+
11
+ # v0.34.0
12
+
13
+ * Records builtin security and I/O methods from `OpenSSL`, `Net`, and `IO`.
14
+
15
+ # v0.33.0
16
+
17
+ * Added command `AppMap.open` to open an AppMap in the browser.
18
+
19
+ # v0.32.0
20
+
21
+ * Removes un-necessary fields from `return` events.
22
+
1
23
  # v0.31.0
2
24
 
3
25
  * Add the ability to hook methods by default, and optionally add labels to them in the
data/README.md CHANGED
@@ -33,7 +33,7 @@ There are several ways to record AppMaps of your Ruby program using the `appmap`
33
33
 
34
34
  Once you have recorded some AppMaps (for example, by running RSpec tests), you use the `appland upload` command
35
35
  to upload them to the AppLand server. This command, and some others, is provided
36
- by the [AppLand CLI](https://github.com/applandinc/appland-cli/releases), to
36
+ by the [AppLand CLI](https://github.com/applandinc/appland-cli/releases).
37
37
  Then, on the [AppLand website](https://app.land), you can
38
38
  visualize the design of your code and share links with collaborators.
39
39
 
@@ -87,12 +87,25 @@ Each entry in the `packages` list is a YAML object which has the following keys:
87
87
 
88
88
  To record RSpec tests, follow these additional steps:
89
89
 
90
- 1) Require `appmap/rspec` in your `spec_helper.rb`.
90
+ 1) Require `appmap/rspec` in your `spec_helper.rb` before any other classes are loaded.
91
91
 
92
92
  ```ruby
93
93
  require 'appmap/rspec'
94
94
  ```
95
95
 
96
+ Note that `spec_helper.rb` in a Rails project typically loads the application's classes this way:
97
+
98
+ ```ruby
99
+ require File.expand_path("../../config/environment", __FILE__)
100
+ ```
101
+
102
+ and `appmap/rspec` must be required before this:
103
+
104
+ ```ruby
105
+ require 'appmap/rspec'
106
+ require File.expand_path("../../config/environment", __FILE__)
107
+ ```
108
+
96
109
  2) *Optional* Add `feature: '<feature name>'` and `feature_group: '<feature group name>'` annotations to your
97
110
  examples.
98
111
 
@@ -136,6 +149,19 @@ To record Minitest tests, follow these additional steps:
136
149
  require 'appmap/minitest'
137
150
  ```
138
151
 
152
+ Note that `test_helper.rb` in a Rails project typically loads the application's classes this way:
153
+
154
+ ```ruby
155
+ require_relative '../config/environment'
156
+ ```
157
+
158
+ and `appmap/rspec` must be required before this:
159
+
160
+ ```ruby
161
+ require 'appmap/rspec'
162
+ require_relative '../config/environment'
163
+ ```
164
+
139
165
  2) Run the tests with the environment variable `APPMAP=true`:
140
166
 
141
167
  ```sh-session
@@ -159,6 +185,8 @@ To record Cucumber tests, follow these additional steps:
159
185
  require 'appmap/cucumber'
160
186
  ```
161
187
 
188
+ Be sure to require it before `config/environment` is required.
189
+
162
190
  2) Create an `Around` hook in `support/hooks.rb` to record the scenario:
163
191
 
164
192
 
@@ -239,7 +267,13 @@ $ bundle config local.appmap $(pwd)
239
267
  Run the tests via `rake`:
240
268
  ```
241
269
  $ bundle exec rake test
242
- ```
270
+ ```
271
+
272
+ The `test` target will build the native extension first, then run the tests. If you need
273
+ to build the extension separately, run
274
+ ```
275
+ $ bundle exec rake compile
276
+ ```
243
277
 
244
278
  ## Using fixture apps
245
279
 
@@ -258,7 +292,7 @@ resources such as a PostgreSQL database.
258
292
  To build the fixture container images, first run:
259
293
 
260
294
  ```sh-session
261
- $ bundle exec rake fixtures:all
295
+ $ bundle exec rake build:fixtures:all
262
296
  ```
263
297
 
264
298
  This will build the `appmap.gem`, along with a Docker image for each fixture app.
data/Rakefile CHANGED
@@ -6,6 +6,13 @@ require 'rdoc/task'
6
6
 
7
7
  require 'open3'
8
8
 
9
+ require "rake/extensiontask"
10
+
11
+ desc 'build the native extension'
12
+ Rake::ExtensionTask.new("appmap") do |ext|
13
+ ext.lib_dir = "lib/appmap"
14
+ end
15
+
9
16
  namespace 'gem' do
10
17
  require 'bundler/gem_tasks'
11
18
  end
@@ -104,7 +111,7 @@ end
104
111
  namespace :spec do
105
112
  RUBY_VERSIONS.each do |ruby_version|
106
113
  desc ruby_version
107
- task ruby_version, [:specs] => ["build:fixtures:#{ruby_version}:all"] do |_, task_args|
114
+ task ruby_version, [:specs] => ["compile", "build:fixtures:#{ruby_version}:all"] do |_, task_args|
108
115
  run_specs(ruby_version, task_args)
109
116
  end.tap do|t|
110
117
  desc "Run all specs"
@@ -119,13 +126,13 @@ Rake::RDocTask.new do |rd|
119
126
  rd.title = 'AppMap'
120
127
  end
121
128
 
122
- Rake::TestTask.new(:minitest) do |t|
129
+ Rake::TestTask.new(minitest: 'compile') do |t|
123
130
  t.libs << 'test'
124
131
  t.libs << 'lib'
125
132
  t.test_files = FileList['test/*_test.rb']
126
133
  end
127
134
 
128
- task spec: "spec:all"
135
+ task spec: %i[spec:all]
129
136
 
130
137
  task test: %i[spec:all minitest]
131
138
 
@@ -18,6 +18,8 @@ Gem::Specification.new do |spec|
18
18
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
19
  spec.files = `git ls-files --no-deleted`.split("
20
20
  ")
21
+ spec.extensions << "ext/appmap/extconf.rb"
22
+
21
23
  spec.bindir = 'exe'
22
24
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
25
  spec.require_paths = ['lib']
@@ -26,6 +28,7 @@ Gem::Specification.new do |spec|
26
28
  spec.add_dependency 'faraday'
27
29
  spec.add_dependency 'gli'
28
30
  spec.add_dependency 'parser'
31
+ spec.add_dependency 'rack'
29
32
 
30
33
  spec.add_development_dependency 'bundler', '~> 1.16'
31
34
  spec.add_development_dependency 'minitest', '~> 5.0'
@@ -33,6 +36,7 @@ Gem::Specification.new do |spec|
33
36
  spec.add_development_dependency 'rake', '>= 12.3.3'
34
37
  spec.add_development_dependency 'rdoc'
35
38
  spec.add_development_dependency 'rubocop'
39
+ spec.add_development_dependency "rake-compiler"
36
40
 
37
41
  # Testing
38
42
  spec.add_development_dependency 'climate_control'
@@ -41,4 +45,5 @@ Gem::Specification.new do |spec|
41
45
  spec.add_development_dependency 'rspec'
42
46
  spec.add_development_dependency 'selenium-webdriver'
43
47
  spec.add_development_dependency 'webdrivers', '~> 4.0'
48
+ spec.add_development_dependency 'timecop'
44
49
  end
@@ -0,0 +1,26 @@
1
+ #include <ruby.h>
2
+ #include <ruby/intern.h>
3
+
4
+ // Seems like CLASS_OR_MODULE_P should really be in a header file in
5
+ // the ruby source -- it's in object.c and duplicated in eval.c. In
6
+ // the future, we'll fail if it does get moved to a header.
7
+ #define CLASS_OR_MODULE_P(obj) \
8
+ (!SPECIAL_CONST_P(obj) && \
9
+ (BUILTIN_TYPE(obj) == T_CLASS || BUILTIN_TYPE(obj) == T_MODULE))
10
+
11
+ static VALUE singleton_method_owner_name(VALUE klass, VALUE method)
12
+ {
13
+ VALUE owner = rb_funcall(method, rb_intern("owner"), 0);
14
+ VALUE attached = rb_ivar_get(owner, rb_intern("__attached__"));
15
+ if (!CLASS_OR_MODULE_P(attached)) {
16
+ attached = rb_funcall(attached, rb_intern("class"), 0);
17
+ }
18
+ return rb_mod_name(attached);
19
+ }
20
+
21
+ void Init_appmap() {
22
+ VALUE appmap = rb_define_module("AppMap");
23
+ VALUE hook = rb_define_class_under(appmap, "Hook", rb_cObject);
24
+
25
+ rb_define_singleton_method(hook, "singleton_method_owner_name", singleton_method_owner_name, 1);
26
+ }
@@ -0,0 +1,6 @@
1
+ require "mkmf"
2
+
3
+ $CFLAGS='-Werror'
4
+ extension_name = "appmap"
5
+ dir_config(extension_name)
6
+ create_makefile(File.join(extension_name, extension_name))
@@ -13,19 +13,24 @@ require 'appmap/config'
13
13
  require 'appmap/trace'
14
14
  require 'appmap/class_map'
15
15
  require 'appmap/metadata'
16
+ require 'appmap/util'
17
+ require 'appmap/open'
18
+
19
+ # load extension
20
+ require 'appmap/appmap'
16
21
 
17
22
  module AppMap
18
23
  class << self
19
24
  @configuration = nil
20
25
  @configuration_file_path = nil
21
26
 
22
- # configuration gets the configuration. If there is no configuration, the default
27
+ # Gets the configuration. If there is no configuration, the default
23
28
  # configuration is initialized.
24
29
  def configuration
25
- @configuration ||= configure
30
+ @configuration ||= initialize
26
31
  end
27
32
 
28
- # configuration= sets the configuration. This is only expected to happen once per
33
+ # Sets the configuration. This is only expected to happen once per
29
34
  # Ruby process.
30
35
  def configuration=(config)
31
36
  warn 'AppMap is already configured' if @configuration && config
@@ -33,22 +38,24 @@ module AppMap
33
38
  @configuration = config
34
39
  end
35
40
 
36
- # initialize configures AppMap for recording. Default behavior is to configure from "appmap.yml".
41
+ # Configures AppMap for recording. Default behavior is to configure from "appmap.yml".
37
42
  # This method also activates the code hooks which record function calls as trace events.
38
43
  # Call this function before the program code is loaded by the Ruby VM, otherwise
39
44
  # the load events won't be seen and the hooks won't activate.
40
45
  def initialize(config_file_path = 'appmap.yml')
41
46
  warn "Configuring AppMap from path #{config_file_path}"
42
- self.configuration = Config.load_from_file(config_file_path)
43
- Hook.new(configuration).enable
47
+ Config.load_from_file(config_file_path).tap do |configuration|
48
+ self.configuration = configuration
49
+ Hook.new(configuration).enable
50
+ end
44
51
  end
45
52
 
46
- # tracing can be used to start tracing, stop tracing, and record events.
53
+ # Used to start tracing, stop tracing, and record events.
47
54
  def tracing
48
55
  @tracing ||= Trace::Tracing.new
49
56
  end
50
57
 
51
- # record records the events which occur while processing a block,
58
+ # Records the events which occur while processing a block,
52
59
  # and returns an AppMap as a Hash.
53
60
  def record
54
61
  tracer = tracing.trace
@@ -69,12 +76,18 @@ module AppMap
69
76
  }
70
77
  end
71
78
 
72
- # class_map builds a class map from a config and a list of Ruby methods.
79
+ # Uploads an AppMap to the AppLand website and displays it.
80
+ def open(appmap = nil, &block)
81
+ appmap ||= AppMap.record(&block)
82
+ AppMap::Open.new(appmap).perform
83
+ end
84
+
85
+ # Builds a class map from a config and a list of Ruby methods.
73
86
  def class_map(methods)
74
87
  ClassMap.build_from_methods(configuration, methods)
75
88
  end
76
89
 
77
- # detect_metadata returns default metadata detected from the Ruby system and from the
90
+ # Returns default metadata detected from the Ruby system and from the
78
91
  # filesystem.
79
92
  def detect_metadata
80
93
  @metadata ||= Metadata.detect.freeze
@@ -61,7 +61,7 @@ module AppMap
61
61
  location: location,
62
62
  static: static,
63
63
  labels: labels
64
- }.delete_if {|k,v| v.nil?}
64
+ }.delete_if { |k,v| v.nil? || v == [] }
65
65
  end
66
66
  end
67
67
  end
@@ -80,10 +80,6 @@ module AppMap
80
80
  protected
81
81
 
82
82
  def add_function(root, package, method)
83
- location = method.source_location
84
- location_file, lineno = location
85
- location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
86
-
87
83
  static = method.static
88
84
 
89
85
  object_infos = [
@@ -101,12 +97,22 @@ module AppMap
101
97
  function_info = {
102
98
  name: method.name,
103
99
  type: 'function',
104
- location: [ location_file, lineno ].join(':'),
105
100
  static: static
106
101
  }
102
+ location = method.source_location
103
+
104
+ function_info[:location] = \
105
+ if location
106
+ location_file, lineno = location
107
+ location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
108
+ [ location_file, lineno ].join(':')
109
+ else
110
+ [ method.defined_class, static ? '.' : '#', method.name ].join
111
+ end
112
+
107
113
  function_info[:labels] = package.labels if package.labels
108
114
  object_infos << function_info
109
-
115
+
110
116
  parent = root
111
117
  object_infos.each do |info|
112
118
  parent = find_or_create parent.children, info do
@@ -1,33 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppMap
4
- Package = Struct.new(:path, :exclude, :labels) do
5
- def initialize(path, exclude, labels = nil)
6
- super
4
+ class Config
5
+ Package = Struct.new(:path, :package_name, :exclude, :labels) do
6
+ def initialize(path, package_name: nil, exclude: [], labels: [])
7
+ super path, package_name, exclude, labels
8
+ end
9
+
10
+ def to_h
11
+ {
12
+ path: path,
13
+ package_name: package_name,
14
+ exclude: exclude.blank? ? nil : exclude,
15
+ labels: labels.blank? ? nil : labels
16
+ }.compact
17
+ end
7
18
  end
8
-
9
- def to_h
10
- {
11
- path: path,
12
- exclude: exclude.blank? ? nil : exclude,
13
- labels: labels.blank? ? nil : labels
14
- }.compact
19
+
20
+ Hook = Struct.new(:method_names, :package) do
15
21
  end
16
- end
17
22
 
18
- class Config
23
+ OPENSSL_PACKAGE = Package.new('openssl', package_name: 'openssl', labels: %w[security crypto])
24
+
19
25
  # Methods that should always be hooked, with their containing
20
26
  # package and labels that should be applied to them.
21
27
  HOOKED_METHODS = {
22
- 'ActiveSupport::SecurityUtils' => {
23
- secure_compare: Package.new('active_support', nil, ['security'])
24
- },
25
- 'OpenSSL::X509::Certificate' => {
26
- sign: Package.new('openssl', nil, ['security'])
27
- }
28
- }
28
+ 'ActiveSupport::SecurityUtils' => Hook.new(:secure_compare, Package.new('active_support', package_name: 'active_support', labels: %w[security crypto]))
29
+ }.freeze
30
+
31
+ BUILTIN_METHODS = {
32
+ 'OpenSSL::PKey::PKey' => Hook.new(:sign, OPENSSL_PACKAGE),
33
+ 'Digest::Instance' => Hook.new(:digest, OPENSSL_PACKAGE),
34
+ 'OpenSSL::X509::Request' => Hook.new(%i[sign verify], OPENSSL_PACKAGE),
35
+ 'OpenSSL::PKCS5' => Hook.new(%i[pbkdf2_hmac_sha1 pbkdf2_hmac], OPENSSL_PACKAGE),
36
+ 'OpenSSL::Cipher' => Hook.new(%i[encrypt decrypt final], OPENSSL_PACKAGE),
37
+ 'OpenSSL::X509::Certificate' => Hook.new(:sign, OPENSSL_PACKAGE),
38
+ 'Logger' => Hook.new(:add, Package.new('logger', labels: %w[log io])),
39
+ 'Net::HTTP' => Hook.new(:request, Package.new('net/http', package_name: 'net/http', labels: %w[http io])),
40
+ 'Net::SMTP' => Hook.new(:send, Package.new('net/smtp', package_name: 'net/smtp', labels: %w[smtp email io])),
41
+ 'Net::POP3' => Hook.new(:mails, Package.new('net/pop3', package_name: 'net/pop', labels: %w[pop pop3 email io])),
42
+ 'Net::IMAP' => Hook.new(:send_command, Package.new('net/imap', package_name: 'net/imap', labels: %w[imap email io])),
43
+ 'IO' => Hook.new(%i[read write open close], Package.new('io', labels: %w[io])),
44
+ 'Marshal' => Hook.new(%i[dump load], Package.new('marshal', labels: %w[serialization marshal])),
45
+ 'Psych' => Hook.new(%i[dump dump_stream load load_stream parse parse_stream], Package.new('yaml', package_name: 'psych', labels: %w[serialization yaml])),
46
+ 'JSON::Ext::Parser' => Hook.new(:parse, Package.new('json', package_name: 'json', labels: %w[serialization json])),
47
+ 'JSON::Ext::Generator::State' => Hook.new(:generate, Package.new('json', package_name: 'json', labels: %w[serialization json]))
48
+ }.freeze
29
49
 
30
50
  attr_reader :name, :packages
51
+
31
52
  def initialize(name, packages = [])
32
53
  @name = name
33
54
  @packages = packages
@@ -43,7 +64,7 @@ module AppMap
43
64
  # Loads configuration from a Hash.
44
65
  def load(config_data)
45
66
  packages = (config_data['packages'] || []).map do |package|
46
- Package.new(package['path'], package['exclude'] || [])
67
+ Package.new(package['path'], exclude: package['exclude'] || [])
47
68
  end
48
69
  Config.new config_data['name'], packages
49
70
  end
@@ -57,14 +78,14 @@ module AppMap
57
78
  end
58
79
 
59
80
  def package_for_method(method)
81
+ defined_class, _, method_name = ::AppMap::Hook.qualify_method_name(method)
82
+ package = find_package(defined_class, method_name)
83
+ return package if package
84
+
60
85
  location = method.source_location
61
86
  location_file, = location
62
87
  return unless location_file
63
88
 
64
- defined_class,_,method_name = Hook.qualify_method_name(method)
65
- hooked_method = find_hooked_method(defined_class, method_name)
66
- return hooked_method if hooked_method
67
-
68
89
  location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
69
90
  packages.find do |pkg|
70
91
  (location_file.index(pkg.path) == 0) &&
@@ -77,15 +98,18 @@ module AppMap
77
98
  end
78
99
 
79
100
  def always_hook?(defined_class, method_name)
80
- !!find_hooked_method(defined_class, method_name)
101
+ !!find_package(defined_class, method_name)
81
102
  end
82
103
 
83
- def find_hooked_method(defined_class, method_name)
84
- find_hooked_class(defined_class)[method_name]
104
+ def find_package(defined_class, method_name)
105
+ hook = find_hook(defined_class)
106
+ return nil unless hook
107
+
108
+ Array(hook.method_names).include?(method_name) ? hook.package : nil
85
109
  end
86
-
87
- def find_hooked_class(defined_class)
88
- HOOKED_METHODS[defined_class] || {}
110
+
111
+ def find_hook(defined_class)
112
+ HOOKED_METHODS[defined_class] || BUILTIN_METHODS[defined_class]
89
113
  end
90
114
  end
91
115
  end