idxfence 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.
@@ -0,0 +1,81 @@
1
+ require_relative "model_parser"
2
+ require_relative "schema_parser"
3
+ require_relative "inflector"
4
+ require_relative "finding"
5
+
6
+ module Idxfence
7
+ # Joins the two passes: every uniqueness validation ModelParser found
8
+ # in app/models/*.rb, resolved to a table name (via
9
+ # Inflector.table_name_for, unless the model set `self.table_name`
10
+ # explicitly), against every unique index SchemaParser found in
11
+ # db/schema.rb for that same table. A validation with no matching
12
+ # unique index -- one covering exactly its column set (the validated
13
+ # column plus any `scope:` column(s), unordered) -- is a real gap: the
14
+ # AR validation can be raced, and only a DB-level unique index closes
15
+ # it. See DETAILS.md for the full algorithm and its documented
16
+ # limitations (case-insensitive uniqueness, irregular pluralization,
17
+ # etc).
18
+ class Checker
19
+ def initialize(model_paths:, schema_path:)
20
+ @model_paths = model_paths
21
+ @schema_path = schema_path
22
+ end
23
+
24
+ # @return [Array<Finding>, String] findings, and a warning string
25
+ # (nil if none) when db/schema.rb was missing/unparseable -- in
26
+ # that case findings is always [] since nothing can be
27
+ # cross-referenced without it.
28
+ def call
29
+ begin
30
+ schema_tables = SchemaParser.parse(schema_path)
31
+ rescue SchemaParser::MissingSchemaError => e
32
+ return [[], "idxfence: warning: #{e.message} -- skipping uniqueness cross-check for #{model_paths.size} model file(s)"]
33
+ end
34
+
35
+ findings = model_paths.flat_map do |model_path|
36
+ ModelParser.new(path: model_path).call.flat_map do |parsed_model|
37
+ check_model(parsed_model, schema_tables)
38
+ end
39
+ end
40
+
41
+ [findings, nil]
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :model_paths, :schema_path
47
+
48
+ def check_model(parsed_model, schema_tables)
49
+ return [] if parsed_model.validations.empty?
50
+
51
+ table = parsed_model.table_name_override || Inflector.table_name_for(parsed_model.class_name)
52
+ unique_indexes = schema_tables[table] || []
53
+
54
+ parsed_model.validations.filter_map do |validation|
55
+ required = ([validation.column] + validation.scope).sort
56
+ next if unique_indexes.any? { |index_cols| index_cols.sort == required }
57
+
58
+ Finding.new(
59
+ code: "IX001",
60
+ model: parsed_model.class_name,
61
+ table: table,
62
+ column: validation.column,
63
+ scope: validation.scope,
64
+ file: parsed_model.file,
65
+ line: validation.line,
66
+ message: build_message(parsed_model.class_name, table, validation)
67
+ )
68
+ end
69
+ end
70
+
71
+ def build_message(class_name, table, validation)
72
+ scope_desc = validation.scope.empty? ? "" : " scoped to #{validation.scope.join(', ')}"
73
+ columns_desc = ([validation.column] + validation.scope).join(", ")
74
+ "#{class_name} validates uniqueness of :#{validation.column}#{scope_desc} at the application layer only -- " \
75
+ "db/schema.rb has no unique index on #{table}(#{columns_desc}). Two concurrent requests can both pass " \
76
+ "the validation's SELECT check before either INSERT commits, producing a genuine duplicate row. Add a " \
77
+ "matching `add_index :#{table}, [#{[validation.column, *validation.scope].map { |c| ":#{c}" }.join(', ')}], unique: true` " \
78
+ "migration. See DETAILS.md."
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,64 @@
1
+ require "optparse"
2
+
3
+ module Idxfence
4
+ module CLI
5
+ module_function
6
+
7
+ def run(argv, out: $stdout, err: $stderr)
8
+ command, *rest = argv
9
+
10
+ case command
11
+ when "check"
12
+ check(rest, out: out, err: err)
13
+ else
14
+ err.puts usage
15
+ 2
16
+ end
17
+ end
18
+
19
+ def usage
20
+ "usage: idxfence check <rails-project-dir> [<rails-project-dir> ...] [--json]\n" \
21
+ " (each <rails-project-dir> must contain app/models/ and db/schema.rb)"
22
+ end
23
+
24
+ def check(argv, out:, err:)
25
+ options = { json: false }
26
+
27
+ parser = OptionParser.new do |opts|
28
+ opts.banner = usage
29
+ opts.on("--json", "emit JSON instead of text") { options[:json] = true }
30
+ end
31
+
32
+ begin
33
+ parser.parse!(argv)
34
+ rescue OptionParser::ParseError => e
35
+ err.puts "idxfence: #{e.message}"
36
+ return 2
37
+ end
38
+
39
+ if argv.empty?
40
+ err.puts usage
41
+ return 2
42
+ end
43
+
44
+ all_findings = []
45
+ warnings = []
46
+
47
+ argv.each do |project_dir|
48
+ findings, warning = Idxfence.check(project_dir: project_dir)
49
+ all_findings.concat(findings)
50
+ warnings << warning if warning
51
+ end
52
+
53
+ warning_text = warnings.empty? ? nil : warnings.join("\n")
54
+
55
+ if options[:json]
56
+ out.puts Render.json(all_findings, warning: warning_text)
57
+ else
58
+ out.puts Render.text(all_findings, warning: warning_text)
59
+ end
60
+
61
+ all_findings.empty? ? 0 : 1
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,26 @@
1
+ module Idxfence
2
+ # A single ActiveRecord `uniqueness: true` validation (application-layer
3
+ # only) with no matching database-level unique index to back it up --
4
+ # meaning two concurrent requests can both pass the validation's
5
+ # `SELECT ... WHERE` check before either one's `INSERT` commits, landing
6
+ # a genuine duplicate row despite the validation "working." See
7
+ # DETAILS.md for the exact race and why only a DB-level unique index
8
+ # closes it.
9
+ #
10
+ # code: "IX001" every finding uses this single code (one rule: an AR
11
+ # uniqueness validation with no matching DB unique index)
12
+ # model: the model class name, e.g. "User"
13
+ # table: the resolved table name the validation's column(s) were
14
+ # checked against, e.g. "users"
15
+ # column: the column the validation checks, e.g. "email"
16
+ # scope: Array<String> of scope column(s), e.g. ["account_id"]; empty
17
+ # array for an unscoped validation
18
+ # file: path to the model source file
19
+ # line: line number of the `validates`/`validates_uniqueness_of` call
20
+ # message: a human-readable description
21
+ Finding = Struct.new(:code, :model, :table, :column, :scope, :file, :line, :message, keyword_init: true) do
22
+ def to_h
23
+ { code: code, model: model, table: table, column: column, scope: scope, file: file, line: line, message: message }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,69 @@
1
+ module Idxfence
2
+ # A small, deliberately conservative stand-in for ActiveSupport::Inflector
3
+ # -- just enough of Rails' own table-name-inference convention
4
+ # (`Model.name.demodulize.underscore.pluralize`) to resolve a model's
5
+ # default table name without requiring an actual Rails/ActiveSupport
6
+ # installation. See DETAILS.md's "table-name inference" section for
7
+ # exactly which pluralization rules are (and are not) implemented, and
8
+ # why `self.table_name = "..."` is honored as an explicit override
9
+ # rather than something idxfence tries to out-guess.
10
+ module Inflector
11
+ UNCOUNTABLE = %w[series species equipment information news].freeze
12
+
13
+ IRREGULAR = {
14
+ "person" => "people",
15
+ "man" => "men",
16
+ "woman" => "women",
17
+ "child" => "children",
18
+ "tooth" => "teeth",
19
+ "foot" => "feet",
20
+ "mouse" => "mice",
21
+ "goose" => "geese"
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ # "Admin::UserAccount" -> "UserAccount" (only the last namespace
27
+ # segment -- matches Rails' default `table_name_prefix`-less
28
+ # behavior; a project that sets a custom `table_name_prefix` isn't
29
+ # modeled here, see DETAILS.md).
30
+ def demodulize(class_name)
31
+ class_name.to_s.split("::").last.to_s
32
+ end
33
+
34
+ # "UserAccount" -> "user_account"
35
+ def underscore(camel)
36
+ camel.to_s
37
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
38
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
39
+ .downcase
40
+ end
41
+
42
+ # "user_account" -> "user_accounts" (a deliberately small rule set,
43
+ # not a full English pluralizer -- see DETAILS.md limitations).
44
+ def pluralize(word)
45
+ return word if UNCOUNTABLE.include?(word)
46
+ return IRREGULAR[word] if IRREGULAR.key?(word)
47
+
48
+ case word
49
+ when /(ch|sh|ss|x|z)\z/
50
+ "#{word}es"
51
+ when /[^aeiou]y\z/
52
+ "#{word[0..-2]}ies"
53
+ when /(fe)\z/
54
+ "#{word[0..-3]}ves"
55
+ when /[^f]f\z/
56
+ "#{word[0..-2]}ves"
57
+ when /s\z/
58
+ word
59
+ else
60
+ "#{word}s"
61
+ end
62
+ end
63
+
64
+ # Rails' own default: `Model.name.demodulize.underscore.pluralize`.
65
+ def table_name_for(class_name)
66
+ pluralize(underscore(demodulize(class_name)))
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,219 @@
1
+ require "ripper"
2
+
3
+ module Idxfence
4
+ # Parses one Ruby source file with Ripper (structural boundaries --
5
+ # class start/end -- come from the s-expression, never from `do`/`end`
6
+ # counting; a narrower regex pass then runs only over the *text* of a
7
+ # class body already located structurally, the same hybrid technique
8
+ # this workspace's mailstall/jobclash/lockstall packages use) looking
9
+ # for a Rails model class (superclass `ApplicationRecord` or
10
+ # `ActiveRecord::Base`) and, within its own body:
11
+ #
12
+ # - an explicit `self.table_name = "..."` override, if present
13
+ # - every `validates :col[, :col2, ...], uniqueness: true` (or
14
+ # `uniqueness: { scope: ..., ... }`) and
15
+ # `validates_uniqueness_of :col[, :col2, ...][, scope: ...]`
16
+ # declaration, one UniquenessValidation per column named.
17
+ #
18
+ # This is deliberately one file's worth of information -- resolving it
19
+ # against a database-level unique index requires a second pass over
20
+ # db/schema.rb, done separately by Idxfence::SchemaParser and joined by
21
+ # Idxfence::Checker. See DETAILS.md's "two passes" section.
22
+ class ModelParser
23
+ MODEL_SUPERCLASS = /\A(?:ApplicationRecord|ActiveRecord::Base)\z/.freeze
24
+
25
+ UniquenessValidation = Struct.new(:column, :scope, :line, keyword_init: true)
26
+ ParsedModel = Struct.new(:class_name, :table_name_override, :validations, :file, keyword_init: true)
27
+
28
+ # One `validates`/`validates_uniqueness_of` statement's leading list
29
+ # of bare `:symbol` column arguments, e.g. the `:email, :username` in
30
+ # `validates :email, :username, uniqueness: true`.
31
+ LEADING_COLUMNS = /\A\s*(?:validates(?:_uniqueness_of)?)\s+((?::\w+\s*,\s*)*:\w+)/.freeze
32
+
33
+ SCOPE_SYMBOL = /scope:\s*:(\w+)/.freeze
34
+ SCOPE_ARRAY = /scope:\s*\[([^\]]*)\]/.freeze
35
+ SCOPE_PCT_I = /scope:\s*%i\[([^\]]*)\]/.freeze
36
+
37
+ SELF_TABLE_NAME = /self\.table_name\s*=\s*["']([^"']+)["']/.freeze
38
+
39
+ def initialize(path:)
40
+ @path = path
41
+ end
42
+
43
+ def call
44
+ source = File.read(path)
45
+ sexp = Ripper.sexp(source)
46
+ return [] unless sexp
47
+
48
+ lines = source.lines
49
+ models = []
50
+
51
+ each_class_node(sexp) do |class_name, class_node|
52
+ next unless model_class?(class_node)
53
+
54
+ start_line = min_line(class_node)
55
+ end_line = max_line(class_node)
56
+ next unless start_line && end_line
57
+
58
+ body_source = lines[(start_line - 1)...end_line].join
59
+
60
+ models << ParsedModel.new(
61
+ class_name: class_name,
62
+ table_name_override: (m = body_source.match(SELF_TABLE_NAME)) && m[1],
63
+ validations: uniqueness_validations(body_source, lines, start_line),
64
+ file: path
65
+ )
66
+ end
67
+
68
+ models
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :path
74
+
75
+ # Walks the class body's own source text statement-by-statement,
76
+ # buffering lines from a `validates`/`validates_uniqueness_of` call
77
+ # until its parens/braces balance (so an options hash wrapped across
78
+ # multiple lines, e.g. `uniqueness: { scope: ..., \n case_sensitive:
79
+ # false }`, is still read as one statement), then extracts every
80
+ # uniqueness-checked column out of that buffered statement.
81
+ def uniqueness_validations(body_source, all_lines, class_start_line)
82
+ validations = []
83
+ buffer = nil
84
+ buffer_start_line = nil
85
+ depth = 0
86
+ body_line_count = 0
87
+
88
+ body_source.each_line.with_index do |line, idx|
89
+ body_line_count = idx + 1
90
+ absolute_line = class_start_line + idx
91
+
92
+ if buffer.nil?
93
+ next unless line =~ /\bvalidates(?:_uniqueness_of)?\b/
94
+
95
+ buffer = +""
96
+ buffer_start_line = absolute_line
97
+ end
98
+
99
+ buffer << line
100
+ depth += line.count("{") + line.count("(") + line.count("[")
101
+ depth -= line.count("}") + line.count(")") + line.count("]")
102
+
103
+ next if depth > 0
104
+
105
+ validations.concat(parse_statement(buffer, buffer_start_line))
106
+ buffer = nil
107
+ buffer_start_line = nil
108
+ end
109
+
110
+ # Ripper.sexp's leaf position tracking only covers tokens that
111
+ # carry their own content (identifiers, literals, keywords) --
112
+ # a class body whose captured span ends on a line containing only
113
+ # closing punctuation (`}`, `end`) is not reflected in that span,
114
+ # so a statement buffer still open here (an options hash whose
115
+ # closing `}` fell on such a line) is finished by reading straight
116
+ # past the class node's own detected span, from the underlying
117
+ # file, until it balances or the file runs out.
118
+ if buffer
119
+ (class_start_line - 1 + body_line_count...all_lines.length).each do |i|
120
+ line = all_lines[i]
121
+ break unless line
122
+
123
+ buffer << line
124
+ depth += line.count("{") + line.count("(") + line.count("[")
125
+ depth -= line.count("}") + line.count(")") + line.count("]")
126
+ break if depth <= 0
127
+ end
128
+
129
+ validations.concat(parse_statement(buffer, buffer_start_line))
130
+ end
131
+
132
+ validations
133
+ end
134
+
135
+ def parse_statement(statement, line)
136
+ return [] unless statement =~ /\buniqueness:/ || statement =~ /\bvalidates_uniqueness_of\b/
137
+
138
+ columns_match = statement.match(LEADING_COLUMNS)
139
+ return [] unless columns_match
140
+
141
+ columns = columns_match[1].scan(/:(\w+)/).flatten
142
+ scope = extract_scope(statement)
143
+
144
+ columns.map { |col| UniquenessValidation.new(column: col, scope: scope, line: line) }
145
+ end
146
+
147
+ def extract_scope(statement)
148
+ if (m = statement.match(SCOPE_PCT_I))
149
+ return m[1].split(/\s+/).reject(&:empty?)
150
+ end
151
+
152
+ if (m = statement.match(SCOPE_ARRAY))
153
+ return m[1].scan(/:(\w+)/).flatten
154
+ end
155
+
156
+ if (m = statement.match(SCOPE_SYMBOL))
157
+ return [m[1]]
158
+ end
159
+
160
+ []
161
+ end
162
+
163
+ def model_class?(class_node)
164
+ superclass_node = class_node[2]
165
+ return false unless superclass_node
166
+
167
+ name = const_name(superclass_node)
168
+ !!(name && name.match?(MODEL_SUPERCLASS))
169
+ end
170
+
171
+ def each_class_node(node, &block)
172
+ return unless node.is_a?(Array)
173
+
174
+ if node[0] == :class
175
+ name = const_name(node[1])
176
+ block.call(name, node)
177
+ return
178
+ end
179
+
180
+ node.each { |child| each_class_node(child, &block) }
181
+ end
182
+
183
+ def const_name(node)
184
+ parts = []
185
+ collect_const_leaves(node, parts)
186
+ parts.empty? ? nil : parts.join("::")
187
+ end
188
+
189
+ def collect_const_leaves(node, acc)
190
+ return unless node.is_a?(Array)
191
+
192
+ acc << node[1] if node[0] == :@const && node[1].is_a?(String)
193
+ node.each { |child| collect_const_leaves(child, acc) }
194
+ end
195
+
196
+ def min_line(node)
197
+ lines = []
198
+ collect_lines(node, lines)
199
+ lines.min
200
+ end
201
+
202
+ def max_line(node)
203
+ lines = []
204
+ collect_lines(node, lines)
205
+ lines.max
206
+ end
207
+
208
+ def collect_lines(node, acc)
209
+ return unless node.is_a?(Array)
210
+
211
+ if node.length == 2 && node.all? { |x| x.is_a?(Integer) }
212
+ acc << node[0]
213
+ return
214
+ end
215
+
216
+ node.each { |child| collect_lines(child, acc) }
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,28 @@
1
+ require "json"
2
+
3
+ module Idxfence
4
+ module Render
5
+ module_function
6
+
7
+ def text(findings, warning: nil)
8
+ lines = []
9
+ lines << "idxfence: #{warning}" if warning
10
+
11
+ if findings.empty?
12
+ lines << "idxfence: no unguarded uniqueness validations found" unless warning
13
+ return lines.join("\n")
14
+ end
15
+
16
+ lines << "idxfence: #{findings.size} finding(s)"
17
+ findings.each do |f|
18
+ scope_desc = f.scope.empty? ? "" : " (scope: #{f.scope.join(', ')})"
19
+ lines << "[#{f.code}] #{f.file}:#{f.line} #{f.model}##{f.column}#{scope_desc} -> #{f.table} has no matching unique index: #{f.message}"
20
+ end
21
+ lines.join("\n")
22
+ end
23
+
24
+ def json(findings, warning: nil)
25
+ JSON.generate(findings: findings.map(&:to_h), warning: warning)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,127 @@
1
+ require "ripper"
2
+
3
+ module Idxfence
4
+ # Parses `db/schema.rb` (Rails' generated, declarative snapshot of the
5
+ # database schema) with Ripper looking for each `create_table` block's
6
+ # boundary structurally, then a narrower regex pass over that block's
7
+ # own text extracting every `t.index [...], unique: true` line -- the
8
+ # same hybrid technique Idxfence::ModelParser uses on model files. See
9
+ # DETAILS.md's "two passes" section for why this has to be a second,
10
+ # separate pass over a different file rather than something
11
+ # ModelParser could infer on its own.
12
+ #
13
+ # Returns a Hash of table_name => Array<Array<String>>, one entry per
14
+ # *unique* index found in that table's `create_table` block, each an
15
+ # array of the index's own column names in the order `t.index` declared
16
+ # them (order doesn't matter for matching -- Checker compares as sets).
17
+ # A non-unique `t.index` (no `unique: true`) is not collected at all --
18
+ # it can't enforce uniqueness at the database level regardless of what
19
+ # columns it covers.
20
+ class SchemaParser
21
+ CREATE_TABLE_IDENT = "create_table".freeze
22
+ TABLE_NAME_LINE = /create_table\s+["']([^"']+)["']/.freeze
23
+ INDEX_LINE = /t\.index\s+\[([^\]]*)\](?:[^\n]*?unique:\s*true)?/.freeze
24
+
25
+ class MissingSchemaError < StandardError; end
26
+
27
+ def self.parse(path)
28
+ raise MissingSchemaError, "#{path}: no such file" unless File.exist?(path)
29
+
30
+ new(path: path).call
31
+ end
32
+
33
+ def initialize(path:)
34
+ @path = path
35
+ end
36
+
37
+ def call
38
+ source = File.read(path)
39
+ sexp = Ripper.sexp(source)
40
+ raise MissingSchemaError, "#{path}: could not parse (invalid Ruby syntax)" unless sexp
41
+
42
+ lines = source.lines
43
+ tables = Hash.new { |h, k| h[k] = [] }
44
+
45
+ each_create_table_block(sexp) do |block_node|
46
+ start_line = min_line(block_node)
47
+ end_line = max_line(block_node)
48
+ next unless start_line && end_line
49
+
50
+ block_source = lines[(start_line - 1)...end_line].join
51
+ table_name = (m = block_source.match(TABLE_NAME_LINE)) && m[1]
52
+ next unless table_name
53
+
54
+ block_source.each_line do |line|
55
+ next unless line =~ /t\.index\b/
56
+
57
+ idx_match = line.match(INDEX_LINE)
58
+ next unless idx_match
59
+ next unless line.match?(/unique:\s*true/)
60
+
61
+ columns = idx_match[1].scan(/["']([^"']+)["']/).flatten
62
+ tables[table_name] << columns unless columns.empty?
63
+ end
64
+ end
65
+
66
+ tables
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :path
72
+
73
+ # Yields the block node of every `create_table "...", ... do |t| ...
74
+ # end` call (a `:method_add_block` s-expression whose call target's
75
+ # method name is exactly `create_table`).
76
+ def each_create_table_block(node, &block)
77
+ return unless node.is_a?(Array)
78
+
79
+ if node[0] == :method_add_block && create_table_call?(node[1])
80
+ block.call(node[2])
81
+ return
82
+ end
83
+
84
+ node.each { |child| each_create_table_block(child, &block) }
85
+ end
86
+
87
+ def create_table_call?(call_node)
88
+ ident = find_first_ident(call_node)
89
+ ident == CREATE_TABLE_IDENT
90
+ end
91
+
92
+ def find_first_ident(node)
93
+ return nil unless node.is_a?(Array)
94
+ return node[1] if node[0] == :@ident && node[1].is_a?(String)
95
+
96
+ node.each do |child|
97
+ found = find_first_ident(child)
98
+ return found if found
99
+ end
100
+
101
+ nil
102
+ end
103
+
104
+ def min_line(node)
105
+ lines = []
106
+ collect_lines(node, lines)
107
+ lines.min
108
+ end
109
+
110
+ def max_line(node)
111
+ lines = []
112
+ collect_lines(node, lines)
113
+ lines.max
114
+ end
115
+
116
+ def collect_lines(node, acc)
117
+ return unless node.is_a?(Array)
118
+
119
+ if node.length == 2 && node.all? { |x| x.is_a?(Integer) }
120
+ acc << node[0]
121
+ return
122
+ end
123
+
124
+ node.each { |child| collect_lines(child, acc) }
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,3 @@
1
+ module Idxfence
2
+ VERSION = "0.1.0"
3
+ end
data/lib/idxfence.rb ADDED
@@ -0,0 +1,40 @@
1
+ require_relative "idxfence/version"
2
+ require_relative "idxfence/finding"
3
+ require_relative "idxfence/inflector"
4
+ require_relative "idxfence/model_parser"
5
+ require_relative "idxfence/schema_parser"
6
+ require_relative "idxfence/checker"
7
+ require_relative "idxfence/render"
8
+
9
+ module Idxfence
10
+ class << self
11
+ # Scans a Rails project root for ActiveRecord `uniqueness: true` /
12
+ # `uniqueness: { ... }` / `validates_uniqueness_of` validations under
13
+ # `app/models/**/*.rb` with no matching unique index in
14
+ # `db/schema.rb`.
15
+ #
16
+ # @param project_dir [String] path to a Rails project root (the
17
+ # directory containing `app/` and `db/`)
18
+ # @return [Array(Array<Finding>, String, nil)] findings, and a
19
+ # warning string (nil if none) when db/schema.rb was missing or
20
+ # unparseable
21
+ def check(project_dir:)
22
+ model_paths = Dir.glob(File.join(project_dir, "app", "models", "**", "*.rb"))
23
+ schema_path = File.join(project_dir, "db", "schema.rb")
24
+
25
+ check_paths(model_paths: model_paths, schema_path: schema_path)
26
+ end
27
+
28
+ # Lower-level entry point: an explicit list of model file paths and
29
+ # one schema.rb path, for callers that already know their own
30
+ # layout (or for testing against fixtures that aren't laid out as a
31
+ # full Rails project).
32
+ #
33
+ # @param model_paths [Array<String>]
34
+ # @param schema_path [String]
35
+ # @return [Array(Array<Finding>, String, nil)]
36
+ def check_paths(model_paths:, schema_path:)
37
+ Checker.new(model_paths: model_paths, schema_path: schema_path).call
38
+ end
39
+ end
40
+ end