zxcvbn-ruby 1.4.0 → 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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +322 -75
  4. data/data/frequency_lists/english_wikipedia.txt +30000 -0
  5. data/data/frequency_lists/female_names.txt +11 -114
  6. data/data/frequency_lists/male_names.txt +3 -24
  7. data/data/frequency_lists/passwords.txt +29623 -6764
  8. data/data/frequency_lists/surnames.txt +28 -30611
  9. data/data/frequency_lists/{english.txt → us_tv_and_film.txt} +147 -13532
  10. data/lib/zxcvbn/clock.rb +6 -0
  11. data/lib/zxcvbn/crack_time.rb +52 -18
  12. data/lib/zxcvbn/data.rb +61 -21
  13. data/lib/zxcvbn/dictionary_ranker.rb +10 -0
  14. data/lib/zxcvbn/feedback.rb +11 -6
  15. data/lib/zxcvbn/feedback_giver.rb +75 -50
  16. data/lib/zxcvbn/guesses.rb +208 -0
  17. data/lib/zxcvbn/match.rb +95 -15
  18. data/lib/zxcvbn/match_builder.rb +15 -0
  19. data/lib/zxcvbn/matchers/date.rb +171 -106
  20. data/lib/zxcvbn/matchers/dictionary.rb +15 -8
  21. data/lib/zxcvbn/matchers/digits.rb +6 -1
  22. data/lib/zxcvbn/matchers/l33t.rb +30 -34
  23. data/lib/zxcvbn/matchers/regex_helpers.rb +14 -6
  24. data/lib/zxcvbn/matchers/repeat.rb +47 -16
  25. data/lib/zxcvbn/matchers/sequences.rb +58 -48
  26. data/lib/zxcvbn/matchers/spatial.rb +22 -6
  27. data/lib/zxcvbn/matchers/year.rb +6 -1
  28. data/lib/zxcvbn/math.rb +15 -28
  29. data/lib/zxcvbn/omnimatch.rb +70 -22
  30. data/lib/zxcvbn/ruby.rb +3 -0
  31. data/lib/zxcvbn/score.rb +34 -10
  32. data/lib/zxcvbn/scorer.rb +142 -75
  33. data/lib/zxcvbn/tester.rb +58 -23
  34. data/lib/zxcvbn/tester_builder.rb +83 -0
  35. data/lib/zxcvbn/trie.rb +21 -0
  36. data/lib/zxcvbn/version.rb +1 -1
  37. data/lib/zxcvbn.rb +47 -7
  38. data/sig/zxcvbn/clock.rbs +5 -0
  39. data/sig/zxcvbn/crack_time.rbs +3 -5
  40. data/sig/zxcvbn/data.rbs +17 -8
  41. data/sig/zxcvbn/feedback.rbs +6 -4
  42. data/sig/zxcvbn/guesses.rbs +36 -0
  43. data/sig/zxcvbn/match.rbs +35 -33
  44. data/sig/zxcvbn/match_builder.rbs +36 -0
  45. data/sig/zxcvbn/matchers/date.rbs +23 -0
  46. data/sig/zxcvbn/matchers/dictionary.rbs +21 -0
  47. data/sig/zxcvbn/matchers/digits.rbs +11 -0
  48. data/sig/zxcvbn/matchers/l33t.rbs +27 -0
  49. data/sig/zxcvbn/matchers/regex_helpers.rbs +7 -0
  50. data/sig/zxcvbn/matchers/repeat.rbs +11 -0
  51. data/sig/zxcvbn/matchers/sequences.rbs +16 -0
  52. data/sig/zxcvbn/matchers/spatial.rbs +15 -0
  53. data/sig/zxcvbn/matchers/year.rbs +11 -0
  54. data/sig/zxcvbn/math.rbs +0 -4
  55. data/sig/zxcvbn/omnimatch.rbs +5 -2
  56. data/sig/zxcvbn/score.rbs +22 -11
  57. data/sig/zxcvbn/scorer.rbs +7 -8
  58. data/sig/zxcvbn/tester.rbs +5 -7
  59. data/sig/zxcvbn/tester_builder.rbs +16 -0
  60. data/sig/zxcvbn/trie.rbs +4 -0
  61. data/sig/zxcvbn.rbs +6 -4
  62. metadata +30 -13
  63. data/lib/zxcvbn/entropy.rb +0 -158
  64. data/lib/zxcvbn/matchers/new_l33t.rb +0 -118
  65. data/lib/zxcvbn/password_strength.rb +0 -27
  66. data/sig/zxcvbn/entropy.rbs +0 -33
  67. data/sig/zxcvbn/password_strength.rbs +0 -10
data/lib/zxcvbn/clock.rb CHANGED
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
+ # Monotonic-clock utility for measuring elapsed time.
5
+ # @api private
4
6
  module Clock
7
+ # Yields to the block and returns the elapsed time in seconds.
8
+ #
9
+ # @yield block whose execution time is measured
10
+ # @return [Float] elapsed seconds as a monotonic-clock duration
5
11
  def self.realtime
6
12
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
13
  yield
@@ -1,30 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
+ # Mixin that converts a guess count into estimated crack times and scores.
5
+ #
6
+ # Provides {estimate_attack_times}, {guesses_to_score}, and {display_time}
7
+ # mirroring the crack-time logic from zxcvbn.js v4.
8
+ # @api private
4
9
  module CrackTime
5
- SINGLE_GUESS = 0.010
6
- NUM_ATTACKERS = 100
10
+ ATTACK_SCENARIOS = {
11
+ 'online_throttling_100_per_hour' => 100.0 / 3600,
12
+ 'online_no_throttling_10_per_second' => 10.0,
13
+ 'offline_slow_hashing_1e4_per_second' => 1e4,
14
+ 'offline_fast_hashing_1e10_per_second' => 1e10
15
+ }.freeze
7
16
 
8
- SECONDS_PER_GUESS = SINGLE_GUESS / NUM_ATTACKERS
9
-
10
- def entropy_to_crack_time(entropy)
11
- 0.5 * (2**entropy) * SECONDS_PER_GUESS
17
+ # Returns the estimated seconds and display strings for each attack scenario.
18
+ #
19
+ # @param guesses [Numeric] estimated guess count
20
+ # @return [Hash] with :crack_times_seconds and :crack_times_display
21
+ def estimate_attack_times(guesses)
22
+ seconds = ATTACK_SCENARIOS.transform_values { |rate| [guesses / rate, Float::MAX].min }
23
+ display = seconds.transform_values { |s| display_time(s) }
24
+ { crack_times_seconds: seconds, crack_times_display: display }
12
25
  end
13
26
 
14
- def crack_time_to_score(seconds)
15
- if seconds < 10**2
27
+ # Convert a guess count to a 0–4 score using zxcvbn.js v4 thresholds.
28
+ #
29
+ # A small delta (5) is added to each threshold so that passwords just at
30
+ # a boundary are not bumped up to the next score band by floating-point
31
+ # noise in the guess count.
32
+ #
33
+ # @param guesses [Numeric] estimated number of guesses to crack the password
34
+ # @return [Integer] score in the range 0..4
35
+ def guesses_to_score(guesses)
36
+ delta = 5
37
+ if guesses < 1_000 + delta
16
38
  0
17
- elsif seconds < 10**4
39
+ elsif guesses < 1_000_000 + delta
18
40
  1
19
- elsif seconds < 10**6
41
+ elsif guesses < 100_000_000 + delta
20
42
  2
21
- elsif seconds < 10**8
43
+ elsif guesses < 10_000_000_000 + delta
22
44
  3
23
45
  else
24
46
  4
25
47
  end
26
48
  end
27
49
 
50
+ # Convert a number of seconds into a human-readable display string.
51
+ #
52
+ # @param seconds [Numeric] duration in seconds
53
+ # @return [String] e.g. "instant", "3 minutes", "centuries"
28
54
  def display_time(seconds)
29
55
  minute = 60
30
56
  hour = minute * 60
@@ -33,18 +59,26 @@ module Zxcvbn
33
59
  year = month * 12
34
60
  century = year * 100
35
61
 
36
- if seconds < minute
37
- 'instant'
62
+ if seconds < 1
63
+ 'less than a second'
64
+ elsif seconds < minute
65
+ t = seconds.round
66
+ "#{t} second#{'s' unless t == 1}"
38
67
  elsif seconds < hour
39
- "#{1 + (seconds / minute).ceil} minutes"
68
+ t = (seconds / minute).round
69
+ "#{t} minute#{'s' unless t == 1}"
40
70
  elsif seconds < day
41
- "#{1 + (seconds / hour).ceil} hours"
71
+ t = (seconds / hour).round
72
+ "#{t} hour#{'s' unless t == 1}"
42
73
  elsif seconds < month
43
- "#{1 + (seconds / day).ceil} days"
74
+ t = (seconds / day).round
75
+ "#{t} day#{'s' unless t == 1}"
44
76
  elsif seconds < year
45
- "#{1 + (seconds / month).ceil} months"
77
+ t = (seconds / month).round
78
+ "#{t} month#{'s' unless t == 1}"
46
79
  elsif seconds < century
47
- "#{1 + (seconds / year).ceil} years"
80
+ t = (seconds / year).round
81
+ "#{t} year#{'s' unless t == 1}"
48
82
  else
49
83
  'centuries'
50
84
  end
data/lib/zxcvbn/data.rb CHANGED
@@ -1,30 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'pathname'
4
5
  require 'zxcvbn/dictionary_ranker'
5
6
  require 'zxcvbn/trie'
6
7
 
7
8
  module Zxcvbn
9
+ # Holds all loaded frequency lists, adjacency graphs, tries, and graph stats
10
+ # used by the matchers and scorer.
11
+ #
12
+ # @attr_reader adjacency_graphs [Hash{String => Hash}] keyboard adjacency data
13
+ # @attr_reader graph_stats [Hash{String => Hash}] precomputed average degree and key count
14
+ # @attr_reader dictionaries [#ranked, #tries] consistent snapshot of ranked dictionaries and
15
+ # their tries; use this for concurrent reads to guarantee the pair is always in sync
16
+ # @api private
8
17
  class Data
18
+ DATA_PATH = Pathname(File.expand_path('../../data', __dir__))
19
+ private_constant :DATA_PATH
20
+
21
+ # Consistent named pair of ranked dictionaries and their tries.
22
+ Dictionaries = ::Data.define(:ranked, :tries) do
23
+ def inspect = "#<#{self.class}:0x#{__id__.to_s(16)}>"
24
+ end
25
+ private_constant :Dictionaries
26
+
27
+ # Built-in dictionary names and the reserved per-call key.
28
+ # These names cannot be passed to {TesterBuilder#add_word_list}.
29
+ RESERVED_NAMES = %w[
30
+ english_wikipedia female_names male_names passwords surnames us_tv_and_film user_inputs
31
+ ].freeze
32
+
33
+ # Loads all built-in frequency lists and adjacency graphs from disk.
9
34
  def initialize
10
- @ranked_dictionaries = DictionaryRanker.rank_dictionaries(
11
- 'english' => read_word_list('english.txt'),
35
+ ranked = DictionaryRanker.rank_dictionaries(
36
+ 'english_wikipedia' => read_word_list('english_wikipedia.txt'),
12
37
  'female_names' => read_word_list('female_names.txt'),
13
38
  'male_names' => read_word_list('male_names.txt'),
14
39
  'passwords' => read_word_list('passwords.txt'),
15
- 'surnames' => read_word_list('surnames.txt')
16
- )
17
- @adjacency_graphs = JSON.parse(DATA_PATH.join('adjacency_graphs.json').read)
18
- @dictionary_tries = build_tries
19
- @graph_stats = compute_graph_stats
40
+ 'surnames' => read_word_list('surnames.txt'),
41
+ 'us_tv_and_film' => read_word_list('us_tv_and_film.txt')
42
+ ).tap { |r| r.each_value(&:freeze) }.freeze
43
+ tries = build_tries(ranked).tap { |t| t.each_value(&:freeze) }.freeze
44
+ @dictionaries = Dictionaries.new(ranked:, tries:)
45
+ @adjacency_graphs =
46
+ JSON.parse(DATA_PATH.join('adjacency_graphs.json').read)
47
+ .tap { |gs| gs.each_value { |g| g.each_value { |a| a.each(&:freeze).freeze }.freeze } }
48
+ .freeze
49
+ @graph_stats = compute_graph_stats.each_value(&:freeze).freeze
20
50
  end
21
51
 
22
- attr_reader :ranked_dictionaries, :adjacency_graphs, :dictionary_tries, :graph_stats
52
+ attr_reader :adjacency_graphs, :graph_stats, :dictionaries
53
+
54
+ def inspect = "#<#{self.class}:0x#{__id__.to_s(16)}>"
23
55
 
56
+ # @return [Hash{String => Hash{String => Integer}}] word → rank maps
57
+ def ranked_dictionaries = @dictionaries.ranked
58
+
59
+ # @return [Hash{String => Trie}] prefix tries per dictionary
60
+ def dictionary_tries = @dictionaries.tries
61
+
62
+ # Adds a custom word list and builds a trie for it.
63
+ #
64
+ # @param name [String] dictionary name (used as a key in {#ranked_dictionaries})
65
+ # @param list [Array<String>] ordered words (most common first)
66
+ # @return [void]
24
67
  def add_word_list(name, list)
25
- ranked_dict = DictionaryRanker.rank_dictionary(list)
26
- @ranked_dictionaries[name] = ranked_dict
27
- @dictionary_tries[name] = build_trie(ranked_dict)
68
+ ranked_dict = DictionaryRanker.rank_dictionary(list.select { |w| w.is_a?(String) }).freeze
69
+ trie = Trie.from_ranked(ranked_dict).freeze
70
+ @dictionaries = @dictionaries.with(
71
+ ranked: @dictionaries.ranked.merge(name => ranked_dict).freeze,
72
+ tries: @dictionaries.tries.merge(name => trie).freeze
73
+ )
28
74
  end
29
75
 
30
76
  private
@@ -33,14 +79,8 @@ module Zxcvbn
33
79
  DATA_PATH.join('frequency_lists', file).read.split
34
80
  end
35
81
 
36
- def build_tries
37
- @ranked_dictionaries.transform_values { |dict| build_trie(dict) }
38
- end
39
-
40
- def build_trie(ranked_dictionary)
41
- trie = Trie.new
42
- ranked_dictionary.each { |word, rank| trie.insert(word, rank) }
43
- trie
82
+ def build_tries(ranked)
83
+ ranked.transform_values { |dict| Trie.from_ranked(dict) }
44
84
  end
45
85
 
46
86
  def compute_graph_stats
@@ -52,8 +92,8 @@ module Zxcvbn
52
92
  starting_positions = graph.length
53
93
 
54
94
  stats[graph_name] = {
55
- average_degree: average_degree,
56
- starting_positions: starting_positions
95
+ average_degree:,
96
+ starting_positions:
57
97
  }
58
98
  end
59
99
  stats
@@ -1,13 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
+ # Converts raw word lists into frequency-ranked dictionaries for matcher use.
5
+ # @api private
4
6
  class DictionaryRanker
7
+ # Ranks multiple word lists, returning a hash of ranked dictionaries.
8
+ #
9
+ # @param lists [Hash{Symbol => Array<String>}] named word lists
10
+ # @return [Hash{Symbol => Hash{String => Integer}}] lowercased word → rank mappings
5
11
  def self.rank_dictionaries(lists)
6
12
  lists.transform_values do |words|
7
13
  rank_dictionary(words)
8
14
  end
9
15
  end
10
16
 
17
+ # Ranks a single word list; rank starts at 1 (most common).
18
+ #
19
+ # @param words [Array<String>] ordered words (most common first)
20
+ # @return [Hash{String => Integer}] lowercased word → 1-based rank
11
21
  def self.rank_dictionary(words)
12
22
  words
13
23
  .each_with_index
@@ -1,12 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
- class Feedback
5
- attr_accessor :warning, :suggestions
6
-
7
- def initialize(options = {})
8
- @warning = options[:warning]
9
- @suggestions = options[:suggestions] || []
4
+ # Human-readable feedback for a low-scoring password.
5
+ #
6
+ # @!attribute [r] warning
7
+ # @return [String] a single warning message, or empty string
8
+ # @!attribute [r] suggestions
9
+ # @return [Array<String>] ordered list of improvement tips
10
+ Feedback = ::Data.define(:warning, :suggestions) do
11
+ # @param warning [String] warning message (default: empty string)
12
+ # @param suggestions [Array<String>] improvement tips (default: [])
13
+ def initialize(warning: nil, suggestions: [])
14
+ super(warning: warning || '', suggestions: suggestions.freeze)
10
15
  end
11
16
  end
12
17
  end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zxcvbn/entropy'
3
+ require 'zxcvbn/guesses'
4
4
  require 'zxcvbn/feedback'
5
5
 
6
6
  module Zxcvbn
7
+ # Generates human-readable {Feedback} for a password given its score and match sequence.
8
+ # @api private
7
9
  class FeedbackGiver
8
10
  NAME_DICTIONARIES = %w[surnames male_names female_names].freeze
9
11
 
@@ -12,10 +14,15 @@ module Zxcvbn
12
14
  'Use a few words, avoid common phrases',
13
15
  'No need for symbols, digits, or uppercase letters'
14
16
  ]
15
- ).freeze
17
+ )
16
18
 
17
- EMPTY_FEEDBACK = Feedback.new.freeze
19
+ EMPTY_FEEDBACK = Feedback.new
18
20
 
21
+ # Returns feedback appropriate for the given score and match sequence.
22
+ #
23
+ # @param score [Integer] 0–4 score from the scorer
24
+ # @param sequence [Array<Match>] optimal match sequence
25
+ # @return [Feedback]
19
26
  def self.get_feedback(score, sequence)
20
27
  # starting feedback
21
28
  return DEFAULT_FEEDBACK if sequence.empty?
@@ -25,7 +32,7 @@ module Zxcvbn
25
32
 
26
33
  # tie feedback to the longest match for longer sequences
27
34
  longest_match = sequence[0]
28
- sequence[1..-1].each do |match|
35
+ sequence[1..].each do |match|
29
36
  longest_match = match if match.token.length > longest_match.token.length
30
37
  end
31
38
 
@@ -33,36 +40,46 @@ module Zxcvbn
33
40
  extra_feedback = 'Add another word or two. Uncommon words are better.'
34
41
 
35
42
  if feedback.nil?
36
- feedback = Feedback.new(suggestions: [extra_feedback])
43
+ Feedback.new(suggestions: [extra_feedback])
37
44
  else
38
- feedback.suggestions.unshift extra_feedback
45
+ feedback.with(suggestions: [extra_feedback, *feedback.suggestions])
39
46
  end
40
-
41
- feedback
42
47
  end
43
48
 
49
+ # Returns pattern-specific feedback for a single match, or nil if none applies.
50
+ #
51
+ # @param match [Match]
52
+ # @param is_sole_match [Boolean] true when this is the only match in the sequence
53
+ # @return [Feedback, nil]
44
54
  def self.get_match_feedback(match, is_sole_match)
45
55
  case match.pattern
46
56
  when 'dictionary'
47
57
  get_dictionary_match_feedback match, is_sole_match
48
58
 
49
59
  when 'spatial'
50
- warning = if match.turns == 1
51
- 'Straight rows of keys are easy to guess'
52
- else
53
- 'Short keyboard patterns are easy to guess'
54
- end
60
+ warning =
61
+ if match.turns == 1
62
+ 'Straight rows of keys are easy to guess'
63
+ else
64
+ 'Short keyboard patterns are easy to guess'
65
+ end
55
66
 
56
67
  Feedback.new(
57
- warning: warning,
68
+ warning:,
58
69
  suggestions: [
59
70
  'Use a longer keyboard pattern with more turns'
60
71
  ]
61
72
  )
62
73
 
63
74
  when 'repeat'
75
+ warning =
76
+ if match.base_token.length == 1
77
+ 'Repeats like "aaa" are easy to guess'
78
+ else
79
+ 'Repeats like "abcabcabc" are only slightly harder to guess than "abc"'
80
+ end
64
81
  Feedback.new(
65
- warning: 'Repeats like "aaa" are easy to guess',
82
+ warning:,
66
83
  suggestions: [
67
84
  'Avoid repeated words and characters'
68
85
  ]
@@ -76,6 +93,15 @@ module Zxcvbn
76
93
  ]
77
94
  )
78
95
 
96
+ when 'year'
97
+ Feedback.new(
98
+ warning: 'Recent years are easy to guess',
99
+ suggestions: [
100
+ 'Avoid recent years',
101
+ 'Avoid years that are associated with you'
102
+ ]
103
+ )
104
+
79
105
  when 'date'
80
106
  Feedback.new(
81
107
  warning: 'Dates are often easy to guess',
@@ -86,49 +112,48 @@ module Zxcvbn
86
112
  end
87
113
  end
88
114
 
115
+ # Returns feedback specific to a dictionary match.
116
+ #
117
+ # @param match [Match] a dictionary pattern match
118
+ # @param is_sole_match [Boolean] true when this is the only match in the sequence
119
+ # @return [Feedback]
89
120
  def self.get_dictionary_match_feedback(match, is_sole_match)
90
- warning = if match.dictionary_name == 'passwords'
91
- if is_sole_match && !match.l33t && !match.reversed
92
- if match.rank <= 10
93
- 'This is a top-10 common password'
94
- elsif match.rank <= 100
95
- 'This is a top-100 common password'
96
- else
97
- 'This is a very common password'
98
- end
99
- else
100
- 'This is similar to a commonly used password'
101
- end
102
- elsif NAME_DICTIONARIES.include? match.dictionary_name
103
- if is_sole_match
104
- 'Names and surnames by themselves are easy to guess'
105
- else
106
- 'Common names and surnames are easy to guess'
107
- end
108
- end
121
+ warning =
122
+ if match.dictionary_name == 'passwords'
123
+ if is_sole_match && !match.l33t && !match.reversed
124
+ if match.rank <= 10
125
+ 'This is a top-10 common password'
126
+ elsif match.rank <= 100
127
+ 'This is a top-100 common password'
128
+ else
129
+ 'This is a very common password'
130
+ end
131
+ elsif (match.guesses_log10 || 0) <= 4
132
+ 'This is similar to a commonly used password'
133
+ end
134
+ elsif match.dictionary_name == 'english_wikipedia'
135
+ 'A word by itself is easy to guess' if is_sole_match
136
+ elsif NAME_DICTIONARIES.include? match.dictionary_name
137
+ if is_sole_match
138
+ 'Names and surnames by themselves are easy to guess'
139
+ else
140
+ 'Common names and surnames are easy to guess'
141
+ end
142
+ end
109
143
 
110
144
  suggestions = []
111
145
  word = match.token
112
146
 
113
- if word =~ Zxcvbn::Entropy::START_UPPER
114
- suggestions.push "Capitalization doesn't help very much"
115
- elsif word =~ Zxcvbn::Entropy::ALL_UPPER && word.downcase != word
116
- suggestions.push(
117
- 'All-uppercase is almost as easy to guess as all-lowercase'
118
- )
147
+ if word =~ Zxcvbn::Guesses::START_UPPER
148
+ suggestions.push("Capitalization doesn't help very much")
149
+ elsif word =~ Zxcvbn::Guesses::ALL_UPPER && word.downcase != word
150
+ suggestions.push('All-uppercase is almost as easy to guess as all-lowercase')
119
151
  end
120
152
 
121
- if match.l33t
122
- suggestions.push(
123
- "Predictable substitutions like '@' instead of 'a' \
124
- don't help very much"
125
- )
126
- end
153
+ suggestions.push("Reversed words aren't much harder to guess") if match.reversed && match.token.length >= 4
154
+ suggestions.push("Predictable substitutions like '@' instead of 'a' don't help very much") if match.l33t
127
155
 
128
- Feedback.new(
129
- warning: warning,
130
- suggestions: suggestions
131
- )
156
+ Feedback.new(warning:, suggestions:)
132
157
  end
133
158
  end
134
159
  end