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,199 @@
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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger" # see lib/karst.rb for why this must precede active_support on Rails 6.1
4
+ require "active_support"
5
+ require "active_support/notifications"
6
+ require_relative "../execution_context"
7
+ require_relative "principal"
8
+ require_relative "request_observation"
9
+ require_relative "example_observation"
10
+ require_relative "reporter"
11
+
12
+ module Karst
13
+ module Spec
14
+ class InvalidMetadataError < StandardError; end
15
+
16
+ # Turns real RSpec execution into Karst's route/scenario catalog.
17
+ #
18
+ # Karst never parses spec source, route-helper arguments, or FactoryBot
19
+ # calls to build this catalog. It observes the same runtime facts the
20
+ # request-evidence engine already relies on -- ActiveSupport::Notifications
21
+ # for controller/render events, and Warden's public hooks for the
22
+ # authenticated principal -- while an example actually runs, and records
23
+ # only what those events say. An example that never issues an HTTP
24
+ # request produces no observation at all.
25
+ #
26
+ # Explicitly out of scope here: provisioning scenario state, database
27
+ # cloning or isolation, browser/session switching, source analysis, and
28
+ # any UI. This module only builds and writes the catalog artifact.
29
+ # rubocop:disable Metrics/ModuleLength
30
+ module Observer
31
+ KEY = :karst_spec_observer_current
32
+ private_constant :KEY
33
+
34
+ # Mutable accumulator for the example currently running. `principal` is
35
+ # updated in place by the Warden hooks as the example progresses, so
36
+ # each request observes the principal that was active when it happened.
37
+ Current = Struct.new(:requests, :principal, keyword_init: true)
38
+ private_constant :Current
39
+
40
+ # Mutable request-in-progress. Frozen into a RequestObservation once the
41
+ # example finishes; never exposed outside this module. Named
42
+ # `http_method`, not `method`, so it never shadows Object#method.
43
+ RequestBuilder = Struct.new(
44
+ :sequence, :http_method, :path, :route_pattern, :controller, :action, :format,
45
+ :status, :redirect_location, :principal_before, :principal_after,
46
+ keyword_init: true
47
+ )
48
+ private_constant :RequestBuilder
49
+
50
+ # rubocop:disable Metrics/ClassLength
51
+ class << self
52
+ attr_reader :reporter, :output_path
53
+
54
+ # rubocop:disable Metrics/MethodLength
55
+ def install!(output:)
56
+ @install_mutex ||= Mutex.new
57
+ @install_mutex.synchronize do
58
+ return @reporter if @installed
59
+
60
+ raise "Karst::Spec::Observer requires RSpec to already be loaded" unless defined?(RSpec)
61
+
62
+ @output_path = output
63
+ @reporter = Reporter.new
64
+ subscribe_notifications
65
+ subscribe_warden
66
+ configure_rspec
67
+ @installed = true
68
+ @reporter
69
+ end
70
+ end
71
+ # rubocop:enable Metrics/MethodLength
72
+
73
+ # The one seam RSpec's `around` hook calls into: start tracking,
74
+ # run the example, then convert whatever was tracked into an
75
+ # immutable ExampleObservation and hand it to the Reporter.
76
+ def wrap_example(example)
77
+ karst_explicit, karst_name = karst_metadata(example)
78
+ start_example!
79
+ yield
80
+ ensure
81
+ finish_and_record!(example, karst_explicit: karst_explicit, karst_name: karst_name)
82
+ end
83
+
84
+ private
85
+
86
+ def current
87
+ Karst::ExecutionContext[KEY]
88
+ end
89
+
90
+ def start_example!
91
+ Karst::ExecutionContext[KEY] = Current.new(requests: [], principal: nil)
92
+ end
93
+
94
+ def finish_example!
95
+ state = current
96
+ Karst::ExecutionContext.delete(KEY)
97
+ state
98
+ end
99
+
100
+ def subscribe_notifications
101
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*args|
102
+ on_start_processing(args.last)
103
+ end
104
+ ActiveSupport::Notifications.subscribe("redirect_to.action_controller") do |*args|
105
+ on_redirect(args.last)
106
+ end
107
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
108
+ on_process_action(args.last)
109
+ end
110
+ end
111
+
112
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
113
+ def on_start_processing(payload)
114
+ state = current
115
+ return unless state
116
+
117
+ state.requests << RequestBuilder.new(
118
+ sequence: state.requests.size,
119
+ http_method: payload[:method],
120
+ path: strip_query(payload[:path]),
121
+ route_pattern: route_pattern_for(payload[:request]),
122
+ controller: payload[:controller],
123
+ action: payload[:action],
124
+ format: payload[:format]&.to_s,
125
+ status: nil,
126
+ redirect_location: nil,
127
+ principal_before: state.principal,
128
+ principal_after: state.principal
129
+ )
130
+ end
131
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
132
+
133
+ def on_redirect(payload)
134
+ builder = current&.requests&.last
135
+ return unless builder
136
+
137
+ builder.redirect_location = strip_query(strip_host(payload[:location]))
138
+ end
139
+
140
+ def on_process_action(payload)
141
+ state = current
142
+ builder = state&.requests&.last
143
+ return unless builder
144
+
145
+ builder.status = payload[:status]
146
+ builder.principal_after = state.principal
147
+ end
148
+
149
+ # Query strings can carry tokens (password resets, OAuth callbacks,
150
+ # signed URLs) and are never retained, on request paths or on
151
+ # redirect targets below -- either can leak the same class of secret
152
+ # into the catalog artifact.
153
+ def strip_query(value)
154
+ value.to_s.split("?").first
155
+ end
156
+
157
+ def strip_host(location)
158
+ location.to_s.sub(%r{\Ahttps?://[^/]+}, "")
159
+ end
160
+
161
+ # Uses Rails' own routing engine to recover the declared path pattern
162
+ # (e.g. "/things/:id(.:format)") for the exact request that was
163
+ # already routed, rather than guessing from route-helper call sites
164
+ # in spec source. Falls back to nil -- never a guess -- if routing
165
+ # metadata is unavailable.
166
+ def route_pattern_for(request)
167
+ return nil unless request && defined?(Rails) && Rails.respond_to?(:application) && Rails.application
168
+
169
+ pattern = nil
170
+ Rails.application.routes.router.recognize(request) do |route, _params|
171
+ pattern = route.path.spec.to_s
172
+ break
173
+ end
174
+ pattern
175
+ rescue StandardError
176
+ nil
177
+ end
178
+
179
+ # Warden is optional: an application with no Warden-based
180
+ # authentication simply never populates `principal`, and every
181
+ # request is recorded with `principal: nil` and role :subject.
182
+ # rubocop:disable Metrics/MethodLength
183
+ def subscribe_warden
184
+ return unless defined?(Warden::Manager)
185
+
186
+ Warden::Manager.after_set_user do |user, _auth, opts|
187
+ state = current
188
+ next unless state
189
+
190
+ state.principal = Principal.new(type: user.class.name, id: user.id, scope: opts[:scope])
191
+ end
192
+
193
+ Warden::Manager.before_logout do |_user, _auth, _opts|
194
+ state = current
195
+ next unless state
196
+
197
+ state.principal = nil
198
+ end
199
+ end
200
+ # rubocop:enable Metrics/MethodLength
201
+
202
+ def configure_rspec
203
+ RSpec.configure do |config|
204
+ config.around do |example|
205
+ Observer.wrap_example(example) { example.run }
206
+ end
207
+
208
+ config.after(:suite) do
209
+ Observer.reporter.write(Observer.output_path)
210
+ end
211
+ end
212
+ end
213
+
214
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
215
+ def finish_and_record!(example, karst_explicit:, karst_name:)
216
+ state = finish_example!
217
+ return unless state
218
+ return if state.requests.empty?
219
+
220
+ reporter.record(
221
+ ExampleObservation.new(
222
+ example_id: example.id,
223
+ file_path: example.metadata[:file_path],
224
+ line_number: example.metadata[:line_number],
225
+ spec_type: example.metadata[:type],
226
+ description_parts: description_parts(example),
227
+ full_description: example.full_description,
228
+ karst_explicit: karst_explicit,
229
+ karst_name: karst_name,
230
+ outcome: outcome_for(example),
231
+ requests: freeze_requests(state.requests)
232
+ ).freeze
233
+ )
234
+ end
235
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
236
+
237
+ # RSpec finalizes `execution_result.status` in Example#finish, which
238
+ # runs strictly after the around-hook chain returns -- it is always
239
+ # nil at this point, however this method is reached. `exception` and
240
+ # `pending_message` are set earlier, before `run_after_example`, so
241
+ # this mirrors RSpec's own status derivation instead of reading a
242
+ # field that has not been assigned yet.
243
+ def outcome_for(example)
244
+ return :failed if example.exception
245
+ return :pending if example.execution_result.pending_message
246
+
247
+ :passed
248
+ end
249
+
250
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
251
+ def freeze_requests(builders)
252
+ builders.map do |builder|
253
+ RequestObservation.new(
254
+ sequence: builder.sequence,
255
+ http_method: builder.http_method,
256
+ path: builder.path,
257
+ route_pattern: builder.route_pattern,
258
+ controller: builder.controller,
259
+ action: builder.action,
260
+ format: builder.format,
261
+ status: builder.status,
262
+ redirect_location: builder.redirect_location,
263
+ principal_before: builder.principal_before,
264
+ principal_after: builder.principal_after,
265
+ principal_changed: builder.principal_after != builder.principal_before
266
+ ).freeze
267
+ end.freeze
268
+ end
269
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
270
+
271
+ def description_parts(example)
272
+ outer = example.example_group.parent_groups.reverse.map(&:description)
273
+ (outer + [example.description]).freeze
274
+ end
275
+
276
+ # rubocop:disable Metrics/MethodLength
277
+ def karst_metadata(example)
278
+ return [false, nil] unless example.metadata.key?(:karst)
279
+
280
+ value = example.metadata[:karst]
281
+ name = if value.is_a?(String)
282
+ value
283
+ elsif value.is_a?(Hash) && value.keys == [:name]
284
+ value[:name]
285
+ end
286
+
287
+ unless name.is_a?(String) && !name.strip.empty?
288
+ raise InvalidMetadataError,
289
+ "Invalid karst: metadata for #{example.id}; expected a non-empty String or { name: non_empty_string }"
290
+ end
291
+
292
+ [true, name]
293
+ end
294
+ # rubocop:enable Metrics/MethodLength
295
+ end
296
+ # rubocop:enable Metrics/ClassLength
297
+ end
298
+ # rubocop:enable Metrics/ModuleLength
299
+ end
300
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+
5
+ module Karst
6
+ module Spec
7
+ # Minimal principal evidence observed via Warden's public hooks during an
8
+ # RSpec example: class name and primary key only, never a serialized user
9
+ # object, mirroring Karst's runtime-evidence principal model.
10
+ Principal = Value.define(:type, :id, :scope)
11
+ end
12
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Karst
7
+ module Spec
8
+ # Collects ExampleObservation instances as the suite runs and serializes
9
+ # them to one deterministic JSON artifact on disk. Never touches the host
10
+ # application's database: this is the only persistence Reporter performs.
11
+ class Reporter
12
+ def initialize
13
+ @examples = []
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def record(example_observation)
18
+ @mutex.synchronize { @examples << example_observation }
19
+ self
20
+ end
21
+
22
+ def to_a
23
+ @mutex.synchronize { @examples.dup }
24
+ end
25
+
26
+ # Ordered by file path then line number, independent of RSpec run order
27
+ # (`--order random` reshuffles examples but must not reshuffle the
28
+ # artifact), so two runs of an unchanged suite produce identical JSON.
29
+ def write(path)
30
+ browser_facing = @mutex.synchronize { @examples.select(&:browser_facing?) }
31
+ ordered = browser_facing.sort_by { |example| [example.file_path.to_s, example.line_number.to_i] }
32
+
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ File.write(path, "#{JSON.pretty_generate(ordered.map { |example| serialize(example) })}\n")
35
+ path
36
+ end
37
+
38
+ private
39
+
40
+ # rubocop:disable Metrics/MethodLength
41
+ def serialize(example)
42
+ {
43
+ "example_id" => example.example_id,
44
+ "file_path" => example.file_path,
45
+ "line_number" => example.line_number,
46
+ "spec_type" => example.spec_type&.to_s,
47
+ "description_parts" => example.description_parts,
48
+ "full_description" => example.full_description,
49
+ "karst_explicit" => example.karst_explicit,
50
+ "karst_name" => example.karst_name,
51
+ "outcome" => example.outcome.to_s,
52
+ "requests" => example.requests.map { |request| serialize_request(request) }
53
+ }
54
+ end
55
+ # rubocop:enable Metrics/MethodLength
56
+
57
+ # rubocop:disable Metrics/MethodLength
58
+ def serialize_request(request)
59
+ {
60
+ "sequence" => request.sequence,
61
+ "method" => request.http_method,
62
+ "path" => request.path,
63
+ "route_pattern" => request.route_pattern,
64
+ "controller" => request.controller,
65
+ "action" => request.action,
66
+ "format" => request.format,
67
+ "status" => request.status,
68
+ "redirect_location" => request.redirect_location,
69
+ "principal_before" => serialize_principal(request.principal_before),
70
+ "principal_after" => serialize_principal(request.principal_after),
71
+ "principal_changed" => request.principal_changed
72
+ }
73
+ end
74
+ # rubocop:enable Metrics/MethodLength
75
+
76
+ def serialize_principal(principal)
77
+ return nil unless principal
78
+
79
+ { "type" => principal.type, "id" => principal.id, "scope" => principal.scope&.to_s }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+
5
+ module Karst
6
+ module Spec
7
+ # One HTTP request observed during a single RSpec example.
8
+ #
9
+ # `principal_before`/`principal_after` are the active Warden principal
10
+ # immediately before and immediately after this request was processed;
11
+ # `principal_changed` is true when they differ -- a login, a logout, or a
12
+ # switch from one principal to another. This is raw observed evidence,
13
+ # not an interpretation of what the request was FOR. Karst does not
14
+ # classify a request as "setup" or "the subject under test": a signup
15
+ # route, an invitation-acceptance route, or a checkout-completion route
16
+ # that happens to establish a session is a legitimate subject request,
17
+ # not authentication plumbing, and a single request carries no reliable
18
+ # signal for telling those apart. That classification, if Karst ever
19
+ # offers one, belongs to catalog-building logic downstream of this
20
+ # observer, informed by more context than one request can supply.
21
+ #
22
+ # Named `http_method`, not `method`, so it never shadows Object#method.
23
+ RequestObservation = Value.define(
24
+ :sequence,
25
+ :http_method,
26
+ :path,
27
+ :route_pattern,
28
+ :controller,
29
+ :action,
30
+ :format,
31
+ :status,
32
+ :redirect_location,
33
+ :principal_before,
34
+ :principal_after,
35
+ :principal_changed
36
+ )
37
+ end
38
+ end