pinspec 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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. metadata +134 -0
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ class Error < StandardError
5
+ class << self
6
+ attr_writer :exit_code
7
+
8
+ def exit_code
9
+ @exit_code || 1
10
+ end
11
+ end
12
+
13
+ def exit_code
14
+ self.class.exit_code
15
+ end
16
+ end
17
+
18
+ class UnparsableSource < Error
19
+ self.exit_code = 1
20
+ end
21
+
22
+ class TargetNotFound < Error
23
+ self.exit_code = 2
24
+ end
25
+
26
+ class AmbiguousTarget < Error
27
+ self.exit_code = 3
28
+ end
29
+
30
+ class BlockRequired < Error
31
+ self.exit_code = 4
32
+ end
33
+
34
+ class UnresolvableSetup < Error
35
+ self.exit_code = 5
36
+
37
+ REASONS = %i[
38
+ association_cycle
39
+ attachment
40
+ opaque_constructor
41
+ unknown_column_type
42
+ apartment
43
+ unresolvable_parameter
44
+ ].freeze
45
+
46
+ attr_reader :reason
47
+
48
+ def initialize(reason, message = nil)
49
+ unless REASONS.include?(reason)
50
+ raise ArgumentError, "unknown UnresolvableSetup reason: #{reason.inspect}"
51
+ end
52
+
53
+ @reason = reason
54
+ super(message || reason.to_s.tr("_", " "))
55
+ end
56
+ end
57
+
58
+ class SchemaFormatUnsupported < Error
59
+ self.exit_code = 6
60
+ end
61
+
62
+ class ProbeFailure < Error
63
+ self.exit_code = 7
64
+ end
65
+
66
+ class NothingStableToPin < Error
67
+ self.exit_code = 8
68
+ end
69
+
70
+ class VerifyFailed < Error
71
+ self.exit_code = 9
72
+ end
73
+
74
+ class UnsupportedRailsVersion < Error
75
+ self.exit_code = 10
76
+ end
77
+
78
+ class EnvironmentRefused < Error
79
+ self.exit_code = 11
80
+ end
81
+
82
+ class PinspecInternalError < Error
83
+ self.exit_code = 12
84
+ end
85
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ module Inputs
5
+ module Boundary
6
+ BY_TYPE = {
7
+ "Integer" => [0, 1, -1],
8
+ "Float" => [0.0, 1.0, -1.0],
9
+ "Boolean" => [true, false],
10
+ "String" => ["", "pinspec"],
11
+ "Symbol" => nil,
12
+ "Array" => [[]],
13
+ "Hash" => [{}],
14
+ "Proc" => nil,
15
+ "Date" => [PINSPEC_EPOCH[0, 10]],
16
+ "Time" => [PINSPEC_EPOCH]
17
+ }.freeze
18
+
19
+ DECIMAL_HINTS = %w[BigDecimal Decimal].freeze
20
+
21
+ class << self
22
+ def values_for(param, column: nil, default_first: true)
23
+ declared = default_value(param)
24
+ edges = edges_for(param, column)
25
+
26
+ ordered = default_first ? [declared, *edges] : [*edges, declared]
27
+
28
+ present(ordered).map { |value| encode(value, param) }.uniq
29
+ end
30
+
31
+ def base_value(param, column: nil)
32
+ declared = default_value(param)
33
+ return Tags.encode(declared) unless declared == :__none__
34
+
35
+ encode(Array(edges_for(param, column)).first, param)
36
+ end
37
+
38
+ def omittable?(param)
39
+ param.optional?
40
+ end
41
+
42
+ private
43
+
44
+ def encode(value, param)
45
+ return Tags.decimal(value) if decimal?(param) && !value.nil?
46
+
47
+ Tags.encode(value)
48
+ end
49
+
50
+ def decimal?(param)
51
+ DECIMAL_HINTS.include?(param.type_hint)
52
+ end
53
+
54
+ def present(values)
55
+ values.reject { |value| value == :__none__ }
56
+ end
57
+
58
+ def default_value(param)
59
+ return :__none__ if param.default_source.nil?
60
+
61
+ literal_from_source(param.default_source)
62
+ end
63
+
64
+ def literal_from_source(source)
65
+ text = source.to_s.strip
66
+
67
+ case text
68
+ when "nil" then nil
69
+ when "true" then true
70
+ when "false" then false
71
+ when /\A:([A-Za-z_]\w*)\z/ then Regexp.last_match(1).to_sym
72
+ when /\A-?\d+\z/ then text.to_i
73
+ when /\A-?\d+\.\d+\z/ then text.to_f
74
+ when /\A"([^"]*)"\z/, /\A'([^']*)'\z/ then Regexp.last_match(1)
75
+ when "[]" then []
76
+ when "{}" then {}
77
+ else :__none__
78
+ end
79
+ end
80
+
81
+ def edges_for(param, column = nil)
82
+ hint = param.type_hint
83
+
84
+ return decimal_edges if DECIMAL_HINTS.include?(hint)
85
+ return BY_TYPE.fetch(hint) if BY_TYPE.key?(hint)
86
+
87
+ from_column = column && edges_for_column_type(column.type)
88
+ return from_column if from_column
89
+
90
+ [nil]
91
+ end
92
+
93
+ def edges_for_column_type(type)
94
+ case type
95
+ when :string, :text, :citext then BY_TYPE.fetch("String")
96
+ when :integer, :bigint, :smallint then BY_TYPE.fetch("Integer")
97
+ when :float then BY_TYPE.fetch("Float")
98
+ when :decimal, :numeric, :money then decimal_edges
99
+ when :boolean then BY_TYPE.fetch("Boolean")
100
+ when :date then BY_TYPE.fetch("Date")
101
+ when :datetime, :timestamp, :timestamptz then BY_TYPE.fetch("Time")
102
+ when :json, :jsonb, :hstore then BY_TYPE.fetch("Hash")
103
+ end
104
+ end
105
+
106
+ def decimal_edges
107
+ %w[0.0 1.0 -1.0]
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ module Inputs
5
+ class Corpus
6
+ DEFAULT_MAX_CASES = 12
7
+
8
+ OMIT = :__pinspec_omit__
9
+
10
+ class << self
11
+ def build(target:, plan:, schema: nil, max_cases: DEFAULT_MAX_CASES, imports: [])
12
+ new(target: target, plan: plan, schema: schema, max_cases: max_cases, imports: imports).build
13
+ end
14
+ end
15
+
16
+ def initialize(target:, plan:, schema: nil, max_cases: DEFAULT_MAX_CASES, imports: [])
17
+ @target = target
18
+ @plan = plan
19
+ @schema = schema
20
+ @max_cases = max_cases
21
+ @imports = imports
22
+ end
23
+
24
+ def build
25
+ cases = [base_case]
26
+ cases.concat(boundary_cases(@max_cases - cases.size))
27
+
28
+ InputCorpus.new(cases: dedup(cases), setup_plan: @plan)
29
+ end
30
+
31
+ private
32
+
33
+ def base_case
34
+ build_case("c001", :defaults) { |param| base_value_for(param) }
35
+ end
36
+
37
+ def boundary_cases(budget)
38
+ return [] if budget <= 0
39
+
40
+ variations = round_robin(ctor_variations, method_variations).first(budget)
41
+
42
+ variations.each_with_index.map do |(param, value), index|
43
+ build_case(format("c%03d", index + 2), :boundary) do |candidate|
44
+ candidate.equal?(param) ? value : base_value_for(candidate)
45
+ end
46
+ end
47
+ end
48
+
49
+ def round_robin(first, second)
50
+ interleaved = []
51
+ [first.size, second.size].max.times do |index|
52
+ interleaved << first[index] if first[index]
53
+ interleaved << second[index] if second[index]
54
+ end
55
+
56
+ interleaved
57
+ end
58
+
59
+ def ctor_variations
60
+ variations_for(@target.initializer_params)
61
+ end
62
+
63
+ def method_variations
64
+ variations_for(@target.params)
65
+ end
66
+
67
+ def variations_for(params)
68
+ params.flat_map do |param|
69
+ next [] if bound?(param)
70
+ next [] if %i[rest keyrest].include?(param.kind)
71
+
72
+ base = base_value_for(param)
73
+ values = Boundary.values_for(param, column: column_for(param))
74
+ .reject { |value| value == base }
75
+
76
+ values += [OMIT] if Boundary.omittable?(param)
77
+
78
+ values.map { |value| [param, value] }
79
+ end
80
+ end
81
+
82
+ def build_case(id, origin)
83
+ ctor_args, ctor_kwargs = split_params(@target.initializer_params) { |param| yield(param) }
84
+ args, kwargs = split_params(@target.params) { |param| yield(param) }
85
+
86
+ InputCase.new(
87
+ id: id,
88
+ ctor_args: ctor_args,
89
+ ctor_kwargs: ctor_kwargs,
90
+ args: args,
91
+ kwargs: kwargs,
92
+ origin: origin
93
+ )
94
+ end
95
+
96
+ def split_params(params)
97
+ positional = []
98
+ keyword = {}
99
+
100
+ params.each do |param|
101
+ value = yield(param)
102
+ next if value == OMIT
103
+
104
+ if %i[keyreq key].include?(param.kind)
105
+ keyword[param.name.to_s] = value
106
+ elsif %i[req opt].include?(param.kind)
107
+ positional << value
108
+ end
109
+ end
110
+
111
+ [positional, keyword]
112
+ end
113
+
114
+ def base_value_for(param)
115
+ ref = @plan.binding_for(param.name)
116
+ return Tags.ref(ref) if ref
117
+
118
+ Boundary.base_value(param, column: column_for(param))
119
+ end
120
+
121
+ def column_for(param)
122
+ return nil if @schema.nil?
123
+
124
+ matches = @schema.tables.filter_map { |table| table.column(param.name) }
125
+ return nil if matches.empty?
126
+
127
+ types = matches.map(&:type).uniq
128
+ types.size == 1 ? matches.first : nil
129
+ end
130
+
131
+ def bound?(param)
132
+ !@plan.binding_for(param.name).nil?
133
+ end
134
+
135
+ def dedup(cases)
136
+ seen = {}
137
+
138
+ cases.each_with_object([]) do |input_case, kept|
139
+ signature = input_case.signature
140
+ next if seen[signature]
141
+
142
+ seen[signature] = true
143
+ kept << input_case
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pinspec
6
+ module Inputs
7
+ class Hydrator
8
+ MAX_DEPTH = 3
9
+ MAX_ROWS_PER_CLUSTER = 20
10
+
11
+ VOLATILE = %w[created_at updated_at].freeze
12
+ DB_FUNCTION_DEFAULT = /\A(?:->|gen_random_uuid|now|uuid_generate_v4|CURRENT_)/i.freeze
13
+
14
+ def initialize(schema:, redactor:, factories:, max_depth: MAX_DEPTH,
15
+ max_rows: MAX_ROWS_PER_CLUSTER)
16
+ raise ArgumentError, "factories: is required - without it an imported row " \
17
+ "gets a camelized table name, which is the wrong constant " \
18
+ "on any app whose tables sit behind an engine prefix" if factories.nil?
19
+
20
+ @schema = schema
21
+ @redactor = redactor
22
+ @resolver = Setup::DependencyResolver.new(schema, factories)
23
+ @max_depth = max_depth
24
+ @max_rows = max_rows
25
+ end
26
+
27
+ def hydrate(table, rows, all_rows, target_source: "")
28
+ @notes = []
29
+ clusters = []
30
+ ordinal = 0
31
+
32
+ @refs = {}
33
+ @counters = Hash.new(0)
34
+ @budget = @max_rows
35
+
36
+ Array(rows).each do |row|
37
+ ordinal += 1
38
+ clusters.concat(build_cluster(table, row, all_rows, ordinal, target_source))
39
+ end
40
+
41
+ [clusters, @notes]
42
+ end
43
+
44
+ private
45
+
46
+ def build_cluster(table, row, all_rows, ordinal, target_source)
47
+ emitted = []
48
+
49
+ walk = lambda do |current_table, current_row, depth|
50
+ return nil if current_row.nil?
51
+ return nil if depth > @max_depth
52
+ return nil if @budget <= 0
53
+
54
+ key = row_key(current_table, current_row)
55
+ return @refs[key] if @refs.key?(key)
56
+
57
+ @counters[current_table] += 1
58
+ ref = "imported_#{singular(current_table)}_#{@counters[current_table]}"
59
+ @refs[key] = ref
60
+ @budget -= 1
61
+
62
+ assoc_refs = {}
63
+
64
+ all_parents(current_table).each do |column, parent_table|
65
+ parent_id = current_row[column] || current_row[column.to_sym]
66
+ parent_row = find_row(all_rows, parent_table, parent_id)
67
+
68
+ if parent_row.nil?
69
+ assoc_refs[column] = handle_missing_parent(current_table, column, parent_table)
70
+ next
71
+ end
72
+
73
+ parent_ref = walk.call(parent_table, parent_row, depth + 1)
74
+ assoc_refs[column] = parent_ref.nil? ? nil : Tags.ref(parent_ref)
75
+ end
76
+
77
+ emitted << build_import(current_table, current_row, ref, assoc_refs, ordinal, target_source)
78
+ ref
79
+ end
80
+
81
+ walk.call(table, row, 0)
82
+ emitted
83
+ end
84
+
85
+ def handle_missing_parent(table, column, parent_table)
86
+ nullable = column_of(table, column)&.nullable?
87
+
88
+ if nullable
89
+ note(:cross_cluster_nulled,
90
+ "#{table}.#{column} pointed outside the sampled rows; it is nullable, " \
91
+ "so the import leaves it null")
92
+ return Tags.encode(nil)
93
+ end
94
+
95
+ note(:row_discarded,
96
+ "#{table}.#{column} requires a #{parent_table} that was not sampled, so " \
97
+ "that row cannot be recreated and was dropped")
98
+ nil
99
+ end
100
+
101
+ def build_import(table, row, ref, assoc_refs, ordinal, target_source)
102
+ attrs, redacted = @redactor.redact(importable_attrs(table, row), ordinal: ordinal, table: table)
103
+
104
+ tagged = attrs.transform_values { |value| Tags.encode(value) }
105
+ tagged.merge!(assoc_refs.compact)
106
+
107
+ flags = []
108
+ reads = @redactor.reads_in(target_source, redacted)
109
+ flags << :redaction_read unless reads.empty?
110
+
111
+ reads.each do |read|
112
+ note(:redaction_read,
113
+ "the target reads #{read[:attribute]} at line #{read[:line]}, and that " \
114
+ "value was rewritten - the pin would freeze behaviour the app never has")
115
+ end
116
+
117
+ ImportCluster.new(
118
+ model: model_for(table),
119
+ table: table,
120
+ name: ref,
121
+ attrs: tagged,
122
+ source: provenance(table, row),
123
+ redacted: redacted,
124
+ flags: flags
125
+ )
126
+ end
127
+
128
+ def importable_attrs(table, row)
129
+ schema_table = @schema.table(table)
130
+ pk = Array(schema_table&.primary_key)
131
+ fk_columns = all_parents(table).map(&:first)
132
+
133
+ row.each_with_object({}) do |(column, value), out|
134
+ name = column.to_s
135
+ next if pk.include?(name)
136
+ next if VOLATILE.include?(name)
137
+ next if fk_columns.include?(name)
138
+ next if db_function_default?(schema_table, name)
139
+
140
+ out[name] = value
141
+ end
142
+ end
143
+
144
+ def db_function_default?(schema_table, column_name)
145
+ default = schema_table&.column(column_name)&.default
146
+ return false if default.nil?
147
+
148
+ default.to_s.match?(DB_FUNCTION_DEFAULT)
149
+ end
150
+
151
+ def provenance(table, row)
152
+ identifier = row["id"] || row[:id] || row.values.first
153
+
154
+ "sample:#{table}:#{Digest::SHA1.hexdigest("#{table}:#{identifier}")[0, 8]}"
155
+ end
156
+
157
+ def all_parents(table)
158
+ schema_table = @schema.table(table)
159
+ return [] if schema_table.nil?
160
+
161
+ schema_table.columns.filter_map do |column|
162
+ fk = @schema.fk_for(table, column.name)
163
+ fk ? [column.name, fk.to_table] : nil
164
+ end
165
+ end
166
+
167
+ def find_row(all_rows, table, id)
168
+ return nil if id.nil?
169
+
170
+ Array(all_rows[table] || all_rows[table.to_sym]).find do |row|
171
+ (row["id"] || row[:id]).to_s == id.to_s
172
+ end
173
+ end
174
+
175
+ def column_of(table, column_name)
176
+ @schema.table(table)&.column(column_name)
177
+ end
178
+
179
+ def row_key(table, row)
180
+ "#{table}:#{row['id'] || row[:id] || row.hash}"
181
+ end
182
+
183
+ def model_for(table)
184
+ @resolver.model_for(table)
185
+ end
186
+
187
+ def singular(table)
188
+ Analyzer::Inflector.singular_candidates(table).first
189
+ end
190
+
191
+ def note(kind, detail)
192
+ @notes << { kind: kind, detail: detail }
193
+ nil
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pinspec
6
+ module Inputs
7
+ class Redactor
8
+ BUILT_IN = %w[
9
+ email phone phone_number mobile
10
+ ssn social_security_number
11
+ dob date_of_birth birthdate
12
+ first_name last_name full_name middle_name maiden_name
13
+ address address1 address2 address_line1 address_line2 street city postcode zip zipcode
14
+ ip_address remote_ip
15
+ token api_key access_token refresh_token secret password_digest encrypted_password
16
+ ].freeze
17
+
18
+ def initialize(attributes: [], enabled: true)
19
+ @extra = Array(attributes).map(&:to_s)
20
+ @enabled = enabled
21
+ end
22
+
23
+ def enabled?
24
+ @enabled
25
+ end
26
+
27
+ PERSON_TABLES = %w[
28
+ customers users people contacts employees members clients patients
29
+ subscribers accounts admins authors owners guests students staff
30
+ ].freeze
31
+
32
+ PERSON_COLUMNS = %w[name display_name nickname username handle].freeze
33
+
34
+ def redacts?(column_name, table: nil)
35
+ return false unless @enabled
36
+
37
+ name = column_name.to_s
38
+ return true if BUILT_IN.include?(name) || @extra.include?(name)
39
+
40
+ PERSON_COLUMNS.include?(name) && PERSON_TABLES.include?(table.to_s)
41
+ end
42
+
43
+ def redact(attrs, ordinal:, table: nil)
44
+ redacted = []
45
+
46
+ rewritten = attrs.each_with_object({}) do |(column, value), out|
47
+ if redacts?(column, table: table) && value.is_a?(String) && !value.empty?
48
+ out[column] = rewrite(column.to_s, value, ordinal)
49
+ redacted << column.to_s
50
+ else
51
+ out[column] = value
52
+ end
53
+ end
54
+
55
+ [rewritten, redacted]
56
+ end
57
+
58
+ def reads_in(source, columns)
59
+ return [] unless @enabled
60
+
61
+ Array(columns).uniq.filter_map do |column|
62
+ line = read_line(source, column.to_s)
63
+ next unless line
64
+
65
+ { attribute: column.to_s, line: line }
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def read_line(source, column)
72
+ pattern = /(?:\.#{Regexp.escape(column)}\b|:#{Regexp.escape(column)}\b|["']#{Regexp.escape(column)}["'])/
73
+
74
+ source.to_s.lines.each_with_index do |text, index|
75
+ return index + 1 if text.match?(pattern)
76
+ end
77
+
78
+ nil
79
+ end
80
+
81
+ def rewrite(column, value, ordinal)
82
+ case column
83
+ when /email/ then rewrite_email(value, ordinal)
84
+ when /phone|mobile/ then rewrite_digits(value, ordinal)
85
+ when /ssn|social_security/ then rewrite_digits(value, ordinal)
86
+ when /dob|birth/ then rewrite_date(value)
87
+ when /token|key|secret|password/ then rewrite_hex(value, ordinal)
88
+ when /ip_address|remote_ip/ then rewrite_ip(value, ordinal)
89
+ else rewrite_filler(value, ordinal)
90
+ end
91
+ end
92
+
93
+ def rewrite_email(value, ordinal)
94
+ local, _, domain = value.rpartition("@")
95
+ return rewrite_filler(value, ordinal) if domain.empty? || local.empty?
96
+
97
+ "#{same_length_token(local, ordinal)}@#{domain}"
98
+ end
99
+
100
+ def rewrite_digits(value, ordinal)
101
+ counter = ordinal.abs
102
+ digits = format("%0#{value.count('0-9')}d", counter)
103
+ index = -1
104
+
105
+ value.gsub(/\d/) do
106
+ index += 1
107
+ digits[index] || "0"
108
+ end
109
+ end
110
+
111
+ def rewrite_date(value)
112
+ value.match?(/\A\d{4}-\d{2}-\d{2}/) ? "1970-01-01#{value[10..]}" : value
113
+ end
114
+
115
+ def rewrite_hex(value, ordinal)
116
+ digest = Digest::SHA256.hexdigest("pinspec:#{ordinal}")
117
+ (digest * ((value.length / digest.length) + 1))[0, value.length]
118
+ end
119
+
120
+ def rewrite_ip(value, ordinal)
121
+ return rewrite_digits(value, ordinal) if value.include?(":")
122
+
123
+ "192.0.2.#{(ordinal.abs % 254) + 1}"
124
+ end
125
+
126
+ def rewrite_filler(value, ordinal)
127
+ same_length_token(value, ordinal)
128
+ end
129
+
130
+ def same_length_token(value, ordinal)
131
+ seed = "p#{ordinal}"
132
+ return seed[0, value.length] if seed.length >= value.length
133
+
134
+ seed + ("x" * (value.length - seed.length))
135
+ end
136
+ end
137
+ end
138
+ end