spree_core 6.0.2 → 6.0.3

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/addresses/geocode_address_job.rb +6 -3
  3. data/app/jobs/spree/variants/remove_line_item_job.rb +5 -0
  4. data/app/models/concerns/spree/preference_schema.rb +5 -0
  5. data/app/models/concerns/spree/typed_associations.rb +53 -20
  6. data/app/models/spree/address.rb +2 -2
  7. data/app/models/spree/collection.rb +7 -2
  8. data/app/models/spree/order.rb +1 -1
  9. data/app/models/spree/product.rb +15 -5
  10. data/app/models/spree/promotion.rb +1 -2
  11. data/app/services/spree/newsletter/subscribe.rb +1 -1
  12. data/app/services/spree/orders/create_from_cart.rb +6 -2
  13. data/app/tools/spree/agent_tools/create_export.rb +3 -1
  14. data/app/tools/spree/agent_tools/create_import.rb +1 -0
  15. data/app/tools/spree/agent_tools/create_media.rb +2 -1
  16. data/app/tools/spree/agent_tools/create_resource.rb +12 -4
  17. data/app/tools/spree/agent_tools/delete_resource.rb +12 -4
  18. data/app/tools/spree/agent_tools/describe_resource.rb +69 -6
  19. data/app/tools/spree/agent_tools/get_resource.rb +7 -4
  20. data/app/tools/spree/agent_tools/resource_write.rb +15 -2
  21. data/app/tools/spree/agent_tools/search_resources.rb +10 -11
  22. data/app/tools/spree/agent_tools/update_resource.rb +6 -6
  23. data/app/tools/spree/agent_tools/upload_file.rb +1 -0
  24. data/app/workflows/spree/carts/complete.rb +4 -1
  25. data/app/workflows/spree/carts/merge.rb +25 -4
  26. data/app/workflows/spree/orders/complete.rb +39 -14
  27. data/app/workflows/spree/products/activate.rb +7 -2
  28. data/app/workflows/spree/products/archive.rb +7 -2
  29. data/app/workflows/spree/products/draft.rb +7 -2
  30. data/app/workflows/spree/products/status_transition.rb +24 -0
  31. data/config/locales/en.yml +3 -0
  32. data/lib/generators/spree/api_resource/api_resource_generator.rb +35 -3
  33. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +9 -4
  34. data/lib/generators/spree/api_resource/templates/admin_integration_spec.rb.tt +74 -0
  35. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +11 -6
  36. data/lib/spree/agent_tool.rb +105 -1
  37. data/lib/spree/agent_tools/default_catalog.rb +16 -21
  38. data/lib/spree/agent_tools/legacy_tools.rb +212 -0
  39. data/lib/spree/agent_tools/operation_catalog.rb +206 -0
  40. data/lib/spree/agent_tools/operation_tool.rb +189 -0
  41. data/lib/spree/agent_tools/registry.rb +65 -31
  42. data/lib/spree/agent_tools/resource_map.rb +128 -5
  43. data/lib/spree/agent_tools.rb +3 -3
  44. data/lib/spree/core/dependencies_helper.rb +96 -40
  45. data/lib/spree/core/engine.rb +2 -1
  46. data/lib/spree/core/version.rb +1 -1
  47. data/lib/spree/core.rb +19 -2
  48. data/lib/spree/testing_support/extension_rake.rb +12 -0
  49. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  50. data/lib/tasks/dependencies.rake +2 -2
  51. metadata +23 -7
  52. data/lib/spree/agent_tools/workflow_endpoints.rb +0 -88
  53. data/lib/spree/agent_tools/workflow_schema.rb +0 -279
  54. data/lib/spree/agent_tools/workflow_tool.rb +0 -654
@@ -10,6 +10,7 @@ module Spree
10
10
  :dependencies,
11
11
  :payment_methods,
12
12
  :adjusters,
13
+ :cart_copiers,
13
14
  :media_viewable_types,
14
15
  :default_tax_provider,
15
16
  :tax_providers,
@@ -79,7 +80,7 @@ module Spree
79
80
  %i[subscribers actor_classes commission_rules delivery_method_rules seller_requirements
80
81
  delivery_rate_providers tax_providers pricing_providers inventory_providers
81
82
  payout_providers digital_asset_providers delivery_profile_types pickup_point_providers
82
- maintenance_tasks].each do |registry|
83
+ maintenance_tasks cart_copiers].each do |registry|
83
84
  app.config.spree[registry] = []
84
85
  end
85
86
  app.config.spree.line_item_comparison_hooks = Set.new
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.2'.freeze
2
+ VERSION = '6.0.3'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -323,6 +323,22 @@ module Spree
323
323
  # @return [Array<Class>]
324
324
  singleton_class.delegate :tax_providers, :tax_providers=, to: :spree_config
325
325
 
326
+ # Extensions that copy their own cart data onto the order a cart becomes.
327
+ # Each entry is a class with a class-level +call(cart:, order:,
328
+ # line_item_map:)+ (a class, so it is re-resolved when code reloads in
329
+ # development; see {.refresh_provider_registries!}), where the map
330
+ # pairs each cart line item with its copy on the order. Both copies run them
331
+ # — checkout ({Spree::Carts::Complete}) and the draft-order copy
332
+ # ({Spree::Orders::CreateFromCart}) — inside their transaction, once the
333
+ # order's line items exist, so an entry that raises rolls the order back.
334
+ #
335
+ # Append, never assign, so an extension does not drop what another added:
336
+ #
337
+ # Spree.cart_copiers << MyApp::CopyEngravings
338
+ #
339
+ # @return [Array<Class>]
340
+ singleton_class.delegate :cart_copiers, :cart_copiers=, to: :spree_config
341
+
326
342
  # Pricing engines a store can be pointed at. Connector gems append their own,
327
343
  # so a merchant picks from what is actually installed.
328
344
  #
@@ -400,7 +416,8 @@ module Spree
400
416
  def self.refresh_provider_registries!
401
417
  [Rails.application.config.spree.delivery_rate_providers,
402
418
  Rails.application.config.spree.fulfillment_providers,
403
- Rails.application.config.spree.pickup_point_providers].compact.each do |registry|
419
+ Rails.application.config.spree.pickup_point_providers,
420
+ Rails.application.config.spree.cart_copiers].compact.each do |registry|
404
421
  registry.map! { |entry| entry.is_a?(Module) && entry.name ? entry.name.constantize : entry }
405
422
  end
406
423
  end
@@ -603,7 +620,7 @@ module Spree
603
620
  def core_dependency?(name)
604
621
  return false unless defined?(Spree::Dependencies)
605
622
 
606
- Spree::Dependencies.class::INJECTION_POINTS.include?(name) ||
623
+ Spree::Dependencies.injection_point?(name) ||
607
624
  Spree::Dependencies.class::LEGACY_WORKFLOW_KEYS.key?(name) ||
608
625
  Spree::Dependencies.class::LEGACY_SERVICE_KEYS.key?(name) ||
609
626
  Spree::Dependencies.class::RENAMED_SERVICE_KEYS.key?(name)
@@ -1,5 +1,17 @@
1
1
  require 'spree/testing_support/common_rake'
2
2
 
3
+ # `rake spree:openapi:generate` writes the extension's openapi/*.yaml from its
4
+ # rswag specs, and `rake spree:sdk:serializer_types` the response types of a
5
+ # package that extends the SDKs (both run by `spree sdk generate`).
6
+ begin
7
+ require 'spree/api/testing_support/openapi_tasks'
8
+ require 'spree/api/sdk_types_task'
9
+ Spree::Api::SdkTypesTask.new
10
+ rescue LoadError
11
+ # An extension without spree_api has no API to document. These files load
12
+ # nothing else, so this is the only thing that can be missing.
13
+ end
14
+
3
15
  desc 'Generates a dummy app for testing an extension'
4
16
  namespace :extension do
5
17
  task :test_app, [:options] do |_t, args|
@@ -662,6 +662,40 @@ steps:
662
662
  hold a secret are skipped, and the legacy tables stay in place as a
663
663
  rollback path until they are dropped in 6.1.
664
664
 
665
+ - id: migrate_paypal_payments
666
+ name: "Give PayPal payments taken before 6.0 the PayPal order id"
667
+ task_class: "SpreePaypalCheckout::MaintenanceTasks::MigratePayments"
668
+ optional: true
669
+ notes: |
670
+ Only runs when spree_paypal_checkout is installed; skipped otherwise.
671
+
672
+ The 0.x gem stored a PayPal payment's capture id as its response code.
673
+ In 6.0 the response code is always the PayPal order id, which is how a
674
+ payment session finds its payment, and the capture id lives in the
675
+ payment's metadata. This step rewrites each older payment to that shape,
676
+ finding its order through the payment session that took it or, for the
677
+ older checkout flow, through spree_paypal_checkout_orders.
678
+
679
+ Run it before dropping spree_paypal_checkout_orders: that table is the
680
+ only link from those older payments to their PayPal order. A payment it
681
+ cannot match is logged by id, counted, and left as it was; refund it in
682
+ PayPal. Supports a dry run, and is idempotent.
683
+
684
+ - id: register_paypal_webhooks
685
+ name: "Register each PayPal payment method's webhook"
686
+ task: "spree:upgrade:register_paypal_webhooks"
687
+ optional: true
688
+ notes: |
689
+ Only runs when spree_paypal_checkout is installed; skipped otherwise.
690
+
691
+ PayPal webhooks register themselves in 6.0 and are always verified with
692
+ the webhook's id. This finds or creates the webhook for each PayPal
693
+ method's URL, replaces the events of one created by hand under the 0.x
694
+ guide, and stores its id. Until it runs, methods created before the
695
+ upgrade reject every webhook delivery.
696
+
697
+ Idempotent.
698
+
665
699
  - id: backfill_import_export_tenancy
666
700
  name: "Place every import in a store, and a seller where one ran it"
667
701
  task: "spree:upgrade:backfill_import_export_tenancy"
@@ -42,7 +42,7 @@ namespace :spree do
42
42
  puts "\n[#{name}]"
43
43
 
44
44
  # Calculate max width for alignment
45
- max_name_width = deps.class::INJECTION_POINTS.map(&:length).max
45
+ max_name_width = deps.injection_points.map(&:length).max
46
46
 
47
47
  deps.current_values.each do |dep|
48
48
  status = dep[:overridden] ? ' [OVERRIDDEN]' : ''
@@ -63,7 +63,7 @@ namespace :spree do
63
63
 
64
64
  def validate_dependencies(name, deps)
65
65
  errors = []
66
- deps.class::INJECTION_POINTS.each do |point|
66
+ deps.injection_points.each do |point|
67
67
  deps.send("#{point}_class")
68
68
  print "\e[32m.\e[0m"
69
69
  rescue NameError => e
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.2
4
+ version: 6.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-11-02 00:00:00.000000000 Z
13
+ date: 2026-11-05 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -422,6 +422,20 @@ dependencies:
422
422
  - - ">="
423
423
  - !ruby/object:Gem::Version
424
424
  version: '0'
425
+ - !ruby/object:Gem::Dependency
426
+ name: ostruct
427
+ requirement: !ruby/object:Gem::Requirement
428
+ requirements:
429
+ - - ">="
430
+ - !ruby/object:Gem::Version
431
+ version: '0'
432
+ type: :runtime
433
+ prerelease: false
434
+ version_requirements: !ruby/object:Gem::Requirement
435
+ requirements:
436
+ - - ">="
437
+ - !ruby/object:Gem::Version
438
+ version: '0'
425
439
  - !ruby/object:Gem::Dependency
426
440
  name: tracking_number
427
441
  requirement: !ruby/object:Gem::Requirement
@@ -1550,6 +1564,7 @@ files:
1550
1564
  - app/workflows/spree/products/nested_attributes.rb
1551
1565
  - app/workflows/spree/products/propose.rb
1552
1566
  - app/workflows/spree/products/reject.rb
1567
+ - app/workflows/spree/products/status_transition.rb
1553
1568
  - app/workflows/spree/products/update.rb
1554
1569
  - app/workflows/spree/purchase_orders/cancel.rb
1555
1570
  - app/workflows/spree/purchase_orders/close.rb
@@ -1987,6 +2002,7 @@ files:
1987
2002
  - lib/generators/spree/api_resource/api_resource_generator.rb
1988
2003
  - lib/generators/spree/api_resource/templates/admin_controller.rb.tt
1989
2004
  - lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt
2005
+ - lib/generators/spree/api_resource/templates/admin_integration_spec.rb.tt
1990
2006
  - lib/generators/spree/api_resource/templates/admin_serializer.rb.tt
1991
2007
  - lib/generators/spree/api_resource/templates/factory.rb.tt
1992
2008
  - lib/generators/spree/api_resource/templates/permissions.en.yml.tt
@@ -2019,13 +2035,13 @@ files:
2019
2035
  - lib/spree/agent_tools/api_dispatch.rb
2020
2036
  - lib/spree/agent_tools/context.rb
2021
2037
  - lib/spree/agent_tools/default_catalog.rb
2038
+ - lib/spree/agent_tools/legacy_tools.rb
2022
2039
  - lib/spree/agent_tools/membership_map.rb
2040
+ - lib/spree/agent_tools/operation_catalog.rb
2041
+ - lib/spree/agent_tools/operation_tool.rb
2023
2042
  - lib/spree/agent_tools/record_summary.rb
2024
2043
  - lib/spree/agent_tools/registry.rb
2025
2044
  - lib/spree/agent_tools/resource_map.rb
2026
- - lib/spree/agent_tools/workflow_endpoints.rb
2027
- - lib/spree/agent_tools/workflow_schema.rb
2028
- - lib/spree/agent_tools/workflow_tool.rb
2029
2045
  - lib/spree/category_permalink_deduplicator.rb
2030
2046
  - lib/spree/checkout/default_requirements.rb
2031
2047
  - lib/spree/checkout/registry.rb
@@ -2334,9 +2350,9 @@ licenses:
2334
2350
  - BSD-3-Clause
2335
2351
  metadata:
2336
2352
  bug_tracker_uri: https://github.com/spree/spree/issues
2337
- changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.2
2353
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.3
2338
2354
  documentation_uri: https://spreecommerce.org/docs/
2339
- source_code_uri: https://github.com/spree/spree/tree/v6.0.2
2355
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.3
2340
2356
  post_install_message:
2341
2357
  rdoc_options: []
2342
2358
  require_paths:
@@ -1,88 +0,0 @@
1
- module Spree
2
- module AgentTools
3
- # Which Admin API operation runs each workflow an agent may call.
4
- #
5
- # A contract like {ResourceMap} and {MembershipMap}: core declares the
6
- # shape, and `spree_api` fills it by reading its own routes and
7
- # controllers, so a workflow that gains an endpoint is dispatched without
8
- # anyone editing a list.
9
- #
10
- # A workflow with no entry runs in this process instead. That is not a
11
- # fallback to be relied on — it means the capability has no endpoint yet,
12
- # and the tool is the only way to reach it.
13
- module WorkflowEndpoints
14
- # @!attribute verb
15
- # @return [String] 'POST', 'PATCH'
16
- # @!attribute path
17
- # @return [String] with `:name` segments still in it
18
- # @!attribute body_keys
19
- # @return [Array<String>] what the operation's request body accepts,
20
- # read from the generated OpenAPI document — which is where the
21
- # renames live (a cancellation's `reason:` keyword is
22
- # `cancel_reason_id` on the wire)
23
- Entry = Struct.new(:verb, :path, :body_keys, keyword_init: true) do
24
- # The dynamic segments, in the order the path names them.
25
- #
26
- # @return [Array<String>]
27
- def path_params
28
- @path_params ||= path.scan(/:([a-z_]+)/).flatten
29
- end
30
-
31
- # @param values [Hash] path parameter name => id
32
- # @return [String]
33
- def path_for(values)
34
- path.gsub(/:([a-z_]+)/) { values.fetch(Regexp.last_match(1)).to_s }
35
- end
36
- end
37
-
38
- class << self
39
- # @param key [Symbol] a `Spree::Dependencies` workflow key
40
- # @return [Entry, nil]
41
- def find(key)
42
- derive!
43
- entries[key.to_sym]
44
- end
45
-
46
- # @return [Hash{Symbol => Entry}]
47
- def all
48
- derive!
49
- entries
50
- end
51
-
52
- def register(key:, verb:, path:, body_keys: [])
53
- entries[key.to_sym] = Entry.new(verb: verb.to_s.upcase, path: path,
54
- body_keys: body_keys.map(&:to_s))
55
- end
56
-
57
- def reset!
58
- @entries = {}
59
- @derived = false
60
- end
61
-
62
- # What has been registered so far, without deriving — for the
63
- # deriver itself, which must not ask for what it is producing.
64
- #
65
- # @return [Hash{Symbol => Entry}]
66
- def registered
67
- entries
68
- end
69
-
70
- # Filled on first read rather than at boot: deriving it loads every
71
- # admin controller, which a developer who never runs an agent should
72
- # not pay for on each code reload.
73
- def derive!
74
- return if @derived
75
-
76
- @derived = true
77
- Spree::Api::AgentWorkflowEndpoints.install if defined?(Spree::Api::AgentWorkflowEndpoints)
78
- end
79
-
80
- private
81
-
82
- def entries
83
- @entries ||= {}
84
- end
85
- end
86
- end
87
- end
88
- end
@@ -1,279 +0,0 @@
1
- module Spree
2
- module AgentTools
3
- # Reads a workflow's argument contract and turns it into a JSON parameter
4
- # schema an agent can fill in.
5
- #
6
- # Nothing here is hand-maintained. `Method#parameters` on `perform` names
7
- # every keyword and says which are required; the YARD `@param` block above
8
- # it gives each one a type and a sentence of prose. Both already exist on
9
- # every workflow, because the house style requires them — so a workflow
10
- # gains a parameter and the tool's schema gains it in the same commit.
11
- class WorkflowSchema
12
- # A workflow that cannot be exposed as written. Raised at derivation time
13
- # so the contract spec fails in CI rather than an agent meeting a broken
14
- # tool in production.
15
- class UndocumentedParameterError < StandardError
16
- def initialize(workflow_class, names)
17
- super("#{workflow_class} exposes #{names.join(', ')} without a YARD @param — " \
18
- 'every parameter of an exposed workflow needs a documented type')
19
- end
20
- end
21
-
22
- # Tenancy, which comes from the credential and never from the caller.
23
- # A workflow that takes `store:` is given the context's store; offering
24
- # it would invite a model to name another merchant's store, and the
25
- # answer to "which store" is the API key or the signed-in admin.
26
- CONTEXT_PARAMETERS = %i[store].freeze
27
-
28
- # Actor keywords a workflow takes whose association has not been
29
- # converted to `acted_by` yet — the 6.1 half of
30
- # docs/plans/6.0-action-actors.md. They are principals by the same
31
- # reasoning as the converted ones, so they are injected too; each drops
32
- # off this list as its association converts, and the registry below then
33
- # carries it.
34
- UNCONVERTED_PRINCIPAL_PARAMETERS = %i[
35
- reviewer reviewed_by rejected_by suspended_by verified_by reopened_by
36
- ].freeze
37
-
38
- # YARD type to JSON type. A model type is handled separately — it becomes
39
- # a prefixed-id string resolved through the resource map.
40
- PRIMITIVES = {
41
- 'String' => 'string',
42
- 'Symbol' => 'string',
43
- 'Time' => 'string',
44
- 'DateTime' => 'string',
45
- 'Date' => 'string',
46
- 'Boolean' => 'boolean',
47
- 'TrueClass' => 'boolean',
48
- 'FalseClass' => 'boolean',
49
- 'Integer' => 'integer',
50
- 'Numeric' => 'number',
51
- 'Float' => 'number',
52
- 'BigDecimal' => 'number',
53
- 'Hash' => 'object',
54
- 'Array' => 'array'
55
- }.freeze
56
-
57
- # @return [Class] the workflow this schema describes
58
- attr_reader :workflow_class
59
-
60
- # @param workflow_class [Class]
61
- # @param except [Array<Symbol>] parameters to leave out of the schema
62
- def initialize(workflow_class, except: [])
63
- @workflow_class = workflow_class
64
- @except = except.map(&:to_sym)
65
- end
66
-
67
- # Every parameter the caller may supply, in `perform` order.
68
- #
69
- # @return [Array<Hash>] `{name:, required:, json_type:, model_name:, description:}`
70
- def parameters
71
- @parameters ||= begin
72
- documented = docs
73
- undocumented = keywords.keys - documented.keys - injected_parameters - @except
74
- raise UndocumentedParameterError.new(workflow_class, undocumented) if undocumented.any?
75
-
76
- keywords.filter_map do |name, required|
77
- next if @except.include?(name) || injected_parameters.include?(name)
78
-
79
- build_parameter(name, required, documented.fetch(name))
80
- end
81
- end
82
- end
83
-
84
- # The principal keywords this workflow accepts, so the adapter knows
85
- # which to inject.
86
- #
87
- # @return [Array<Symbol>]
88
- def principal_parameters
89
- keywords.keys & self.class.principal_parameter_names
90
- end
91
-
92
- # The tenancy keywords this workflow accepts.
93
- #
94
- # @return [Array<Symbol>]
95
- def context_parameters
96
- keywords.keys & CONTEXT_PARAMETERS
97
- end
98
-
99
- # Everything filled from the context rather than by the caller.
100
- #
101
- # @return [Array<Symbol>]
102
- def injected_parameters
103
- self.class.principal_parameter_names + CONTEXT_PARAMETERS
104
- end
105
-
106
- private
107
-
108
- # `perform`'s keywords, mapped to whether they are required.
109
- #
110
- # @return [Hash{Symbol => Boolean}]
111
- def keywords
112
- @keywords ||= workflow_class.instance_method(:perform).parameters.filter_map do |kind, name|
113
- [name, kind == :keyreq] if %i[key keyreq].include?(kind)
114
- end.to_h
115
- end
116
-
117
- def build_parameter(name, required, doc)
118
- types = doc[:types]
119
- model_name = types.filter_map { |type| model_name_for(type) }.first
120
-
121
- json_type = model_name ? 'string' : json_type_for(types)
122
-
123
- {
124
- name: name,
125
- required: required,
126
- model_name: model_name,
127
- json_type: json_type,
128
- item_type: json_type == 'array' ? item_type_for(types) : nil,
129
- description: description_for(doc[:description], model_name)
130
- }
131
- end
132
-
133
- def description_for(text, model_name)
134
- return text if model_name.blank?
135
-
136
- prefix = "Prefixed id of the #{model_name.demodulize.underscore.humanize.downcase}"
137
- text.present? ? "#{prefix} — #{text}" : prefix
138
- end
139
-
140
- # A Spree model, addressed by prefixed id, as its canonical class name.
141
- # A YARD block may name a deprecated alias (`Spree::ShippingMethod` for
142
- # `Spree::DeliveryMethod`), and the alias is a constant pointing at the
143
- # same class — so resolve the constant and ask it for its own name
144
- # rather than trusting the spelling.
145
- #
146
- # `Spree.admin_user_class` and the bare `Object` of a principal
147
- # parameter never reach here: those parameters are stripped first.
148
- #
149
- # @return [String, nil]
150
- def model_name_for(type)
151
- return unless type.start_with?('Spree::')
152
-
153
- constant = type.safe_constantize
154
- return unless constant.is_a?(Class) && constant < ActiveRecord::Base
155
-
156
- constant.name
157
- end
158
-
159
- # What an array holds, read from the documented type: `Array<Hash>` holds
160
- # objects, and anything else is treated as strings — the common case for
161
- # a list of names or codes.
162
- def item_type_for(types)
163
- inner = types.filter_map { |type| type[/\AArray<(.+)>\z/, 1] }.first
164
-
165
- PRIMITIVES[inner] || 'string'
166
- end
167
-
168
- def json_type_for(types)
169
- concrete = types.reject { |type| type == 'nil' }
170
- # `Array<Hash>` and friends — the outer container is what JSON sees.
171
- concrete = concrete.map { |type| type.split('<').first }
172
- concrete.filter_map { |type| PRIMITIVES[type] }.first || 'string'
173
- end
174
-
175
- # The YARD block above `perform`, parsed from the workflow's own source.
176
- #
177
- # Read from source rather than through the YARD gem: the gem is a
178
- # development dependency, and this has to work in production, where the
179
- # tool list is built on every request.
180
- #
181
- # @return [Hash{Symbol => Hash}]
182
- def docs
183
- @docs ||= self.class.parse_docs(workflow_class)
184
- end
185
-
186
- class << self
187
- # Every keyword that names who acted, so none is ever offered to a
188
- # caller as a free parameter.
189
- #
190
- # Read from {Spree::ActedBy} rather than listed here: the macro already
191
- # records each actor association per model, exactly so a caller
192
- # sweeping them walks all of them without being handed a list. A model
193
- # that gains an actor gains it here in the same commit, and a name
194
- # nobody declares cannot linger.
195
- #
196
- # @return [Array<Symbol>]
197
- def principal_parameter_names
198
- declared = if defined?(Spree::ActedBy)
199
- Spree::ActedBy.models.flat_map(&:acted_by_associations).map(&:to_sym)
200
- else
201
- []
202
- end
203
-
204
- (declared | UNCONVERTED_PRINCIPAL_PARAMETERS).uniq
205
- end
206
-
207
- # Parsed once per workflow class and cached, because a tool list is
208
- # built for every MCP request and the source never changes at runtime.
209
- #
210
- # @param workflow_class [Class]
211
- # @return [Hash{Symbol => Hash}] `{types: [String], description: String}`
212
- def parse_docs(workflow_class)
213
- file, = workflow_class.instance_method(:perform).source_location
214
- key = [workflow_class.name, file, (File.mtime(file) if file && File.exist?(file))]
215
-
216
- cache[key] ||= parse_source(workflow_class)
217
- end
218
-
219
- # @return [void]
220
- def clear_cache!
221
- @cache = {}
222
- end
223
-
224
- private
225
-
226
- def cache
227
- @cache ||= {}
228
- end
229
-
230
- def parse_source(workflow_class)
231
- source = comment_above_perform(workflow_class)
232
- return {} if source.blank?
233
-
234
- parse_param_tags(source)
235
- end
236
-
237
- # The comment block immediately above `def perform`, joined so a
238
- # `@param` whose prose wraps onto the next line keeps it.
239
- def comment_above_perform(workflow_class)
240
- location = workflow_class.instance_method(:perform).source_location
241
- return if location.nil?
242
-
243
- file, line = location
244
- return unless File.exist?(file)
245
-
246
- lines = File.readlines(file)
247
- comment = []
248
- index = line - 2
249
- while index >= 0 && lines[index].strip.start_with?('#')
250
- comment.unshift(lines[index].strip.delete_prefix('#').strip)
251
- index -= 1
252
- end
253
- comment
254
- end
255
-
256
- def parse_param_tags(comment_lines)
257
- tags = {}
258
- current = nil
259
-
260
- comment_lines.each do |line|
261
- if (match = line.match(/\A@param\s+(\w+)\s+\[([^\]]*)\]\s*(.*)\z/))
262
- current = match[1].to_sym
263
- tags[current] = {
264
- types: match[2].split(',').map(&:strip).reject(&:empty?),
265
- description: match[3].strip
266
- }
267
- elsif line.start_with?('@')
268
- current = nil
269
- elsif current && line.present?
270
- tags[current][:description] = [tags[current][:description], line].reject(&:blank?).join(' ')
271
- end
272
- end
273
-
274
- tags
275
- end
276
- end
277
- end
278
- end
279
- end