karst 0.1.0 → 0.2.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.
@@ -1,83 +0,0 @@
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
@@ -1,233 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/string/inflections"
4
- require_relative "../value"
5
- require_relative "../identity"
6
-
7
- module Karst
8
- module Access
9
- # Given one exact resource (the specific record a route addresses by id)
10
- # and one specific principal (typically the successful outcome from an
11
- # Access::Sweep), reports simple, directly observed foreign-key
12
- # relationships between those two records. This is evidence, not an
13
- # authorization claim: it never states or implies *why* an outcome
14
- # occurred, only which foreign-key columns, if any, point from one given
15
- # record to the other's id.
16
- #
17
- # Deliberately narrow, matching the v1 scope this class was built for:
18
- # - only foreign-key-shaped columns (ending in "_id") on the two given
19
- # records are ever inspected -- never an arbitrary attribute, so no
20
- # other column value (name, email, token, ...) is ever read or shown;
21
- # - only a direct column-value comparison between the two given records,
22
- # never a join, a has_many traversal, or any multi-hop graph walk;
23
- # - resource resolution from a route path is attempted only through
24
- # Rails' own route recognition plus its controller-to-model naming
25
- # convention, and only trusted when every step succeeds unambiguously
26
- # (a recognized route with an :id segment, a controller name that
27
- # classifies to a real loaded Active Record class, and a record that
28
- # actually exists for that id). Anything softer -- an unrecognized
29
- # route, a controller with no conventional model, a missing record --
30
- # is reported as a limitation rather than guessed at.
31
- # rubocop:disable Metrics/ClassLength
32
- class ResourceEvidence
33
- class Error < StandardError; end
34
-
35
- # The resource side never gets Identity::PrincipalDescriptor's
36
- # configurable display_label hook -- there is no equivalent concept
37
- # for "the current route's resource" -- so it gets its own minimal,
38
- # equally attribute-free descriptor.
39
- ResourceDescriptor = Value.define(:model_name, :id)
40
-
41
- # from_model/from_id is whichever of the resource/principal actually
42
- # holds the foreign-key column; to_model/to_id is the other side.
43
- Relationship = Value.define(:column, :from_model, :from_id, :to_model, :to_id)
44
-
45
- Result = Value.define(:principal, :resource, :relationships, :observed_status, :observed_redirect,
46
- :limitation) do
47
- # Plain-text rendering deliberately kept free of causal wording
48
- # ("owns", "is authorized", "grants") -- see class comment above.
49
- def to_text
50
- lines = [principal.display_label]
51
- lines << observed_line if observed_status || observed_redirect
52
- lines << "" << "Related state:"
53
- lines.concat(related_state_lines)
54
- lines.join("\n")
55
- end
56
-
57
- private
58
-
59
- def observed_line
60
- observed_redirect ? "Observed #{observed_status} → #{observed_redirect}" : "Observed #{observed_status}"
61
- end
62
-
63
- def related_state_lines
64
- return [" Unavailable: #{limitation}"] if limitation
65
- return [no_relationship_line] if relationships.empty?
66
-
67
- grouped_relationship_lines
68
- end
69
-
70
- def no_relationship_line
71
- " No observed foreign-key relationship to #{resource.model_name} ##{resource.id}."
72
- end
73
-
74
- def grouped_relationship_lines
75
- relationships.group_by { |rel| [rel.from_model, rel.from_id] }.flat_map do |(model, id), grouped|
76
- ["#{model} ##{id}"] + grouped.map { |rel| " #{rel.column} → #{rel.to_model} ##{rel.to_id}" }
77
- end
78
- end
79
- end
80
-
81
- class << self
82
- # Resolves the resource, resolves the principal (from a
83
- # Sweep::Outcome's PrincipalDescriptor), and reports relationships in
84
- # one call. Either resolution step may fail safely -- see
85
- # #resolve_resource and #resolve_principal -- in which case the
86
- # Result carries a limitation instead of relationships.
87
- def for_outcome(outcome:, path:, http_method: "GET", application: nil)
88
- resource, resource_limitation = resolve_resource(path: path, http_method: http_method,
89
- application: application)
90
- principal, principal_limitation = resolve_principal(outcome.principal)
91
- limitation = [resource_limitation, principal_limitation].compact.join("; ")
92
-
93
- return unresolved_result(outcome, limitation) if resource.nil? || principal.nil?
94
-
95
- new(resource: resource, principal: principal).call(
96
- observed_status: outcome.status, observed_redirect: outcome.redirect
97
- )
98
- end
99
-
100
- # Attempts to resolve the exact record a route addresses, trusting
101
- # only Rails' own route recognition and controller naming
102
- # convention, and only when every step is unambiguous. Returns
103
- # [record, nil] on success or [nil, reason] when any step is not
104
- # reliable -- never a guessed record.
105
- def resolve_resource(path:, http_method: "GET", application: nil)
106
- app = application || rails_application
107
- return [nil, "no Rails application is available to recognize the route"] unless app
108
-
109
- params = recognize(app, path, http_method)
110
- return [nil, "the route could not be recognized"] unless params
111
-
112
- id = params[:id]
113
- return [nil, "the recognized route has no :id segment addressing one specific resource"] unless id
114
-
115
- find_by_controller(params[:controller], id)
116
- end
117
-
118
- # Resolves the actual record behind a Karst::Identity::PrincipalDescriptor
119
- # only through the configured principal source. A valid model/id outside
120
- # that source is deliberately unresolved.
121
- def resolve_principal(descriptor)
122
- record = Identity.resolve(model_name: descriptor.model_name, id: descriptor.id)
123
- return [nil, "principal is not available from the configured principal source"] unless record
124
-
125
- [record, nil]
126
- rescue Identity::Error
127
- [nil, "the configured principal source is unavailable"]
128
- end
129
-
130
- private
131
-
132
- def rails_application
133
- defined?(Rails) && Rails.respond_to?(:application) && Rails.application
134
- end
135
-
136
- def find_by_controller(controller, id)
137
- klass = active_record_class(controller.to_s.classify)
138
- return [nil, "the route's controller does not map to a loaded Active Record model by convention"] unless klass
139
-
140
- record = klass.find_by(klass.primary_key => id)
141
- return [nil, "no #{klass.name} record exists for id #{id.inspect}"] unless record
142
-
143
- [record, nil]
144
- end
145
-
146
- def unresolved_result(outcome, limitation)
147
- Result.new(principal: outcome.principal, resource: nil, relationships: [].freeze,
148
- observed_status: outcome.status, observed_redirect: outcome.redirect,
149
- limitation: limitation.empty? ? "the resource or principal could not be resolved" : limitation)
150
- end
151
-
152
- def recognize(app, path, http_method)
153
- app.routes.recognize_path(path, method: http_method)
154
- rescue StandardError
155
- nil
156
- end
157
-
158
- def active_record_class(name)
159
- klass = name.to_s.safe_constantize
160
- return nil unless defined?(ActiveRecord::Base) && klass.is_a?(Class) && klass < ActiveRecord::Base
161
-
162
- klass
163
- end
164
- end
165
-
166
- def initialize(resource:, principal:)
167
- @resource = resource
168
- @principal = principal
169
- end
170
-
171
- def call(observed_status: nil, observed_redirect: nil)
172
- Result.new(
173
- principal: Identity.describe(@principal),
174
- resource: ResourceDescriptor.new(model_name: model_name(@resource), id: primary_key_value(@resource)),
175
- relationships: relationships.freeze,
176
- observed_status: observed_status,
177
- observed_redirect: observed_redirect,
178
- limitation: nil
179
- )
180
- end
181
-
182
- private
183
-
184
- def relationships
185
- foreign_keys_from(@resource, @principal) + foreign_keys_from(@principal, @resource)
186
- end
187
-
188
- def foreign_keys_from(source, target)
189
- return [] unless active_record?(source) && active_record?(target)
190
-
191
- source.class.columns_hash.values.filter_map { |column| relationship_for(source, target, column) }
192
- end
193
-
194
- def relationship_for(source, target, column)
195
- return unless foreign_key_column?(source.class, column)
196
- return unless targets?(column, target.class)
197
-
198
- value = source.public_send(column.name)
199
- return if value.nil? || value != primary_key_value(target)
200
-
201
- Relationship.new(column: column.name, from_model: model_name(source), from_id: primary_key_value(source),
202
- to_model: model_name(target), to_id: primary_key_value(target))
203
- end
204
-
205
- def foreign_key_column?(klass, column)
206
- column.name.end_with?("_id") && column.name != klass.primary_key
207
- end
208
-
209
- # "Obvious" is judged purely from the column's own name against the
210
- # target's actual class (including its Active Record ancestry, so a
211
- # single-table-inherited subclass still matches its base class's
212
- # conventional foreign key) -- never a declared association, never any
213
- # other column's value, and never a second hop through another model.
214
- def targets?(column, target_klass)
215
- candidate = column.name.delete_suffix("_id").classify.safe_constantize
216
- candidate.is_a?(Class) && (target_klass <= candidate || candidate <= target_klass)
217
- end
218
-
219
- def active_record?(record)
220
- defined?(ActiveRecord::Base) && record.is_a?(ActiveRecord::Base)
221
- end
222
-
223
- def model_name(record)
224
- record.class.respond_to?(:model_name) ? record.class.model_name.name.to_s : record.class.name.to_s
225
- end
226
-
227
- def primary_key_value(record)
228
- record.public_send(record.class.primary_key)
229
- end
230
- end
231
- # rubocop:enable Metrics/ClassLength
232
- end
233
- end
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require_relative "principal"
5
- require_relative "scenario"
6
-
7
- module Karst
8
- module Spec
9
- # Read-only index over the JSON artifact Karst::Spec::Observer writes,
10
- # answering "what scenarios have been observed for this controller/action"
11
- # without requiring RSpec, Rails, or the host application's database to be
12
- # loaded. This is pure data ingestion: JSON primitives in, immutable
13
- # Scenario objects out. It never deserializes arbitrary Ruby objects,
14
- # never constantizes a principal type, and never queries anything.
15
- #
16
- # A Catalog is always in exactly one of three states:
17
- #
18
- # - :missing -- the artifact does not exist yet (the suite has never
19
- # been run with the observer installed).
20
- # - :invalid -- the artifact exists but could not be read as the expected
21
- # JSON array (empty file, malformed JSON, or an incompatible top-level
22
- # shape). `error` holds a human-readable reason.
23
- # - :ready -- the artifact was a valid JSON array. `scenarios` may still
24
- # be empty (a suite that observed zero browser-facing requests), which
25
- # is deliberately distinct from :missing: "no scenarios cover this
26
- # route" is not the same claim as "the catalog was never generated."
27
- #
28
- # Malformed individual entries inside an otherwise valid array (a request
29
- # missing its controller, an example with no requests at all) are skipped
30
- # individually rather than invalidating the whole artifact.
31
- # rubocop:disable Metrics/ClassLength
32
- class Catalog
33
- DEFAULT_RELATIVE_PATH = File.join("tmp", "karst", "scenarios.json")
34
- private_constant :DEFAULT_RELATIVE_PATH
35
-
36
- EMPTY = [].freeze
37
- private_constant :EMPTY
38
-
39
- OUTCOMES = %w[passed failed pending].freeze
40
- private_constant :OUTCOMES
41
-
42
- class << self
43
- # Rails' own tmp/ convention when available, otherwise a plain
44
- # relative path so this stays usable from a bare Ruby process (tests,
45
- # a future CLI) with no Rails loaded.
46
- def default_path
47
- return DEFAULT_RELATIVE_PATH unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
48
-
49
- File.join(Rails.root.to_s, DEFAULT_RELATIVE_PATH)
50
- end
51
-
52
- def load(path: default_path)
53
- new(*read(path))
54
- end
55
-
56
- private
57
-
58
- def read(path)
59
- return [:missing, EMPTY, nil] unless File.exist?(path)
60
-
61
- raw = File.read(path)
62
- return [:invalid, EMPTY, "scenario artifact at #{path} is empty"] if raw.strip.empty?
63
-
64
- parse(raw, path)
65
- rescue SystemCallError => e
66
- [:invalid, EMPTY, "scenario artifact at #{path} could not be read: #{e.message}"]
67
- end
68
-
69
- def parse(raw, path)
70
- parsed = JSON.parse(raw)
71
- return [:invalid, EMPTY, "scenario artifact at #{path} is not a JSON array"] unless parsed.is_a?(Array)
72
-
73
- [:ready, build_scenarios(parsed), nil]
74
- rescue JSON::ParserError => e
75
- [:invalid, EMPTY, "scenario artifact at #{path} is not valid JSON: #{e.message}"]
76
- end
77
-
78
- # Ordered deterministically so two reads of an unchanged artifact
79
- # agree: passed examples before failed/pending ones, then file path,
80
- # line number, example id, and finally request sequence (the
81
- # tie-breaker that keeps multiple scenarios from the same example in
82
- # their original request order). Explicitly labelled QA scenarios lead
83
- # discovered scenarios while preserving those guarantees within each
84
- # group.
85
- def build_scenarios(examples)
86
- scenarios = examples.flat_map { |example| scenarios_from_example(example) }
87
- scenarios.sort_by { |scenario| sort_key(scenario) }.freeze
88
- end
89
-
90
- def sort_key(scenario)
91
- [scenario.explicit? ? 0 : 1, scenario.passed? ? 0 : 1, scenario.file_path, scenario.line_number,
92
- scenario.example_id, scenario.sequence]
93
- end
94
-
95
- def scenarios_from_example(example)
96
- return EMPTY unless example.is_a?(Hash) && valid_example?(example)
97
-
98
- example["requests"].filter_map { |request| scenario_from_request(request, example) }
99
- end
100
-
101
- def valid_example?(example)
102
- example["example_id"].is_a?(String) && example["file_path"].is_a?(String) &&
103
- example["line_number"].is_a?(Integer) && example["description_parts"].is_a?(Array) &&
104
- example["full_description"].is_a?(String) && example["requests"].is_a?(Array)
105
- end
106
-
107
- # A request only becomes a Scenario once it carries enough evidence to
108
- # be indexed and shown: a browser-facing (HTML) response, a resolved
109
- # controller/action, an HTTP method, and its position within the
110
- # example. Everything else observed about the request (route pattern,
111
- # path, status, redirect target, principal before/after) is retained
112
- # as-is, including nil, since a nil there is itself real evidence
113
- # (e.g. the request never reached `process_action`).
114
- def scenario_from_request(request, example)
115
- return nil unless request.is_a?(Hash) && request["format"] == "html" && valid_request?(request)
116
-
117
- build_scenario(request, example)
118
- rescue StandardError
119
- nil
120
- end
121
-
122
- def valid_request?(request)
123
- required = %w[controller action method].all? { |key| non_empty_string?(request[key]) }
124
- required && request["sequence"].is_a?(Integer)
125
- end
126
-
127
- def non_empty_string?(value)
128
- value.is_a?(String) && !value.empty?
129
- end
130
-
131
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
132
- def build_scenario(request, example)
133
- Scenario.new(
134
- example_id: example["example_id"], file_path: example["file_path"], line_number: example["line_number"],
135
- description_parts: example["description_parts"].freeze, full_description: example["full_description"],
136
- karst_explicit: example["karst_explicit"] == true,
137
- karst_name: non_empty_string?(example["karst_name"]) ? example["karst_name"] : nil,
138
- example_outcome: outcome_for(example["outcome"]),
139
- controller: request["controller"], action: request["action"], http_method: request["method"],
140
- route_pattern: request["route_pattern"], observed_path: request["path"],
141
- observed_status: request["status"], observed_redirect: request["redirect_location"],
142
- principal_before: principal_from(request["principal_before"]),
143
- principal_after: principal_from(request["principal_after"]),
144
- principal_changed: request["principal_changed"] == true, sequence: request["sequence"]
145
- ).freeze
146
- end
147
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
148
-
149
- def outcome_for(raw)
150
- OUTCOMES.include?(raw) ? raw.to_sym : :unknown
151
- end
152
-
153
- def principal_from(raw)
154
- return nil unless raw.is_a?(Hash)
155
-
156
- Principal.new(type: raw["type"], id: raw["id"], scope: raw["scope"])
157
- end
158
- end
159
- private_class_method :new
160
-
161
- attr_reader :status, :scenarios, :error
162
-
163
- def initialize(status, scenarios, error)
164
- @status = status
165
- @scenarios = scenarios
166
- @error = error
167
- @index = build_index(scenarios)
168
- freeze
169
- end
170
-
171
- def ready?
172
- status == :ready
173
- end
174
-
175
- # Indexed primarily by controller/action -- Rails' own stable routing
176
- # identity -- so "/things/1" and "/things/2" report as the same
177
- # capability instead of fragmenting by dynamic id. `http_method`
178
- # narrows further for the (uncommon) case where one controller/action
179
- # legitimately answers more than one verb; omitted, every scenario for
180
- # that controller/action is returned regardless of method.
181
- def scenarios_for(controller:, action:, http_method: nil)
182
- matches = @index.fetch([controller, action], EMPTY)
183
- return matches if http_method.nil?
184
-
185
- normalized = http_method.to_s.upcase
186
- matches.select { |scenario| scenario.http_method == normalized }.freeze
187
- end
188
-
189
- private
190
-
191
- def build_index(scenarios)
192
- scenarios.group_by { |scenario| [scenario.controller, scenario.action] }
193
- .transform_values(&:freeze)
194
- .freeze
195
- end
196
- end
197
- # rubocop:enable Metrics/ClassLength
198
- end
199
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../value"
4
-
5
- module Karst
6
- module Spec
7
- # One RSpec example's complete observed request history: every request it
8
- # issued, in order, plus enough of RSpec's own metadata (file/line,
9
- # nested description, stable example id, pass/fail outcome) to present
10
- # and re-locate the example without re-parsing spec source.
11
- ExampleObservation = Value.define(
12
- :example_id,
13
- :file_path,
14
- :line_number,
15
- :spec_type,
16
- :description_parts,
17
- :full_description,
18
- :karst_explicit,
19
- :karst_name,
20
- :outcome,
21
- :requests
22
- ) do
23
- # An example is part of Karst's route/page catalog only if at least one
24
- # of its requests rendered HTML -- an examples whose only requests are
25
- # JSON API calls has nothing to show a person exercising a page.
26
- def browser_facing?
27
- requests.any? { |request| request.format == "html" }
28
- end
29
- end
30
- end
31
- end