karst 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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. metadata +138 -0
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../value"
6
+
7
+ module Karst
8
+ module Access
9
+ # The local, machine-scoped record of which discovered candidate
10
+ # populations a developer has explicitly allowed Karst to execute
11
+ # automatically (see Karst::Access::ApprovedPopulations for how an
12
+ # approval is turned back into something runnable, and
13
+ # Karst::Access::PopulationDiscovery for what can be discovered at all).
14
+ #
15
+ # Deliberately *data*, never code: an entry is a model name and a scope
16
+ # name, and nothing else. Karst never writes a lambda, a snippet, or any
17
+ # other executable Ruby into this file, and never evaluates its contents
18
+ # -- an entry is only ever compared, as a string, against what current
19
+ # source-based discovery independently confirms. That is what keeps this
20
+ # file from degrading into an arbitrary method allowlist: adding
21
+ # `{"model": "User", "scope": "destroy_all"}` by hand approves nothing,
22
+ # because discovery will not confirm it.
23
+ #
24
+ # Stored under the host application's `tmp/` (`tmp/karst/`) on purpose:
25
+ # Rails already treats `tmp/` as machine-local, disposable, and
26
+ # git-ignored, which is exactly the intended lifetime of a local
27
+ # development approval. Deleting the file resets every approval; nothing
28
+ # else in Karst is affected. Karst never edits the host application's
29
+ # initializer or any other committed file.
30
+ #
31
+ # Every read fails closed. A file that is unreadable, is not JSON, is not
32
+ # the expected document shape, carries an unknown schema version, or
33
+ # holds a single unusable entry approves *nothing at all* and reports an
34
+ # error for the panel to show -- rather than partially trusting a
35
+ # document Karst cannot fully account for.
36
+ # rubocop:disable Metrics/ModuleLength
37
+ module PopulationApprovals
38
+ SCHEMA_VERSION = 1
39
+
40
+ RELATIVE_PATH = File.join("tmp", "karst", "approved_populations.json")
41
+
42
+ # Both names are matched against exactly what PopulationDiscovery can
43
+ # produce -- a real constant path, and a scope name Ripper read from a
44
+ # literal symbol/string in `scope :name, -> { ... }`. Anything else is
45
+ # rejected before it can even be compared to a discovered candidate.
46
+ MODEL_NAME = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
47
+ SCOPE_NAME = /\A[a-z_][A-Za-z0-9_]*\z/
48
+
49
+ # A bound on how much of this file Karst will consider at all, so a
50
+ # corrupted or maliciously grown document cannot turn every principal
51
+ # source resolution into an unbounded amount of source parsing.
52
+ MAX_ENTRIES = 500
53
+
54
+ Entry = Value.define(:model_name, :method_name) do
55
+ def matches?(model_name, method_name)
56
+ self.model_name == model_name.to_s && self.method_name == method_name.to_s
57
+ end
58
+
59
+ def display_label
60
+ "#{model_name}.#{method_name}"
61
+ end
62
+ end
63
+
64
+ # `entries` is always usable (possibly empty) and always sorted;
65
+ # `error` is a human-readable reason the stored document was rejected
66
+ # or could not be written, or nil.
67
+ Record = Value.define(:entries, :error) do
68
+ def approved?(model_name, method_name)
69
+ entries.any? { |entry| entry.matches?(model_name, method_name) }
70
+ end
71
+ end
72
+
73
+ class << self
74
+ def path
75
+ File.join(root, RELATIVE_PATH)
76
+ end
77
+
78
+ # The path as a developer should see it: relative to the application
79
+ # root, since that is where they will go looking for (or delete) it.
80
+ def display_path
81
+ RELATIVE_PATH
82
+ end
83
+
84
+ def load
85
+ document = JSON.parse(File.read(path))
86
+ parse(document)
87
+ rescue Errno::ENOENT
88
+ empty
89
+ rescue JSON::ParserError
90
+ failed("could not be read as JSON")
91
+ rescue StandardError => e
92
+ failed("could not be read (#{e.class})")
93
+ end
94
+
95
+ # Replaces the whole approval set with `entries`, atomically: callers
96
+ # always submit the complete list they intend to keep, so unapproving
97
+ # is simply approving a smaller set, and a partially written file can
98
+ # never be observed.
99
+ def replace(entries)
100
+ normalized = normalize(entries)
101
+ write(normalized)
102
+ Record.new(entries: normalized, error: nil)
103
+ rescue StandardError => e
104
+ Record.new(entries: normalized || [].freeze, error: "approvals could not be saved (#{e.class})")
105
+ end
106
+
107
+ private
108
+
109
+ def root
110
+ return Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
111
+
112
+ Dir.pwd
113
+ end
114
+
115
+ def parse(document)
116
+ reason = rejection(document)
117
+ return failed(reason) if reason
118
+
119
+ entries = document["approved"].map { |item| entry(item) }
120
+ return failed("holds an entry Karst does not recognize") if entries.include?(nil)
121
+
122
+ Record.new(entries: sort(entries.uniq).freeze, error: nil)
123
+ end
124
+
125
+ def rejection(document)
126
+ return "is not a Karst approval document" unless document.is_a?(Hash)
127
+ return "was written by an incompatible Karst version" unless document["version"] == SCHEMA_VERSION
128
+
129
+ approved = document["approved"]
130
+ return "is not a Karst approval document" unless approved.is_a?(Array)
131
+
132
+ "holds more than #{MAX_ENTRIES} entries" if approved.size > MAX_ENTRIES
133
+ end
134
+
135
+ def entry(item)
136
+ return nil unless item.is_a?(Hash)
137
+
138
+ model_name = item["model"]
139
+ method_name = item["scope"]
140
+ return nil unless model_name.is_a?(String) && method_name.is_a?(String)
141
+ return nil unless MODEL_NAME.match?(model_name) && SCOPE_NAME.match?(method_name)
142
+
143
+ Entry.new(model_name: model_name, method_name: method_name)
144
+ end
145
+
146
+ def normalize(entries)
147
+ usable = entries.filter_map do |item|
148
+ model_name = item.model_name.to_s
149
+ method_name = item.method_name.to_s
150
+ next unless MODEL_NAME.match?(model_name) && SCOPE_NAME.match?(method_name)
151
+
152
+ Entry.new(model_name: model_name, method_name: method_name)
153
+ end
154
+ sort(usable.uniq).first(MAX_ENTRIES).freeze
155
+ end
156
+
157
+ # One deterministic order everywhere -- the file, the panel, and the
158
+ # order Karst would try approved populations in -- so the same set of
159
+ # approvals always produces byte-identical storage and the same
160
+ # search behavior, regardless of checkbox submission order.
161
+ def sort(entries)
162
+ entries.sort_by { |item| [item.model_name, item.method_name] }
163
+ end
164
+
165
+ def write(entries)
166
+ target = path
167
+ FileUtils.mkdir_p(File.dirname(target))
168
+ temporary = "#{target}.#{Process.pid}.tmp"
169
+ File.write(temporary, "#{JSON.pretty_generate(document(entries))}\n")
170
+ File.rename(temporary, target)
171
+ ensure
172
+ FileUtils.rm_f(temporary) if temporary
173
+ end
174
+
175
+ def document(entries)
176
+ {
177
+ "version" => SCHEMA_VERSION,
178
+ "approved" => entries.map { |entry| { "model" => entry.model_name, "scope" => entry.method_name } }
179
+ }
180
+ end
181
+
182
+ def empty
183
+ Record.new(entries: [].freeze, error: nil)
184
+ end
185
+
186
+ def failed(reason)
187
+ Record.new(entries: [].freeze,
188
+ error: "#{display_path} #{reason}; Karst approved no populations from it. " \
189
+ "Delete the file and approve again.")
190
+ end
191
+ end
192
+ end
193
+ # rubocop:enable Metrics/ModuleLength
194
+ end
195
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+
5
+ module Karst
6
+ module Access
7
+ # Renders a developer's curated selection of
8
+ # Karst::Access::PopulationDiscovery::Candidate into a Ruby config
9
+ # snippet they can copy into their own Karst.configure block. Karst
10
+ # never writes to the host application's files on its own -- the
11
+ # developer stays in control of what actually gets committed (see
12
+ # README "Curation / persistence").
13
+ module PopulationConfigSnippet
14
+ # wired is the subset actually rendered into `code` (every selected
15
+ # candidate whose model matches a configured principal source).
16
+ # unwired is every selected candidate that is not wired into
17
+ # anything yet -- present so the UI can say so honestly instead of
18
+ # silently dropping a selection (see README "Principal vs artifact
19
+ # populations": this release only wires principal populations).
20
+ Result = Value.define(:code, :wired, :unwired)
21
+
22
+ class << self
23
+ def generate(candidates)
24
+ unique = candidates.uniq { |candidate| [candidate.model_name, candidate.method_name] }
25
+ wired, unwired = unique.partition(&:principal_source)
26
+ Result.new(code: render(wired), wired: wired, unwired: unwired)
27
+ end
28
+
29
+ private
30
+
31
+ def render(wired)
32
+ return "# Select at least one population above to generate a configuration snippet.\n" if wired.empty?
33
+
34
+ by_source = wired.group_by(&:principal_source)
35
+ by_source.size == 1 && by_source.keys.first == :default ? flat(by_source.fetch(:default)) : nested(by_source)
36
+ end
37
+
38
+ def flat(candidates)
39
+ "config.principal_populations = {\n#{entries(candidates, indent: 2)}\n}\n"
40
+ end
41
+
42
+ def nested(by_source)
43
+ sources = by_source.map do |source, group|
44
+ " #{source_key(source)}: {\n populations: {\n#{entries(group, indent: 6)}\n }\n }"
45
+ end.join(",\n")
46
+ "config.principal_sources = {\n#{sources}\n}\n"
47
+ end
48
+
49
+ # Sorted by model then method so the same selection always renders
50
+ # byte-identical output, regardless of checkbox submission order.
51
+ def entries(candidates, indent:)
52
+ pad = " " * indent
53
+ sorted = candidates.sort_by { |candidate| [candidate.model_name, candidate.method_name.to_s] }
54
+ sorted.map { |candidate| entry(candidate, pad) }.join(",\n")
55
+ end
56
+
57
+ def entry(candidate, pad)
58
+ "#{pad}#{candidate.method_name}: -> { #{candidate.model_name}.#{candidate.method_name} }"
59
+ end
60
+
61
+ def source_key(source)
62
+ source.to_s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ ? source : source.inspect
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+ require_relative "../value"
5
+
6
+ module Karst
7
+ module Access
8
+ # Finds zero-argument Rails scope declarations by parsing model source.
9
+ # Ripper is part of Ruby's standard library (including Ruby 2.7), so this
10
+ # does not raise Karst's minimum Ruby version or add a parser dependency.
11
+ # Discovery never calls a scope or any other model method.
12
+ class PopulationDiscovery
13
+ FRAMEWORK_NAMESPACES = %w[ActiveRecord ActiveStorage ActionText ActionMailbox].freeze
14
+
15
+ Candidate = Value.define(:model_name, :method_name, :principal_source)
16
+
17
+ ModelGroup = Value.define(:model_name, :candidate_names, :principal_source) do
18
+ def candidates
19
+ candidate_names.map do |method_name|
20
+ Candidate.new(model_name: model_name, method_name: method_name, principal_source: principal_source)
21
+ end
22
+ end
23
+ end
24
+
25
+ Result = Value.define(:model_groups, :load_warning) do
26
+ def candidates
27
+ model_groups.flat_map(&:candidates)
28
+ end
29
+ end
30
+
31
+ def call
32
+ Result.new(model_groups: model_groups, load_warning: @load_warning)
33
+ end
34
+
35
+ # Confirms one exact model/scope pair against the model's *current*
36
+ # source, executing nothing. Deliberately takes the class the caller
37
+ # already holds (always an already-configured principal source's own
38
+ # model, see Karst::Access::ApprovedPopulations) rather than a name to
39
+ # look up: a stored approval can therefore never cause Karst to
40
+ # constantize, autoload, or reach a class the application had not
41
+ # already pointed it at, and this check stays a single file parse
42
+ # instead of a full application eager load.
43
+ #
44
+ # Fails closed by construction: it answers "would discovery list this
45
+ # candidate right now," so a scope that was deleted, renamed, given
46
+ # parameters, or replaced by an ordinary class method stops being
47
+ # confirmed as soon as the source changes.
48
+ def confirms?(klass:, method_name:)
49
+ name = method_name.to_sym
50
+ klass.respond_to?(name) && scope_names(klass).include?(name)
51
+ end
52
+
53
+ private
54
+
55
+ def model_groups
56
+ candidate_models.filter_map do |klass|
57
+ names = scope_names(klass)
58
+ next if names.empty?
59
+
60
+ ModelGroup.new(model_name: klass.name, candidate_names: names,
61
+ principal_source: principal_source_for(klass))
62
+ end.sort_by(&:model_name)
63
+ end
64
+
65
+ def eager_load_application!
66
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
67
+
68
+ Rails.application.eager_load!
69
+ rescue StandardError => e
70
+ @load_warning = "The application could not be fully loaded (#{e.class}: #{e.message}); " \
71
+ "discovery may be missing some models."
72
+ end
73
+
74
+ def candidate_models
75
+ @candidate_models ||= begin
76
+ eager_load_application!
77
+ if defined?(ActiveRecord::Base)
78
+ ActiveRecord::Base.descendants.select { |klass| application_model?(klass) }.uniq
79
+ else
80
+ []
81
+ end
82
+ end
83
+ end
84
+
85
+ def application_model?(klass)
86
+ klass.name && !klass.abstract_class? && !framework_model?(klass)
87
+ end
88
+
89
+ def framework_model?(klass)
90
+ FRAMEWORK_NAMESPACES.any? { |namespace| klass.name == namespace || klass.name.start_with?("#{namespace}::") }
91
+ end
92
+
93
+ # Memoized per instance so one discovery pass parses each model's
94
+ # source file exactly once, whether it is reached by #call or by a
95
+ # sequence of #confirms? checks over the same class.
96
+ def scope_names(klass)
97
+ @scope_names ||= {}
98
+ @scope_names.fetch(klass) { @scope_names[klass] = parse_scope_names(klass) }
99
+ end
100
+
101
+ def parse_scope_names(klass)
102
+ file, = Object.const_source_location(klass.name)
103
+ return [] unless file && File.file?(file)
104
+
105
+ SourceScopes.new(File.read(file), klass.name).call
106
+ rescue StandardError
107
+ []
108
+ end
109
+
110
+ def principal_source_for(klass)
111
+ principal_source_klasses.each { |name, source_klass| return name if source_klass == klass }
112
+ nil
113
+ end
114
+
115
+ # Memoized around the configuration read as well as the result:
116
+ # resolving the effective principal sources now also resolves locally
117
+ # approved populations (see Karst::Access::ApprovedPopulations), so
118
+ # re-reading it once per model group would re-read the approval file
119
+ # and re-parse model source once per model group too.
120
+ def principal_source_klasses
121
+ @principal_source_klasses ||= begin
122
+ sources = Karst.config.principal_sources || {}
123
+ sources.each_with_object({}) do |(name, source), memo|
124
+ klass = source.record_klass
125
+ memo[name] = klass if klass
126
+ end
127
+ end
128
+ end
129
+
130
+ # A deliberately small Ripper AST reader. It recognizes only literal
131
+ # names and the two callable forms accepted here: -> {} and lambda {}.
132
+ # rubocop:disable Metrics/ClassLength
133
+ class SourceScopes
134
+ def initialize(source, model_name)
135
+ @tree = Ripper.sexp(source)
136
+ @model_name = model_name
137
+ end
138
+
139
+ def call
140
+ return [] unless @tree
141
+
142
+ find_classes(@tree, []).uniq.sort
143
+ end
144
+
145
+ private
146
+
147
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
148
+ def find_classes(node, namespace)
149
+ return [] unless node.is_a?(Array)
150
+
151
+ case node[0]
152
+ when :module
153
+ name = constant_name(node[1])
154
+ find_classes(node[2], qualify(namespace, name))
155
+ when :class
156
+ name = constant_name(node[1])
157
+ full_name = qualify(namespace, name)
158
+ direct = full_name.join("::") == @model_name ? scopes_in_body(node[3]) : []
159
+ direct + find_classes(node[3], full_name)
160
+ else
161
+ node.flat_map { |child| find_classes(child, namespace) }
162
+ end
163
+ end
164
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
165
+
166
+ def qualify(namespace, name)
167
+ return namespace unless name
168
+ return name.split("::") if name.include?("::")
169
+
170
+ namespace + [name]
171
+ end
172
+
173
+ def constant_name(node)
174
+ return unless node.is_a?(Array)
175
+ return node[1] if node[0] == :@const
176
+ return constant_name(node[1]) if %i[const_ref top_const_ref].include?(node[0])
177
+
178
+ [constant_name(node[1]), constant_name(node[2])].compact.join("::") if node[0] == :const_path_ref
179
+ end
180
+
181
+ def scopes_in_body(body)
182
+ statements = body && body[0] == :bodystmt ? body[1] : []
183
+ Array(statements).filter_map { |statement| scope_name(statement) }
184
+ end
185
+
186
+ def scope_name(node)
187
+ arguments = scope_arguments(node)
188
+ return unless arguments && arguments.length >= 2
189
+ return unless zero_argument_callable?(arguments[1])
190
+
191
+ literal_name(arguments[0])
192
+ end
193
+
194
+ def scope_arguments(node)
195
+ return unless node.is_a?(Array)
196
+
197
+ return unless scope_call?(node)
198
+
199
+ argument_list(node[2])
200
+ end
201
+
202
+ def scope_call?(node)
203
+ (node[0] == :command && identifier(node[1]) == "scope") ||
204
+ (node[0] == :method_add_arg && fcall_name(node[1]) == "scope")
205
+ end
206
+
207
+ def argument_list(node)
208
+ node = node[1] if node && node[0] == :arg_paren
209
+ node && node[0] == :args_add_block ? node[1] : nil
210
+ end
211
+
212
+ def fcall_name(node)
213
+ node = node[1] if node && node[0] == :method_add_arg
214
+ identifier(node[1]) if node && node[0] == :fcall
215
+ end
216
+
217
+ def identifier(node)
218
+ node[1] if node && %i[@ident @op].include?(node[0])
219
+ end
220
+
221
+ def literal_name(node)
222
+ return unless node
223
+
224
+ case node[0]
225
+ when :symbol_literal then simple_symbol(node)
226
+ when :dyna_symbol, :string_literal then static_string(node[1])&.to_sym
227
+ end
228
+ end
229
+
230
+ def simple_symbol(node)
231
+ token = node.dig(1, 1)
232
+ token[1].to_sym if token && token[0].to_s.start_with?("@")
233
+ end
234
+
235
+ def static_string(node)
236
+ return unless node && node[0] == :string_content
237
+
238
+ tokens = node.drop(1)
239
+ return unless tokens.all? { |token| token[0] == :@tstring_content }
240
+
241
+ tokens.map { |token| token[1] }.join
242
+ end
243
+
244
+ def zero_argument_callable?(node)
245
+ if node && node[0] == :lambda
246
+ empty_params?(node[1])
247
+ elsif lambda_block?(node)
248
+ block = node[2]
249
+ empty_params?(block[1])
250
+ else
251
+ false
252
+ end
253
+ end
254
+
255
+ def lambda_block?(node)
256
+ node && node[0] == :method_add_block && fcall_name(node[1]) == "lambda" &&
257
+ %i[brace_block do_block].include?(node[2]&.first)
258
+ end
259
+
260
+ def empty_params?(node)
261
+ return true if node.nil?
262
+
263
+ node = node[1] if node[0] == :paren
264
+ node && node[0] == :params && node.drop(1).all?(&:nil?)
265
+ end
266
+ end
267
+ # rubocop:enable Metrics/ClassLength
268
+ private_constant :SourceScopes
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+ require_relative "candidate_population"
5
+
6
+ module Karst
7
+ module Access
8
+ # The explicit, bounded "does this discovered candidate actually work"
9
+ # step -- deliberately separate from Karst::Access::PopulationDiscovery,
10
+ # which never executes anything. Only ever resolves a model/method pair
11
+ # that a given Karst::Access::PopulationDiscovery::Result itself already
12
+ # discovered; a submitted name that is not on that list is rejected
13
+ # without calling anything, so this can never become a general "call any
14
+ # class method by name" endpoint. Every resolution is bounded to
15
+ # PREVIEW_LIMIT rows via Karst::Access::CandidatePopulation.resolve --
16
+ # never a COUNT, never full materialization.
17
+ class PopulationPreview
18
+ PREVIEW_LIMIT = 3
19
+
20
+ Result = Value.define(:model_name, :method_name, :resolved, :records, :error)
21
+
22
+ def self.call(model_name:, method_name:, discovery_result:)
23
+ new(model_name: model_name, method_name: method_name, discovery_result: discovery_result).call
24
+ end
25
+
26
+ def initialize(model_name:, method_name:, discovery_result:)
27
+ @model_name = model_name.to_s
28
+ @method_name = method_name.to_s
29
+ @discovery_result = discovery_result
30
+ end
31
+
32
+ def call
33
+ return unresolved("this is not a discovered candidate") unless known_candidate?
34
+
35
+ klass = model_class
36
+ return unresolved("the model could not be resolved") unless klass
37
+
38
+ resolve(klass)
39
+ end
40
+
41
+ private
42
+
43
+ def known_candidate?
44
+ @discovery_result.candidates.any? do |candidate|
45
+ candidate.model_name == @model_name && candidate.method_name.to_s == @method_name
46
+ end
47
+ end
48
+
49
+ def model_class
50
+ return nil unless defined?(ActiveRecord::Base)
51
+
52
+ ActiveRecord::Base.descendants.find { |klass| klass.name == @model_name }
53
+ end
54
+
55
+ def resolve(klass)
56
+ method_name = @method_name
57
+ return missing_scope unless scope_exists?(klass, method_name)
58
+
59
+ population = CandidatePopulation.resolve(
60
+ name: method_name.to_sym, callable: -> { klass.public_send(method_name) },
61
+ source_klass: klass, limit: PREVIEW_LIMIT
62
+ )
63
+ return unresolved("did not resolve to a usable ActiveRecord::Relation for #{@model_name}") unless population
64
+
65
+ Result.new(model_name: @model_name, method_name: @method_name, resolved: true, records: population.records,
66
+ error: nil)
67
+ end
68
+
69
+ def scope_exists?(klass, method_name)
70
+ klass.respond_to?(method_name)
71
+ end
72
+
73
+ def missing_scope
74
+ unresolved("the discovered scope no longer exists on #{@model_name}")
75
+ end
76
+
77
+ def unresolved(error)
78
+ Result.new(model_name: @model_name, method_name: @method_name, resolved: false, records: [].freeze,
79
+ error: error)
80
+ end
81
+ end
82
+ end
83
+ end