seams 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.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -12
  3. data/README.md +235 -82
  4. data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
  5. data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
  6. data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
  7. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
  8. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
  9. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
  10. data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
  11. data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
  12. data/lib/generators/seams/admin/admin_generator.rb +51 -4
  13. data/lib/generators/seams/admin/templates/README.md.tt +33 -3
  14. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
  15. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
  16. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
  17. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
  18. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
  19. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
  20. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
  21. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
  22. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
  23. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
  24. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
  25. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
  26. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
  27. data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
  28. data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
  29. data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
  30. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
  31. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
  32. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
  33. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
  34. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
  35. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
  36. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
  37. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
  38. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
  39. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
  40. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
  41. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
  42. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
  43. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
  44. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
  45. data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
  46. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
  47. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
  48. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
  49. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
  50. data/lib/generators/seams/auth/auth_generator.rb +1 -2
  51. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
  52. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
  53. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
  54. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
  55. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
  56. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
  57. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
  58. data/lib/generators/seams/billing/billing_generator.rb +4 -3
  59. data/lib/generators/seams/billing/templates/README.md.tt +26 -0
  60. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
  61. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
  62. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
  63. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
  64. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
  65. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
  66. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
  67. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
  68. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
  69. data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
  70. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
  71. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
  72. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
  73. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
  74. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
  75. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
  76. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
  77. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
  78. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
  79. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
  80. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
  81. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
  82. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
  83. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
  84. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
  85. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
  86. data/lib/generators/seams/design/design_generator.rb +647 -0
  87. data/lib/generators/seams/design/templates/README.md.tt +129 -0
  88. data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
  89. data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
  90. data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
  91. data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
  92. data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
  93. data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
  94. data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
  95. data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
  96. data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
  97. data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
  98. data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
  99. data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
  100. data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
  101. data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
  102. data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
  103. data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
  104. data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
  105. data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
  106. data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
  107. data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
  108. data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
  109. data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
  110. data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
  111. data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
  112. data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
  113. data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
  114. data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
  115. data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
  116. data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
  117. data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
  118. data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
  119. data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
  120. data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
  121. data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
  122. data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
  123. data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
  124. data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
  125. data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
  126. data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
  127. data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
  128. data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
  129. data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
  130. data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
  131. data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
  132. data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
  133. data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
  134. data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
  135. data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
  136. data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
  137. data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
  138. data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
  139. data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
  140. data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
  141. data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
  142. data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
  143. data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
  144. data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
  145. data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
  146. data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
  147. data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
  148. data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
  149. data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
  150. data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
  151. data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
  152. data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
  153. data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
  154. data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
  155. data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
  156. data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
  157. data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
  158. data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
  159. data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
  160. data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
  161. data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
  162. data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
  163. data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
  164. data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
  165. data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
  166. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
  167. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
  168. data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
  169. data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
  170. data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
  171. data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
  172. data/lib/generators/seams/engine/engine_generator.rb +6 -0
  173. data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
  174. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  175. data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
  176. data/lib/generators/seams/install/install_generator.rb +72 -6
  177. data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
  178. data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
  179. data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
  180. data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
  181. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
  182. data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
  183. data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
  184. data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
  185. data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
  186. data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
  187. data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
  188. data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
  189. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
  190. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
  191. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
  192. data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
  193. data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
  194. data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
  195. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
  196. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
  197. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
  198. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
  199. data/lib/seams/cli/list.rb +15 -0
  200. data/lib/seams/cli/quality.rb +46 -5
  201. data/lib/seams/cli/resolve.rb +9 -5
  202. data/lib/seams/cli/test_changed.rb +5 -1
  203. data/lib/seams/cli.rb +20 -0
  204. data/lib/seams/configuration.rb +24 -1
  205. data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
  206. data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
  207. data/lib/seams/event_registry.rb +19 -0
  208. data/lib/seams/events/adapter.rb +9 -0
  209. data/lib/seams/events/adapters/active_support.rb +4 -0
  210. data/lib/seams/events/publisher.rb +29 -0
  211. data/lib/seams/events.rb +1 -0
  212. data/lib/seams/generators/dummy_app_writer.rb +21 -0
  213. data/lib/seams/generators/follow_up_generator.rb +1 -2
  214. data/lib/seams/generators/host_injector.rb +9 -2
  215. data/lib/seams/generators/splicer.rb +2 -2
  216. data/lib/seams/observability/adapter.rb +22 -0
  217. data/lib/seams/observability.rb +6 -0
  218. data/lib/seams/permission_registry.rb +66 -0
  219. data/lib/seams/permissions.rb +120 -0
  220. data/lib/seams/version.rb +1 -1
  221. data/lib/seams.rb +7 -0
  222. metadata +107 -3
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "seams"
5
+ require "seams/permissions"
6
+ require "seams/generators/host_injector"
7
+ require "seams/generators/eject_aware"
8
+
9
+ module Seams
10
+ module Generators
11
+ # Writes ONE host-editable initializer that spells out the default
12
+ # role -> ability grant map and assigns it through
13
+ # `Seams.configure { |c| c.permission_grants = {...} }`.
14
+ #
15
+ # This is the deferred-friendly seam of the permissions layer (Wave
16
+ # 11B / issue #37): there is no database table and no YAML DSL — a
17
+ # host changes who-can-do-what by editing this one Ruby file. The
18
+ # generated map is a readable copy of Seams::Permissions::DEFAULT_GRANTS
19
+ # rendered at generate time, so the host starts from the same defaults
20
+ # the gem ships and edits from there.
21
+ #
22
+ # The catalog of ability CODES is engine-owned: each engine registers
23
+ # the `resource.action.engine` codes it understands from its engine.rb
24
+ # (mirroring the event bus). This file only decides which ROLES hold
25
+ # which already-registered codes. Referencing a code no engine has
26
+ # registered makes `Seams::Permissions.can?` raise — deny-by-default,
27
+ # loudly.
28
+ #
29
+ # Run with: bin/seams permissions (or bin/rails generate seams:permissions)
30
+ class PermissionsGenerator < Rails::Generators::Base
31
+ include Seams::Generators::HostInjector
32
+ include Seams::Generators::EjectAware
33
+
34
+ source_root File.expand_path("templates", __dir__)
35
+
36
+ INITIALIZER_RELATIVE = "config/initializers/seams_permissions.rb"
37
+
38
+ # The single deliverable: the host-editable grant map. Eject-aware
39
+ # so a host that has stamped the eject header (to fully own the file
40
+ # and never be prompted again) keeps their version on a re-run.
41
+ def create_initializer
42
+ template_unless_ejected "config/initializers/seams_permissions.rb.tt",
43
+ host_path(INITIALIZER_RELATIVE)
44
+ end
45
+
46
+ def report_summary
47
+ say report_summary_text, :green
48
+ end
49
+
50
+ def report_summary_text
51
+ <<~TXT
52
+
53
+ Permissions grant map generated at #{INITIALIZER_RELATIVE}
54
+
55
+ Next steps:
56
+ 1. Edit the role -> ability map in
57
+ #{INITIALIZER_RELATIVE}
58
+ Each role lists the registered ability codes it holds. Roles
59
+ inherit downward (owner inherits admin inherits member), so a
60
+ code only needs to appear at the lowest role that should hold it.
61
+
62
+ 2. List the ability codes every installed engine registers:
63
+ bin/rails runner 'pp Seams::PermissionRegistry.all'
64
+ You can only grant codes that appear there — `can?` raises on
65
+ an unregistered code (deny-by-default, loudly).
66
+
67
+ 3. Guard a controller action with a code:
68
+ before_action -> { authorize_permission!("invoice.read.billing") }
69
+
70
+ To fully own this file (skip it on future generator runs):
71
+ bin/seams resolve --eject (or add the `# seams:ejected from` header).
72
+
73
+ See doc/reference/PERMISSIONS.md for the model, the role hierarchy, the bypass
74
+ tiers, and what is deliberately deferred (YAML DSL, DB custom roles,
75
+ per-ability grants).
76
+
77
+ TXT
78
+ end
79
+
80
+ private
81
+
82
+ # A readable Ruby literal for the default grant map, rendered from
83
+ # Seams::Permissions::DEFAULT_GRANTS so the generated file always
84
+ # matches the gem's shipped defaults. Each role becomes a
85
+ # `"role" => %w[...]` block (4-space indent), one ability code per
86
+ # line (6-space indent), so it sits cleanly inside the
87
+ # `config.permission_grants = {` hash in the template. Entries are
88
+ # joined with a trailing comma + newline.
89
+ def grant_map_body
90
+ Seams::Permissions::DEFAULT_GRANTS.map do |role, abilities|
91
+ codes = abilities.map { |code| " #{code}" }.join("\n")
92
+ %( "#{role}" => %w[\n#{codes}\n ])
93
+ end.join(",\n")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Seams permissions — the host-editable role -> ability grant map.
4
+ #
5
+ # This is the ONE place you decide who-can-do-what. There is no database
6
+ # table and no DSL: authorisation is a code-defined role hierarchy plus
7
+ # this map. Edit the hash below, restart, done.
8
+ #
9
+ # How it resolves (Seams::Permissions.can?):
10
+ # - Roles inherit DOWNWARD: owner inherits admin inherits member. So a
11
+ # code only needs to appear at the LOWEST role that should hold it —
12
+ # anything granted to "member" is automatically held by admin + owner.
13
+ # - "system" is a pseudo-role that bypasses every check (background
14
+ # jobs, trusted internal actors). It needs no entry here.
15
+ # - Platform staff (Auth::Identity#staff?) are bypassed at the REQUEST
16
+ # layer by the caller (e.g. authorize_permission!), not here.
17
+ # - Deny-by-default: a role that is not granted a code does not hold it.
18
+ #
19
+ # The catalog of ability CODES is engine-owned. Each engine registers the
20
+ # `resource.action.engine` codes it understands from its engine.rb
21
+ # (mirroring the event bus). You can only grant codes that some engine has
22
+ # registered — referencing an unregistered code makes `can?` RAISE, not
23
+ # quietly allow. List the live catalog with:
24
+ #
25
+ # bin/rails runner 'pp Seams::PermissionRegistry.all'
26
+ #
27
+ # The map below is a copy of Seams::Permissions::DEFAULT_GRANTS as shipped
28
+ # by the gem at the time this file was generated. Add, remove, or move
29
+ # codes between roles to fit your product. To add a brand-new code, first
30
+ # register it from the owning engine's engine.rb, then grant it here.
31
+ Seams.configure do |config|
32
+ config.permission_grants = {
33
+ <%= grant_map_body %>
34
+ }
35
+ end
@@ -41,7 +41,18 @@ module Teams
41
41
  # Wraps the lookup in a row lock and short-circuits if the
42
42
  # invitation has already been accepted, so a double-click on the
43
43
  # email link returns a friendly redirect instead of a 500.
44
+ #
45
+ # Unauthenticated requests stash the token in the session and
46
+ # redirect to the sign-in path with a `return_to` parameter equal
47
+ # to the current request URL. The host's sign-in flow is responsible
48
+ # for reading `return_to` and redirecting the user back after
49
+ # authentication.
44
50
  def accept
51
+ unless current_identity_id
52
+ session[:pending_invitation_token] = params[:token]
53
+ return redirect_to(main_app.new_session_path(return_to: request.url))
54
+ end
55
+
45
56
  Teams::Invitation.transaction do
46
57
  @invitation = Teams::Invitation.lock.find_by!(token: params[:token])
47
58
 
@@ -53,6 +64,18 @@ module Teams
53
64
  return redirect_to root_path, alert: "Invitation expired"
54
65
  end
55
66
 
67
+ # Prevent a holder of a stolen token from accepting the
68
+ # invitation under a different identity. Compare
69
+ # case-insensitively to guard against trivial casing bypasses.
70
+ current_email = if defined?(Auth::Current) && Auth::Current.respond_to?(:identity) && Auth::Current.identity
71
+ Auth::Current.identity.email.to_s.downcase
72
+ else
73
+ ""
74
+ end
75
+ unless current_email == @invitation.email.to_s.downcase
76
+ return redirect_to teams_path, alert: "This invitation was sent to a different email address."
77
+ end
78
+
56
79
  @invitation.team.memberships.create!(identity_id: current_identity_id, role: @invitation.role)
57
80
  @invitation.update!(accepted_at: Time.current)
58
81
  end
@@ -87,16 +110,9 @@ module Teams
87
110
  Teams::Membership::ROLES.include?(candidate) ? candidate : "member"
88
111
  end
89
112
 
90
- # Resolves the signed-in human's id from `Auth::Current.identity`
91
- # (the Auth engine's per-request namespace). Gated on
92
- # `defined?(Auth::Current)` so it's safe in hosts that don't ship
93
- # auth. Override in your host if you wire auth differently.
94
- def current_identity_id
95
- if defined?(Auth::Current) && Auth::Current.respond_to?(:identity) && Auth::Current.identity
96
- return Auth::Current.identity.id
97
- end
98
-
99
- nil
100
- end
113
+ # current_identity_id is provided by Teams::Authorization (included
114
+ # at the top of the class). Do not redefine here — keep the concern
115
+ # as the single source of truth so a future change to identity
116
+ # resolution lands in one place.
101
117
  end
102
118
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module Teams
4
4
  class TeamsController < ApplicationController
5
- before_action :set_team, only: %i[show edit update destroy]
5
+ include Teams::Authorization
6
+
7
+ before_action :set_team, only: %i[show edit update destroy]
8
+ before_action :require_team_member!, only: %i[show]
9
+ before_action :require_team_admin!, only: %i[edit update destroy]
6
10
 
7
11
  def index
8
12
  @teams = Teams::Team.joins(:memberships).where(memberships: { identity_id: current_identity_id })
@@ -52,17 +56,5 @@ module Teams
52
56
  def team_params
53
57
  params.require(:team).permit(:name, :slug)
54
58
  end
55
-
56
- # Resolves the signed-in human's id from `Auth::Current.identity`
57
- # (the Auth engine's per-request namespace). Gated on
58
- # `defined?(Auth::Current)` so it's safe in hosts that don't ship
59
- # auth. Override in your host if you wire auth differently.
60
- def current_identity_id
61
- if defined?(Auth::Current) && Auth::Current.respond_to?(:identity) && Auth::Current.identity
62
- return Auth::Current.identity.id
63
- end
64
-
65
- nil
66
- end
67
59
  end
68
60
  end
@@ -18,13 +18,11 @@ module Teams
18
18
  private
19
19
 
20
20
  def require_team_member!
21
- head :forbidden and return false unless team_member?
22
- true
21
+ redirect_to teams_path, alert: "Not a member of that team." unless team_member?
23
22
  end
24
23
 
25
24
  def require_team_admin!
26
- head :forbidden and return false unless team_admin?
27
- true
25
+ redirect_to teams_path, alert: "Admin access required." unless team_admin?
28
26
  end
29
27
 
30
28
  def team_member?
@@ -18,6 +18,19 @@ module Teams
18
18
  # seams:insertion-point teams.engine.events
19
19
  end
20
20
 
21
+ initializer "teams.register_abilities" do
22
+ # Ability codes this engine owns, registered exactly like events
23
+ # (resource.action.engine). `read` is the member-level view;
24
+ # `manage` is the admin-level mutate. Host grant maps reference
25
+ # these codes; see Seams::Permissions::DEFAULT_GRANTS.
26
+ Seams::PermissionRegistry.register("team.read.teams", owned_by: "Teams")
27
+ Seams::PermissionRegistry.register("team.manage.teams", owned_by: "Teams")
28
+ Seams::PermissionRegistry.register("member.manage.teams", owned_by: "Teams")
29
+ Seams::PermissionRegistry.register("invitation.manage.teams", owned_by: "Teams")
30
+ # Follow-up generators that ship new teams abilities register them here.
31
+ # seams:insertion-point teams.engine.abilities
32
+ end
33
+
21
34
  initializer "teams.append_migrations" do |app|
22
35
  unless app.root == root
23
36
  config.paths["db/migrate"].expanded.each do |expanded_path|
@@ -8,15 +8,29 @@ module Seams
8
8
  # engine under engines/, looks up its registered events in
9
9
  # Seams::EventRegistry, and prints a summary.
10
10
  class List
11
+ # Default directory that holds the generated engines.
11
12
  DEFAULT_ENGINES_ROOT = "engines"
12
13
 
14
+ # Matches the top-level `module Foo` declaration in an engine file.
13
15
  MODULE_DECLARATION = /\bmodule\s+([A-Z][A-Za-z0-9_]*)\b/
14
16
 
17
+ # The canonical event-name convention: at least three
18
+ # dot-separated word segments ("subscription.created.billing").
19
+ # Used to reject regex captures that are NOT event names — the
20
+ # subscription scan is textual and multiline, so when an event
21
+ # name is passed as a variable it would otherwise grab the next
22
+ # unrelated string literal downstream (e.g. a subscriber's
23
+ # owner-class fallback like "Accounts::Account") and report a
24
+ # phantom subscription/dependency.
25
+ EVENT_NAME = /\A\w+(\.\w+){2,}\z/
26
+
15
27
  def initialize(engines_root: DEFAULT_ENGINES_ROOT, output: $stdout)
16
28
  @engines_root = engines_root
17
29
  @output = output
18
30
  end
19
31
 
32
+ # Discover the engines, gather their events, and print the report.
33
+ # @return [Boolean] true on success.
20
34
  def call
21
35
  engines = discover_engines
22
36
  @output.puts("seams: #{engines.size} engine(s) installed")
@@ -69,6 +83,7 @@ module Seams
69
83
  content.scan(/Publisher\.(?:subscribe|attach_once|attach_class)\([^"']*["']([^"']+)["']/m)
70
84
  .flatten
71
85
  end
86
+ .grep(EVENT_NAME)
72
87
  .uniq
73
88
  end
74
89
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
3
4
  require "seams"
4
5
 
5
6
  module Seams
@@ -14,7 +15,10 @@ module Seams
14
15
  #
15
16
  # Returns true when every gate that ran passed; false otherwise.
16
17
  class Quality
18
+ # Default directory that holds the generated engines.
17
19
  DEFAULT_ENGINES_ROOT = "engines"
20
+ # Gate statuses that count as a pass for the overall result.
21
+ PASSING_STATUSES = %i[pass skipped].freeze
18
22
 
19
23
  def initialize(engines_root: DEFAULT_ENGINES_ROOT, output: $stdout)
20
24
  @engines_root = engines_root
@@ -22,6 +26,8 @@ module Seams
22
26
  @results = {}
23
27
  end
24
28
 
29
+ # Run the quality gates across every engine.
30
+ # @return [Boolean] true when every gate that ran passed.
25
31
  def call
26
32
  run_rubocop
27
33
  run_brakeman
@@ -29,19 +35,54 @@ module Seams
29
35
  run_simplecov_collation
30
36
 
31
37
  print_summary
32
- @results.values.all? { |status| %i[pass skipped].include?(status) }
38
+ @results.values.all? { |status| PASSING_STATUSES.include?(status) }
33
39
  end
34
40
 
35
41
  private
36
42
 
43
+ # Runs RuboCop twice over: once host-wide, then once per engine.
44
+ #
45
+ # The per-engine runs are NOT redundant: the generated host
46
+ # .rubocop.yml excludes `engines/**/*` (engines lint themselves),
47
+ # and RuboCop merges the project root's `AllCops: Exclude` into
48
+ # any run started from a subdirectory unless
49
+ # `--ignore-parent-exclusion` is passed. A single host-wide run
50
+ # therefore inspects ZERO engine files, silently skipping the
51
+ # Seams boundary cops. Each engine with its own .rubocop.yml is
52
+ # linted from inside its directory with parent exclusion ignored.
37
53
  def run_rubocop
38
- if gem_installed?("rubocop")
39
- @output.puts("=== rubocop --parallel ===")
40
- @results[:rubocop] = system("bundle", "exec", "rubocop", "--parallel") ? :pass : :fail
41
- else
54
+ unless gem_installed?("rubocop")
42
55
  @output.puts("rubocop not installed — skipping.")
43
56
  @results[:rubocop] = :skipped
57
+ return
44
58
  end
59
+
60
+ @output.puts("=== rubocop --parallel ===")
61
+ host_ok = system("bundle", "exec", "rubocop", "--parallel")
62
+
63
+ # Run every engine even after a failure so the summary shows
64
+ # all offending engines, not just the first.
65
+ engines_ok = engine_rubocop_dirs.map { |dir| engine_rubocop_passed?(dir) }.all?
66
+
67
+ @results[:rubocop] = host_ok && engines_ok ? :pass : :fail
68
+ end
69
+
70
+ def engine_rubocop_passed?(dir)
71
+ @output.puts("=== rubocop --ignore-parent-exclusion (#{dir}) ===")
72
+ out, status = Open3.capture2e(
73
+ "bundle", "exec", "rubocop", "--ignore-parent-exclusion", chdir: dir
74
+ )
75
+ @output.puts(out)
76
+ status.success?
77
+ end
78
+
79
+ # Engines that ship their own .rubocop.yml (the generated
80
+ # default). Engines without one fall back to whatever the host
81
+ # run covers, so they are skipped here.
82
+ def engine_rubocop_dirs
83
+ Dir.glob(File.join(@engines_root, "*"))
84
+ .select { |dir| File.directory?(dir) && File.exist?(File.join(dir, ".rubocop.yml")) }
85
+ .sort
45
86
  end
46
87
 
47
88
  def run_brakeman
@@ -41,16 +41,19 @@ module Seams
41
41
  # with a trailing `?`, but that's wrong for the run_* dispatchers
42
42
  # (they're imperative, not predicates). AbcSize / CyclomaticComplexity
43
43
  # likewise trigger on the run_* methods because CLI command
44
- # branches are inherently branchy. The cops are disabled at file
45
- # scope and the methods are kept linear and well-commented.
46
- # rubocop:disable Naming/PredicateMethod, Metrics/AbcSize, Metrics/CyclomaticComplexity
44
+ # branches are inherently branchy. The cops are disabled for the
45
+ # whole class and the methods are kept linear and well-commented.
46
+ # rubocop:disable-next Naming/PredicateMethod, Metrics/AbcSize, Metrics/CyclomaticComplexity
47
47
  class Resolve
48
+ # Default directory that holds the generated engines.
48
49
  DEFAULT_ENGINES_ROOT = "engines"
49
50
 
50
51
  # Header injected at the top of every ejected file. Position
51
52
  # matters: future regenerations check the FIRST line of an
52
53
  # existing destination file for this exact prefix.
53
54
  EJECT_HEADER_PREFIX = "# seams:ejected from"
55
+
56
+ # Builds the full ejected-file header block for a given event/source.
54
57
  EJECT_HEADER_LINES = lambda do |from|
55
58
  <<~HEADER
56
59
  #{EJECT_HEADER_PREFIX} #{from}
@@ -78,6 +81,8 @@ module Seams
78
81
  @error = error
79
82
  end
80
83
 
84
+ # Execute the selected resolve mode (:eject / :list_markers / :list_ejected).
85
+ # @return [Boolean] true on success, false on a handled failure.
81
86
  def call
82
87
  case @mode
83
88
  when :eject then run_eject
@@ -138,7 +143,7 @@ module Seams
138
143
  fail_with(
139
144
  "refusing to eject #{engine}/#{relative}: this file is framework-managed " \
140
145
  "(migrations, engine.rb, version.rb, Gemfile, .gemspec) and is not eject-eligible. " \
141
- "See doc/INSERTION_POINTS.md and Seams::Generators::EjectAware for the rule."
146
+ "See doc/reference/INSERTION_POINTS.md and Seams::Generators::EjectAware for the rule."
142
147
  )
143
148
  false
144
149
  end
@@ -271,6 +276,5 @@ module Seams
271
276
  false
272
277
  end
273
278
  end
274
- # rubocop:enable Naming/PredicateMethod, Metrics/AbcSize, Metrics/CyclomaticComplexity
275
279
  end
276
280
  end
@@ -17,7 +17,9 @@ module Seams
17
17
  # The caller (rake task or bin/seams) translates that to an exit
18
18
  # code.
19
19
  class TestChanged
20
+ # Default git ref to diff the working tree against.
20
21
  DEFAULT_BASE = "main"
22
+ # Default directory that holds the generated engines.
21
23
  DEFAULT_ENGINES_ROOT = "engines"
22
24
 
23
25
  def initialize(base: DEFAULT_BASE, engines_root: DEFAULT_ENGINES_ROOT, output: $stdout)
@@ -26,6 +28,8 @@ module Seams
26
28
  @output = output
27
29
  end
28
30
 
31
+ # Run the specs of every engine changed since {DEFAULT_BASE}.
32
+ # @return [Boolean] true if all selected suites pass.
29
33
  def call
30
34
  engines = changed_engines
31
35
  if engines.empty?
@@ -59,7 +63,7 @@ module Seams
59
63
  end
60
64
 
61
65
  # Array-form shell-out: no interpolation into a shell, so a
62
- # @base value like `; rm -rf /` passes as one argv element to
66
+ # +@base+ value like `; rm -rf /` passes as one argv element to
63
67
  # git rather than getting evaluated. shell_escape is defence in
64
68
  # depth on top of that.
65
69
  def resolve_merge_base
data/lib/seams/cli.rb CHANGED
@@ -19,10 +19,19 @@ module Seams
19
19
  module CLI
20
20
  module_function
21
21
 
22
+ # List every engine and the events it emits/subscribes to.
23
+ # @param engines_root [String] directory holding the engines.
24
+ # @param output [IO] stream to write the report to.
25
+ # @return [Boolean] true on success.
22
26
  def list(engines_root: "engines", output: $stdout)
23
27
  Seams::CLI::List.new(engines_root: engines_root, output: output).call
24
28
  end
25
29
 
30
+ # Run the specs of every engine changed since +base+.
31
+ # @param base [String] the git ref to diff against.
32
+ # @param engines_root [String] directory holding the engines.
33
+ # @param output [IO] stream to write progress to.
34
+ # @return [Boolean] true if all selected suites pass.
26
35
  def test_changed(base: "main", engines_root: "engines", output: $stdout)
27
36
  Seams::CLI::TestChanged.new(
28
37
  base: base,
@@ -31,10 +40,21 @@ module Seams
31
40
  ).call
32
41
  end
33
42
 
43
+ # Run rubocop across every engine.
44
+ # @param engines_root [String] directory holding the engines.
45
+ # @param output [IO] stream to write progress to.
46
+ # @return [Boolean] true if every engine lints clean.
34
47
  def quality(engines_root: "engines", output: $stdout)
35
48
  Seams::CLI::Quality.new(engines_root: engines_root, output: output).call
36
49
  end
37
50
 
51
+ # Drive the eject / insertion-point escape hatch.
52
+ # @param mode [Symbol] :eject, :list_markers, or :list_ejected.
53
+ # @param argument [String, nil] mode-specific argument (e.g. "<engine>/<file>").
54
+ # @param engines_root [String] directory holding the engines.
55
+ # @param output [IO] stream for normal output.
56
+ # @param error [IO] stream for error output.
57
+ # @return [Boolean] true on success.
38
58
  def resolve(mode:, argument: nil, engines_root: "engines", output: $stdout, error: $stderr)
39
59
  Seams::CLI::Resolve.new(
40
60
  mode: mode,
@@ -1,19 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "seams/permissions"
4
+
3
5
  module Seams
4
6
  # Global Seams configuration. Set via Seams.configure { |c| ... } in
5
7
  # config/initializers/seams.rb of the host application.
8
+ #
9
+ # @example
10
+ # Seams.configure do |c|
11
+ # c.host_app_name = "Acme"
12
+ # c.event_bus_adapter = "MyApp::Events::SidekiqAdapter"
13
+ # end
6
14
  class Configuration
15
+ # @!attribute [rw] event_bus_adapter
16
+ # @return [String] class name of the event-bus adapter (default
17
+ # "Seams::Events::Adapters::ActiveSupport").
18
+ # @!attribute [rw] observability_adapter
19
+ # @return [String] class name of the observability adapter (default
20
+ # "Seams::Observability::Adapters::RailsLogger").
21
+ # @!attribute [rw] event_namespace_separator
22
+ # @return [String] separator between event-name segments (default ".").
23
+ # @!attribute [rw] host_app_name
24
+ # @return [String, nil] the host application's name, for reporting.
25
+ # @!attribute [rw] permission_grants
26
+ # @return [Hash] the role -> ability grant map (default
27
+ # {Seams::Permissions::DEFAULT_GRANTS}).
7
28
  attr_accessor :event_bus_adapter,
8
29
  :observability_adapter,
9
30
  :event_namespace_separator,
10
- :host_app_name
31
+ :host_app_name,
32
+ :permission_grants
11
33
 
12
34
  def initialize
13
35
  @event_bus_adapter = "Seams::Events::Adapters::ActiveSupport"
14
36
  @observability_adapter = "Seams::Observability::Adapters::RailsLogger"
15
37
  @event_namespace_separator = "."
16
38
  @host_app_name = nil
39
+ @permission_grants = Seams::Permissions::DEFAULT_GRANTS
17
40
  end
18
41
  end
19
42
  end
@@ -10,19 +10,22 @@ module RuboCop
10
10
  # through public events (Seams::Events::Publisher) and
11
11
  # explicitly-exposed concerns — not by requiring private files.
12
12
  class NoCrossEngineDependency < Base
13
+ # Path segments that climb (or stay in) the directory tree.
14
+ CLIMB_SEGMENTS = [".", ".."].freeze
15
+
13
16
  MSG = "Engine `%<own>s` must not require `%<path>s` from another engine. " \
14
17
  "Communicate via events or via `%<other>s`'s exposed concerns."
15
18
 
16
19
  # @!method require_call?(node)
17
20
  def_node_matcher :require_call?, <<~PATTERN
18
- (send nil? {:require :require_relative} (str $_))
21
+ (send nil? ${:require :require_relative} (str $_))
19
22
  PATTERN
20
23
 
21
24
  def on_send(node)
22
- path = require_call?(node)
25
+ method, path = require_call?(node)
23
26
  return unless path
24
27
 
25
- offending_engine = other_engine_for(path)
28
+ offending_engine = other_engine_for(method, path)
26
29
  return unless offending_engine
27
30
 
28
31
  add_offense(
@@ -42,11 +45,40 @@ module RuboCop
42
45
  Array(cop_config["OtherEngines"]).map(&:to_s)
43
46
  end
44
47
 
45
- def other_engine_for(path)
46
- # Match either "billing/foo" or "../../billing/foo" — the engine
47
- # name is whichever directory segment matches another engine.
48
+ # For a plain `require`, only the FIRST path segment identifies
49
+ # the library — `require "billing/foo"` loads the sibling engine,
50
+ # but `require "rspec/core/rake_task"` loads rspec-core no matter
51
+ # what its deeper segments are called. Matching ANY segment (the
52
+ # old behaviour) false-fired on every generated host with an
53
+ # engine named `core`, because engine Rakefiles require
54
+ # "rspec/core/rake_task".
55
+ #
56
+ # For `require_relative`, the path is anchored inside the
57
+ # engine's own tree: it only reaches a sibling engine when it
58
+ # first climbs OUT via `../`. Resolve the climb and check the
59
+ # first segment after it — `../../billing/foo` is a genuine
60
+ # cross-engine reach, while `core/something` (no climb) is just
61
+ # a subdirectory of the requiring file.
62
+ def other_engine_for(method, path)
63
+ candidate =
64
+ if method == :require_relative
65
+ first_segment_after_climb(path)
66
+ else
67
+ path.split("/").first
68
+ end
69
+
70
+ other_engines.find { |engine| engine == candidate }
71
+ end
72
+
73
+ # Returns the first path segment after the leading `../` (or
74
+ # `./`) climb, or nil when the path never leaves the requiring
75
+ # file's own directory.
76
+ def first_segment_after_climb(path)
48
77
  segments = path.split("/")
49
- other_engines.find { |engine| segments.include?(engine) }
78
+ climbed = segments.take_while { |segment| CLIMB_SEGMENTS.include?(segment) }
79
+ return nil unless climbed.include?("..")
80
+
81
+ segments.drop(climbed.size).first
50
82
  end
51
83
 
52
84
  def capitalize(name)