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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- 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
|