thecore_generators 3.2.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48198bd6f914066ca468eae2e95cb9dba85003d49532b1db28f8620db3c40b21
4
- data.tar.gz: 7a3e542b82e0ff43691e3a988dad6e42f59fc51da0e460220e5481881eb55ba3
3
+ metadata.gz: b52de470428f2cecb91388721b140a88d6cdb0a1e0ea7b3ce07b0b855e17fbaa
4
+ data.tar.gz: 5a6147ac698f2820027e493bd8bdee96c13f39867dd50f6b472a495f5f01b829
5
5
  SHA512:
6
- metadata.gz: 82748585f40f3f1e3975a3c1186d4bb98a3d8c4963ebcb495d9ba4c3c86701b2a48e981c28a6bfe220bd20b90fbcdacffa7e332b36edcbba2d4c3ab80e097e0d
7
- data.tar.gz: ca82db7bdc678f66789b0ed9af24bf28f4f2253e2c025c3972118e602a86e662fc845863b098ba220d3163739c30b73f2588b30eb0aa8384300bc86a25adf013
6
+ metadata.gz: 52374b36988c76209702afb27c59e32ac95e05c53ca805914257264bb3eaae31ee06fa3329faae3a6da5c25742d31e136f470a22cf1897cdbd9a17caad010f97
7
+ data.tar.gz: 75083924ea8bdc22bf39d5dbea930da5ec5eb37d736107b43037e235d40a4430b0ae963a24133b9789631135de83b51cdfb1afd82515c4d2f0c69ddb43bb30a2
data/README.md CHANGED
@@ -12,10 +12,16 @@ own `thecore:*`-namespaced generators or an application template. See
12
12
  in the thecore repo for the full design.
13
13
 
14
14
  **Status:** Model + Migration generator hook (Phase 1 of
15
- [ADR 0002](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0002-thecore-generators-gem-and-generator-hook-mechanism.md)).
15
+ [ADR 0002](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0002-thecore-generators-gem-and-generator-hook-mechanism.md)),
16
+ plus default-concern removal ([ADR 0001](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0001-application-record-defaults-over-generated-concerns.md))
17
+ and migration-driven inverse-association wiring
18
+ ([ADR 0003](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0003-migration-driven-inverse-association-wiring.md)).
16
19
  `ThecoreGenerators::Railtie` registers `config.app_generators.orm :thecore, migration:
17
20
  true, timestamps: true`, so plain `rails generate model`/`rails generate migration`
18
- transparently apply thecore's scaffolding conventions — no new command vocabulary.
21
+ transparently apply thecore's scaffolding conventions — no new command vocabulary. **Phase 2
22
+ (check_practices + Root/Member Action generators) is complete** as of this release: `rails
23
+ generate thecore:root_action`, `rails generate thecore:member_action`, and `rails
24
+ thecore:check_practices` (Scaffold Files + Models + Actions, `--fix` included) all ship.
19
25
 
20
26
  ### What `rails generate model`/`rails generate migration` do now
21
27
 
@@ -104,6 +110,148 @@ model that has no concern of its own.
104
110
  Either concern can be added independently — a model doesn't need both just because it
105
111
  needs one.
106
112
 
113
+ ### Inverse association wiring for `references` columns
114
+
115
+ Rails' own generators only ever wire the owning (`belongs_to`) side of a `references`/
116
+ `add_reference` column — the inverse `has_many`/`has_one` side has always been a manual
117
+ follow-up. `rails generate model`/`migration` now write that missing inverse side
118
+ automatically into the *target* model's own canonical concern
119
+ (`config/initializers/concern_<target_model>.rb`, wired up via
120
+ `config/initializers/after_initialize.rb`):
121
+
122
+ ```bash
123
+ rails generate migration AddPostRefToComments post:references
124
+ # → prompts: "Inverse association on Post for this reference (has_many/has_one/skip)?"
125
+ # → writes `has_many :comments` into config/initializers/concern_post.rb
126
+ ```
127
+
128
+ With a real terminal attached you're prompted for cardinality (`has_many` default, `has_one`,
129
+ or `skip`); pass `--non-interactive` (always used by `addModel`/`addMigration` in the VS Code
130
+ extension, and recommended for any other scripted/CI invocation) to skip the prompt and default
131
+ straight to `has_many`. Re-running the generator against the same target model never duplicates
132
+ an already-written association line, and a later, different reference onto the same target
133
+ appends into the same existing concern file. When the target model lives in a different ATOM
134
+ than the one being generated into, the concern is still written into the *invoking* app/ATOM
135
+ (never the target's own) and the generator logs — but never edits — the gemspec/Gemfile
136
+ dependency line a human needs to add so that include actually resolves. Full mechanics in
137
+ `CLAUDE.md`.
138
+
139
+ ### `rails generate thecore:root_action NAME`
140
+
141
+ A Ruby port of `thecore_code_extension`'s `addRootAction.js` — produces the same end result
142
+ from a terminal, with the same ATOM-aware placement `rails generate model`/`migration` use:
143
+
144
+ ```bash
145
+ rails generate thecore:root_action my_action
146
+ ```
147
+
148
+ This creates:
149
+
150
+ - The RailsAdmin action config file — `lib/root_actions/my_action.rb` in ATOM context,
151
+ `config/root_actions/my_action.rb` in host-app context (main-app actions never live under
152
+ `lib/`, for Zeitwerk autoload safety).
153
+ - Its view/JS/SCSS companions: `app/views/rails_admin/main/my_action.html.erb`,
154
+ `app/assets/javascripts/rails_admin/actions/my_action.js`,
155
+ `app/assets/stylesheets/rails_admin/actions/my_action.scss`.
156
+ - A `require` line inserted into `config/initializers/after_initialize.rb` (created from a
157
+ skeleton if absent) — `require 'root_actions/my_action'` in ATOM context, a full
158
+ `Rails.root.join(...)` require in host-app context (`config/` isn't on the load path).
159
+ - A precompile line inserted into `config/initializers/assets.rb` (created from a skeleton if
160
+ absent).
161
+ - An `admin.actions.my_action` locale entry (`menu`/`title`/`breadcrumb`, all set to the
162
+ title-cased action name) written into **every** `*.yml` file already present under
163
+ `config/locales` — `en.yml`/`it.yml` are created first only when the directory has none yet.
164
+
165
+ `NAME` must be snake_case (lowercase letters, digits, underscores). `--atom=NAME` overrides
166
+ placement the same way it does for `rails generate model`/`migration`. Re-running the
167
+ generator against the same action name never duplicates the require line, the precompile
168
+ line, or a locale entry.
169
+
170
+ The reusable pieces behind this (`Thecore::Generators::CompanionFiles`,
171
+ `Thecore::Generators::ActionCompanion`) are shared with `thecore:member_action` (below), and
172
+ `check_practices --fix` (further below) delegates straight to both generators' own template
173
+ rendering rather than reimplementing it.
174
+
175
+ ### `rails generate thecore:member_action NAME`
176
+
177
+ The Member Action counterpart to `thecore:root_action` above — a Ruby port of
178
+ `thecore_code_extension`'s `addMemberAction.js`, sharing everything about placement, the
179
+ after_initialize.rb/assets.rb/locale mechanics, and even the generator implementation itself
180
+ (`Thecore::Generators::ActionCompanion`) with Root Action:
181
+
182
+ ```bash
183
+ rails generate thecore:member_action my_action
184
+ ```
185
+
186
+ Same file layout as Root Action (`lib/member_actions/`/`config/member_actions/`,
187
+ `app/views/rails_admin/main/my_action.html.erb`, `.../actions/my_action.js`/`.scss`,
188
+ after_initialize.rb require line, assets.rb precompile line, every-locale-file entry,
189
+ `--atom=NAME`, idempotent re-run). What differs is each action's own template content — not
190
+ unified between the two, matching `addRootAction.js`/`addMemberAction.js`'s own separate
191
+ templates exactly:
192
+
193
+ - **`action.rb`** (server-side, the real behavioral difference) — a RailsAdmin `:member`
194
+ action (`http_methods [:get, :patch]`) whose controller branches on `request.xhr? &&
195
+ request.get?` (returns JSON) vs. `request.patch?` (a form submission, redirects back to the
196
+ record), instead of Root's single `:root` action with a fetch/JSON + `ActionCable.server.broadcast`
197
+ example.
198
+ - **`action.js`/`action.html.erb`** — both still set up the same `ActivityLogChannel`
199
+ ActionCable subscription Root's do; only the test button's click handler differs (a plain
200
+ XHR `GET` here vs. `fetch` there), and the view adds a `form_with(..., method: :patch)` for
201
+ the PATCH half of the example.
202
+
203
+ ### `rails thecore:check_practices`
204
+
205
+ A Ruby port of `thecore_code_extension`'s `checkPractices.js` — audits **Scaffold Files**
206
+ (thecore_generators#13), **Models** (thecore_generators#13), and **Actions**
207
+ (thecore_generators#14, `--fix` included).
208
+
209
+ ```bash
210
+ rails thecore:check_practices # host app + every ATOM under vendor/submodules/
211
+ rails thecore:check_practices -- --atom=my_atom # scope to a single ATOM
212
+ rails thecore:check_practices -- --json # structured output for CI/the VS Code extension
213
+ rails thecore:check_practices -- --fix # apply every fixable violation, no confirmation
214
+ ```
215
+
216
+ The `--` before any flag is the standard Rake convention for passing arguments through to a
217
+ task instead of having Rake's own option parser reject them — see
218
+ [Rake's own docs](https://ruby.github.io/rake/doc/rakefile_rdoc.html#label-Task+Arguments).
219
+ Flags combine freely, e.g. `rails thecore:check_practices -- --atom=my_atom --fix --json`.
220
+
221
+ - **Scaffold Files** — `config/initializers/after_initialize.rb` and `assets.rb` must exist
222
+ and carry their structural marker (`Rails.application.configure do` /
223
+ `Rails.application.config.assets.precompile`). Checked in **both** ATOM and host-app
224
+ context (`checkPractices.js` only ever checked ATOM context). Not fixable.
225
+ - **Models** — rescoped per [ADR 0001](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0001-application-record-defaults-over-generated-concerns.md):
226
+ a model with **no** `Api::`/`RailsAdmin::` concern is the correct, no-customization default
227
+ and is never flagged. Only two states are violations: a model `include`-ing a concern module
228
+ whose file doesn't exist (`orphan_api_include`/`orphan_rails_admin_include`), or a concern
229
+ file present but missing one of its required markers (`extend ActiveSupport::Concern` plus
230
+ `cattr_accessor :json_attrs` for `Api::`, `rails_admin do` for `RailsAdmin::`). Not fixable —
231
+ regenerating over an existing, hand-edited concern could clobber real customization.
232
+ - **Actions** — scans `root_actions`, `member_actions`, and `collection_actions` (in `lib/` for
233
+ an ATOM, `config/` for the host app), with the same rules for all three:
234
+ `collection_actions` is scanned even though no generator creates files there yet, since a
235
+ hand-written one could already exist and go unreported otherwise. Reports missing/broken
236
+ action-file markers (`RailsAdmin::Config::Actions.add_action`, `http_methods` — never
237
+ fixable), a missing companion view/JS/SCSS or one present but missing its own marker (a
238
+ *missing* companion is fixable for `root_action`/`member_action`, by delegating straight to
239
+ that generator's own template rendering — never for `collection_action`, which has no
240
+ generator to delegate to; an *existing* companion missing a marker is never fixable, same
241
+ reasoning as Models), a missing `after_initialize.rb` require line (fixable, all three
242
+ kinds), and a missing locale entry checked against **every** `*.yml` already present in the
243
+ locales directory, not just `en`/`it` (fixable, all three kinds).
244
+
245
+ Default output is human-readable text grouped by file; `--json` emits
246
+ `{ "violations": [{ "file", "line", "message", "severity", "fixable", "code" }] }` — `code` is
247
+ a stable identifier (e.g. `missing_after_initialize`, `orphan_api_include`,
248
+ `missing_companion_view`) a future consumer can filter on without depending on `message` text.
249
+ `--fix` applies every fixable violation in one pass with no confirmation of its own — whoever
250
+ passes it has already decided — then re-scans and reports/exits based on whatever violations
251
+ remain (so a violation this run can't fix, e.g. a `collection_action` companion, still shows up
252
+ after `--fix`). The task exits non-zero whenever any violation remains, zero otherwise, so it's
253
+ usable as a CI gate either with or without `--fix`.
254
+
107
255
  ## Installation
108
256
 
109
257
  Add to your host app's or ATOM's `Gemfile`:
@@ -142,6 +290,16 @@ To run a single test file:
142
290
  bundle exec ruby -Itest test/generators/thecore/model_generator_test.rb
143
291
  ```
144
292
 
293
+ ## Who depends on this
294
+
295
+ The host backend app's own `Gemfile` (`:development` group) and the
296
+ [Thecore VS Code extension](https://github.com/gabrieletassoni/thecore_code_extension)'s
297
+ `addModel`/`addMigration` commands both depend on this gem being present to get Thecore-aware
298
+ `rails generate` behavior — the extension actually checks for it and offers to add it
299
+ automatically if it's missing, since without it `rails generate model`/`migration` still "work"
300
+ but silently skip every convention described above. See `CLAUDE.md`'s "Who consumes this gem"
301
+ section for detail.
302
+
145
303
  ## Releasing
146
304
 
147
305
  Version lives in `lib/thecore_generators/version.rb`. Pushing a commit that bumps it
@@ -0,0 +1,116 @@
1
+ module Thecore
2
+ module Generators
3
+ # Shared helper logic for Thecore's own per-action generators
4
+ # (RootActionGenerator - thecore_generators#11, MemberActionGenerator -
5
+ # thecore_generators#12): everything about them that is NOT the action
6
+ # file's own RailsAdmin action type/template content, and not a Thor
7
+ # *task* method itself.
8
+ #
9
+ # Thor::Group (which Rails::Generators::Base/NamedBase extends)
10
+ # discovers its task list via a `method_added` hook that only fires for
11
+ # methods defined directly, via `def`, in the generator class's own
12
+ # body - never for methods a class merely picks up through `include`.
13
+ # So each of `validate_action_name!`/`create_action_file`/
14
+ # `create_view_js_scss_companions`/`add_after_initialize_require`/
15
+ # `add_assets_precompile_line`/`add_locale_entries` must still be a
16
+ # thin method defined directly on RootActionGenerator/
17
+ # MemberActionGenerator themselves (identical one-liners in both); only
18
+ # the private logic those methods delegate to lives here.
19
+ #
20
+ # Extracted once both generators existed and turned out identical apart
21
+ # from that thin task-method layer and their directory name
22
+ # ("root_actions"/"member_actions") - not a speculative abstraction
23
+ # built ahead of a second user.
24
+ #
25
+ # The including class must declare `action_kind "root_action"` (or
26
+ # `"member_action"`) at the class body level, which derives both the
27
+ # directory name (pluralized: "root_actions"/"member_actions") and the
28
+ # wording used in the name-validation error message.
29
+ module ActionCompanion
30
+ def self.included(base)
31
+ base.extend(ClassMethods)
32
+ end
33
+
34
+ # Shared require-line text for an action's `after_initialize.rb`
35
+ # registration - `lib/<kind>s/<name>` in ATOM context (on the load
36
+ # path via the gemspec), a full `Rails.root.join('config', ...)` path
37
+ # in host-app context (`config/` isn't on the load path). A plain
38
+ # module method (not routed through `ClassMethods`/`extend`, unlike
39
+ # `action_kind` below) so it's callable directly as
40
+ # `Thecore::Generators::ActionCompanion.require_line_for(...)` without
41
+ # an including generator instance - used both by `require_line` below
42
+ # (via a real generator's own `atom_dir`/`file_name`) and directly by
43
+ # `Thecore::CheckPractices`' Actions check, so the audit's own
44
+ # expectation can never drift out of sync with what a real Root/Member
45
+ # Action generator actually writes.
46
+ def self.require_line_for(kind:, in_atom:, name:)
47
+ dir_name = "#{kind}s"
48
+ if in_atom
49
+ "require '#{dir_name}/#{name}'"
50
+ else
51
+ "require Rails.root.join('config', '#{dir_name}', '#{name}').to_s"
52
+ end
53
+ end
54
+
55
+ # A class-level accessor, not an instance `define_method`, and
56
+ # deliberately so: any *instance* method this DSL defined directly on
57
+ # the including generator class - even a private one, since Thor's
58
+ # `method_added` hook fires and registers it as a task at the moment
59
+ # `define_method` returns, before a later `private` call could demote
60
+ # it - would itself become a spurious Thor task (this was tried and
61
+ # caught via `RootActionGenerator.all_tasks.keys` including
62
+ # "action_kind" as an actual, if harmless, generator step). Storing
63
+ # the value in a class-level ivar instead, read back via
64
+ # `self.class.action_kind` from the private instance methods below,
65
+ # adds no method to the generator class's own instance side at all.
66
+ module ClassMethods
67
+ def action_kind(value = nil)
68
+ @action_kind = value unless value.nil?
69
+ @action_kind
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def validate_action_name_for_kind!
76
+ return if name.match?(/\A[a-z_][a-z0-9_]*\z/)
77
+
78
+ raise Thor::Error,
79
+ "'#{name}' is not a valid #{self.class.action_kind.tr("_", " ")} name - use " \
80
+ "snake_case, starting with a lowercase letter or underscore (a leading digit would " \
81
+ "make the generated `topic: :#{name}` symbol invalid Ruby)."
82
+ end
83
+
84
+ def action_dir_name
85
+ "#{self.class.action_kind}s"
86
+ end
87
+
88
+ # lib/<action_dir_name> in ATOM context (on the load path via the
89
+ # gemspec), config/<action_dir_name> in host-app context (not on the
90
+ # load path, hence the full-path require in `require_line` below) -
91
+ # see docs/adr/0001-main-app-actions-live-in-config.md in
92
+ # thecore_code_extension.
93
+ def action_file_path
94
+ base_dir = atom_dir ? "lib" : "config"
95
+ File.join(base_dir, action_dir_name, "#{file_name}.rb")
96
+ end
97
+
98
+ def require_line
99
+ ActionCompanion.require_line_for(kind: self.class.action_kind, in_atom: !atom_dir.nil?, name: file_name)
100
+ end
101
+
102
+ def assets_precompile_line
103
+ "Rails.application.config.assets.precompile += %w( rails_admin/actions/#{file_name}.js " \
104
+ "rails_admin/actions/#{file_name}.css )"
105
+ end
106
+
107
+ def action_name_camel_case
108
+ file_name.downcase.gsub(/[-_]([a-z0-9])/) { Regexp.last_match(1).upcase }
109
+ end
110
+
111
+ def title_case_name
112
+ file_name.split("_").map(&:capitalize).join(" ")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,217 @@
1
+ require "generators/thecore/workspace_context"
2
+
3
+ module Thecore
4
+ module Generators
5
+ # Implements ADR 0003 in the thecore repo
6
+ # (docs/adr/0003-migration-driven-inverse-association-wiring.md):
7
+ # Rails' own migration generator only ever wires the owning (`belongs_to`)
8
+ # side of a `references`/`add_reference` column — the inverse
9
+ # (`has_many`/`has_one`) has always been manual. This module detects
10
+ # `references` attributes on the migration being generated and writes the
11
+ # missing inverse side into the *target* model's canonical per-model
12
+ # concern, following the cross-ATOM extension pattern GUIDE.md §4.5
13
+ # already documents (`config/initializers/concern_<model>.rb` plus a
14
+ # `TargetModel.send(:include, ...)` registered in
15
+ # `config/initializers/after_initialize.rb`).
16
+ #
17
+ # Included by both Thecore::Generators::MigrationGenerator (standalone
18
+ # `rails generate migration ... x:references`) and
19
+ # Thecore::Generators::ModelGenerator (`rails generate model Foo
20
+ # x:references` delegates its own migration creation to
21
+ # ActiveRecord::Generators::ModelGenerator#create_migration_file, a
22
+ # *different* method than MigrationGenerator's own, so both host classes
23
+ # need their own hook). Both wire it the same way: override
24
+ # `create_migration_file`, call `super`, then
25
+ # `wire_inverse_associations_from_references` — matching the
26
+ # override-and-call-super style Thecore::Generators::ModelGenerator
27
+ # already uses for `create_model_file`/`add_default_concerns`.
28
+ #
29
+ # Requires the includer to already provide (directly or via
30
+ # Thecore::Generators::AtomAware, included by both host classes):
31
+ # - `attributes` — Rails::Generators::GeneratedAttribute array (from
32
+ # ActiveRecord's own migration/model generator `argument`)
33
+ # - `table_name` — Rails::Generators::NamedBase; memoized into
34
+ # @table_name, which for MigrationGenerator is populated by
35
+ # ActiveRecord's own `set_local_assigns!` (run during `super`) from
36
+ # the migration's file name (`add_x_to_y`/`create_y`), and for
37
+ # ModelGenerator resolves directly from the model's own class name -
38
+ # either way it names the table/model *gaining* the reference.
39
+ # - `destination_root`/`host_app_root` — Thecore::Generators::AtomAware
40
+ module AssociationWiring
41
+ HEADER_COMMENT = <<~RUBY.freeze
42
+ # This file is maintained by thecore_generators (migration generator's
43
+ # inverse-association wiring - see
44
+ # docs/adr/0003-migration-driven-inverse-association-wiring.md in the
45
+ # thecore repo). Associations below are appended automatically whenever
46
+ # a `references` column targeting this model is added elsewhere.
47
+ # Do not hand-edit the generated section: a future generator run may
48
+ # append to it again, and hand edits are not accounted for.
49
+ RUBY
50
+
51
+ AFTER_INITIALIZE_TEMPLATE = <<~RUBY.freeze
52
+ Rails.application.configure do
53
+ config.after_initialize do
54
+ end
55
+ end
56
+ RUBY
57
+
58
+ def self.included(base)
59
+ base.class_option :non_interactive, type: :boolean, default: false,
60
+ desc: "Skip the inverse-association cardinality prompt and default to has_many " \
61
+ "(no real TTY is available to CI or scripted invocations, e.g. the VS Code " \
62
+ "extension)"
63
+ end
64
+
65
+ private
66
+
67
+ # Entry point, called after `super` from the includer's own
68
+ # `create_migration_file` override.
69
+ def wire_inverse_associations_from_references
70
+ return if options[:migration] == false # ModelGenerator's --no-migration
71
+ return if attributes.nil?
72
+
73
+ owning_table_name = table_name
74
+ return if owning_table_name.blank?
75
+
76
+ reference_attributes = attributes.select(&:reference?)
77
+ return if reference_attributes.empty?
78
+
79
+ reference_attributes.each { |attribute| wire_inverse_association(owning_table_name, attribute) }
80
+ end
81
+
82
+ def wire_inverse_association(owning_table_name, attribute)
83
+ target_class_name = attribute.singular_name.camelize
84
+ cardinality = prompt_cardinality_for(target_class_name)
85
+ return if cardinality == :skip
86
+
87
+ association_line = association_line_for(cardinality, owning_table_name)
88
+ write_target_concern(target_class_name, association_line)
89
+ register_after_initialize(target_class_name)
90
+ log_cross_boundary_dependency(target_class_name)
91
+ end
92
+
93
+ def association_line_for(cardinality, owning_table_name)
94
+ if cardinality == :has_one
95
+ "has_one :#{owning_table_name.singularize}"
96
+ else
97
+ "has_many :#{owning_table_name}"
98
+ end
99
+ end
100
+
101
+ def prompt_cardinality_for(target_class_name)
102
+ return :has_many unless interactive_association_prompt?
103
+
104
+ answer = ask(
105
+ "Inverse association on #{target_class_name} for this reference " \
106
+ "(has_many/has_one/skip)?",
107
+ default: "has_many",
108
+ limited_to: %w[has_many has_one skip]
109
+ )
110
+ answer.to_s.strip.to_sym
111
+ end
112
+
113
+ # No real TTY is present for CI/scripted/extension invocations (or the
114
+ # includer was explicitly told not to prompt via --non-interactive) -
115
+ # default straight to has_many, per ADR 0003.
116
+ def interactive_association_prompt?
117
+ !options[:non_interactive] && $stdin.tty? && $stdout.tty?
118
+ end
119
+
120
+ def concern_path_for(target_class_name)
121
+ "config/initializers/concern_#{target_class_name.underscore}.rb"
122
+ end
123
+
124
+ def concern_module_name_for(target_class_name)
125
+ "Concern#{target_class_name}"
126
+ end
127
+
128
+ def write_target_concern(target_class_name, association_line)
129
+ concern_path = concern_path_for(target_class_name)
130
+
131
+ if File.exist?(File.join(destination_root, concern_path))
132
+ insert_association_into_existing_concern(concern_path, association_line)
133
+ else
134
+ create_concern_file(concern_path, concern_module_name_for(target_class_name), association_line)
135
+ end
136
+ end
137
+
138
+ def create_concern_file(concern_path, concern_module_name, association_line)
139
+ content = <<~RUBY
140
+ #{HEADER_COMMENT}
141
+ module #{concern_module_name}
142
+ extend ActiveSupport::Concern
143
+
144
+ included do
145
+ #{association_line}
146
+ end
147
+ end
148
+ RUBY
149
+
150
+ create_file(concern_path, content)
151
+ end
152
+
153
+ def insert_association_into_existing_concern(concern_path, association_line)
154
+ full_path = File.join(destination_root, concern_path)
155
+ content = File.read(full_path)
156
+
157
+ if content.match?(/^\s*#{Regexp.escape(association_line)}\s*$/)
158
+ say_status :skip, "#{concern_path} already has `#{association_line}`", :blue
159
+ return
160
+ end
161
+
162
+ insert_into_file(concern_path, " #{association_line}\n", after: /included do\n/)
163
+ end
164
+
165
+ def register_after_initialize(target_class_name)
166
+ path = "config/initializers/after_initialize.rb"
167
+ full_path = File.join(destination_root, path)
168
+
169
+ create_file(path, AFTER_INITIALIZE_TEMPLATE) unless File.exist?(full_path)
170
+
171
+ registration_line = "#{target_class_name}.send(:include, #{concern_module_name_for(target_class_name)})"
172
+ content = File.read(full_path)
173
+ return if content.include?(registration_line)
174
+
175
+ insert_into_file(path, " #{registration_line}\n", after: /config\.after_initialize do\n/)
176
+ end
177
+
178
+ # Cross-boundary case (ADR 0003): the concern always lives in the
179
+ # invoking app/ATOM (destination_root), never the target model's own -
180
+ # so when the target model actually lives elsewhere, the generated
181
+ # `TargetModel.send(:include, ...)` line depends on that model's
182
+ # constant being loaded, which requires a real gem/path dependency a
183
+ # human has to add. We only log it — never edit a gemspec/Gemfile.
184
+ def log_cross_boundary_dependency(target_class_name)
185
+ target_root = Thecore::Generators::WorkspaceContext.model_root_for(
186
+ class_name: target_class_name, app_root: host_app_root
187
+ )
188
+ return if target_root.nil? || target_root == destination_root
189
+
190
+ own_is_atom = destination_root != host_app_root
191
+ target_is_atom = target_root != host_app_root
192
+ own_label = own_is_atom ? File.basename(destination_root) : "the host app"
193
+ target_label = target_is_atom ? File.basename(target_root) : "the host app"
194
+
195
+ message = "Cross-boundary reference: #{target_class_name} lives in #{target_label}, but the " \
196
+ "generated concern was written into #{own_label} (ADR 0003: the concern always lives in the " \
197
+ "invoking app/ATOM). #{dependency_hint(own_is_atom, target_is_atom, target_root)}"
198
+
199
+ say_status :dependency, message, :yellow
200
+ end
201
+
202
+ def dependency_hint(own_is_atom, target_is_atom, target_root)
203
+ target_gem_name = File.basename(target_root)
204
+
205
+ if own_is_atom && target_is_atom
206
+ "Add a gem dependency in this ATOM's gemspec, e.g.: spec.add_dependency \"#{target_gem_name}\""
207
+ elsif !own_is_atom && target_is_atom
208
+ "Add a Gemfile dependency in the host app, e.g.: gem \"#{target_gem_name}\", " \
209
+ "path: \"vendor/submodules/#{target_gem_name}\""
210
+ else
211
+ "The target model lives in the host app; confirm this ATOM is meant to reference host-app " \
212
+ "code directly (no gemspec/Gemfile dependency line applies here)."
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -30,21 +30,32 @@ module Thecore
30
30
 
31
31
  def initialize(*args)
32
32
  super
33
+ @host_app_root = destination_root
33
34
  self.destination_root = atom_dir if atom_dir
34
35
  end
35
36
 
37
+ # The true host-app root, captured before `destination_root` is
38
+ # (possibly) overridden above — for a real `rails generate` invocation
39
+ # this is `Rails::Command.root`, in tests whatever
40
+ # `Rails::Generators::TestCase` configured as `destination_root`.
41
+ # Unlike `destination_root` (which becomes the ATOM dir once
42
+ # overridden), this always stays the app root, giving
43
+ # Thecore::Generators::WorkspaceContext.model_root_for a stable anchor
44
+ # to search from regardless of where *this* invocation itself landed.
45
+ attr_reader :host_app_root
46
+
36
47
  # The absolute ATOM directory this generator's files should land in, or
37
48
  # nil for plain host-app context. Resolved once, before
38
49
  # `destination_root` is (possibly) overridden above, since the
39
- # pre-override `destination_root` is the correct app-root anchor for
40
- # resolving an explicit `--atom=NAME`.
50
+ # pre-override `destination_root` (== `host_app_root`) is the correct
51
+ # app-root anchor for resolving an explicit `--atom=NAME`.
41
52
  def atom_dir
42
53
  return @atom_dir if @atom_dir_resolved
43
54
 
44
55
  @atom_dir_resolved = true
45
56
  @atom_dir = Thecore::Generators::WorkspaceContext.atom_dir_for(
46
57
  cwd: Dir.pwd,
47
- app_root: destination_root,
58
+ app_root: host_app_root,
48
59
  atom_name: options[:atom]
49
60
  )
50
61
  end