react-manifest-rails 0.3.0 → 0.3.2

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: 42ca130740decc2c19123ab8e2d0b368925f0bd988736d84a9087d4016e1836c
4
- data.tar.gz: e9e23a7c0eb8dea12f4eb8498f08bff0ea11fe56f168833ae3ef13e090b4d59a
3
+ metadata.gz: d5ea479337143141722feeaa26cbf9e8d46e2158c399f5d29f4cb6bc5dbbdcd7
4
+ data.tar.gz: 0d4983f0b8ec3c35d7165fbfe801fc3f73286868e88bca1da83e76f6dcf963ce
5
5
  SHA512:
6
- metadata.gz: d73c047a2257f229e26682658c2644c0645fd362ba30f2f2ef34fceb15ecf83cf282b2c7899472e3eb25351b30512b5a3e8dbe456e1057502453876f2ce1868d
7
- data.tar.gz: 78ebfd5b3390fa6204d6a59a46d94f1aef87c87359b499049f745da68d493f2dab3dd4fefe1f46667ca1be40f66c02e17fa1b977f3fc5b67370a9ba37a9b0566
6
+ metadata.gz: dfeba95401019cb1233677138d89b99d9c286d55c89edc74fd1e9f10e4ade37f0038cfede9d12045a43d0143355c622c9c746178d0a8da75c3c6cf97e62b9a79
7
+ data.tar.gz: 29159646362cbdb7be57a57fdc77fd61010e607c7dfe2214f65e301b352b44914e1749304bb0f6e457b2cab97f0627f34ea5dfbbef1597ed3a93951069389baa
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2] - 2026-10-13
11
+
12
+ ### Added
13
+ - `react_component` adds `data-react-persist="true"` to the root element when it runs after
14
+ `react_bundle_tag` in the same request. That is a component in the layout body (navbar,
15
+ footer, sidebar), since Rails renders the template first. A Turbolinks wrapper can keep it
16
+ mounted across visits and pass it new props. Template components are never marked.
17
+
18
+ ## [0.3.1] - 2026-10-13
19
+
20
+ ### Added
21
+ - `react_bundle_tag` emits `data-turbolinks-track="reload"` on the shared bundle's tag only
22
+ (`config.shared_bundle`). Turbolinks 5 then full-reloads once after a deploy changes
23
+ `ux_shared`, so old shared code never runs with new controller code. Controller and
24
+ `always_include` bundles stay untracked to avoid a reload on every cross-controller visit.
25
+ - `react_manifest:generate` warns when two different files declare the same top-level
26
+ `const`/`let`/`class` (destructured names included) in different bundles. Classic scripts
27
+ share one global scope, so under Turbolinks the second bundle throws "Identifier X has
28
+ already been declared" and is rejected.
29
+ - `react_manifest:generate` (and the dev boot/watch generation) writes
30
+ `ux_manifests/component_index.json`, the component-symbol -> bundle maps. `react_component`
31
+ loads it instead of parsing `ux/app` with mini_racer on each process's first call (no V8
32
+ context, no per-worker RSS cost). It is used only when its content fingerprint matches;
33
+ a missing or stale sidecar falls back to the runtime parse. Gitignored alongside the
34
+ manifests; removed by `react_manifest:clean`.
35
+ - `rake react_manifest:check`: analysis only, writes nothing, exits 1 with the warnings on
36
+ stderr when any "declared twice" clash is found (cross-bundle lexical collisions,
37
+ `always_include` double-declares). Meant for CI.
38
+
39
+ ### Fixed
40
+ - `mini_racer` is now a declared runtime dependency (`>= 0.2`); `AstExtractor` already
41
+ required it unconditionally.
42
+ - The built gem no longer ships `lib/react_manifest/vendor/build/node_modules`.
43
+ - `react_bundle_tag` now hoists bundle `<script>` tags into the layout `<head>`. Rails renders
44
+ the template before the layout, so `react_component` wrote ux_shared, `always_include` and
45
+ controller bundle tags into the `<body>`; Turbolinks re-evaluates body scripts on every
46
+ visit, giving "Identifier X has already been declared". `react_component` now marks its
47
+ inline tags (`data-react-manifest`) and `react_bundle_tag` moves them to the head, once, in
48
+ shared → always_include → controller order. Layouts without `react_bundle_tag` are unchanged.
49
+ - Collision warnings for `always_include` bundles now cover the every-page double.
50
+ Previously only a file that **directly** defined a collided symbol was warned about.
51
+ A file that is merely *tainted* (transitively depends on a collided symbol) and gets
52
+ pulled into **2+** `always_include` tags double-declares on every page but was not
53
+ warned. The warning now reports both a *multi-tag* double (a file carried by 2+
54
+ always_include tags — including tainted files, naming the file, symbol, and bundles)
55
+ and the *owner-page* double (single tag vs. the owning bundle), with wording accurate
56
+ to each case rather than always claiming the double happens "on the owner's page".
57
+
10
58
  ## [0.3.0] - 2026-07-15
11
59
 
12
60
  ### Fixed
data/README.md CHANGED
@@ -143,13 +143,14 @@ end
143
143
  ### Key option notes
144
144
 
145
145
  - **`ignore`**: skips entire controller dirs under `ux/app/`. `ignore = ["admin"]` excludes `ux/app/admin/`.
146
- - **`isolated_app_dirs`**: a controller dir's own `ux_<name>.js` is generated as usual, but its files/symbols are never inferred as a dependency of any *other* controller. Without the optional AST engine below (i.e. no `mini_racer`), symbol usage detection is regex-based, so a generic component name can collide with an unrelated word appearing as plain JSX text elsewhere (e.g. a `Show` component vs. a "Show More" button label in a different controller) and get wrongly inlined everywhere. Use this as a manual, always-available fallback for self-contained dirs — e.g. a page-builder tool bundling its own vendored library — that should never leak into other pages, whether or not `mini_racer` is installed. `isolated_app_dirs = ["rvb"]` isolates `ux/app/rvb/`.
146
+ - **`isolated_app_dirs`**: a controller dir's own `ux_<name>.js` is generated as usual, but its files/symbols are never inferred as a dependency of any *other* controller. When the AST engine below is unavailable (e.g. `mini_racer` failed to load), symbol usage detection is regex-based, so a generic component name can collide with an unrelated word appearing as plain JSX text elsewhere (e.g. a `Show` component vs. a "Show More" button label in a different controller) and get wrongly inlined everywhere. Use this as a manual, always-available fallback for self-contained dirs — e.g. a page-builder tool bundling its own vendored library — that should never leak into other pages, whether or not `mini_racer` is installed. `isolated_app_dirs = ["rvb"]` isolates `ux/app/rvb/`.
147
147
  - **`exclude_paths`**: excludes files whose path contains any listed segment. Not based on `application.js`.
148
148
  - **`dry_run`**: also honoured by `DRY_RUN=1` environment variable at runtime.
149
149
  - **`extensions`**: add `ts` and `tsx` to enable TypeScript source detection.
150
150
  - **`external_roots` / `external_providers`**: do not point these at files already inside shared `ux/` dirs (`components/`, `hooks/`, `lib/`, etc.). The generator now skips those overlaps to prevent duplicate declarations in browser runtime.
151
151
  - **`stdout_logging`**: `Rails.logger` always receives status lines regardless of this flag. Turn this on only if your `Rails.logger` does *not* already print to your terminal in development — if it does (common with `RAILS_LOG_TO_STDOUT`, Docker, or `bin/dev`/Foreman setups), enabling this prints every line twice.
152
- - **AST-based symbol extraction**: add `gem "mini_racer"` to your Gemfile to opt in. When present, definitions and usages are derived from a real JS/JSX AST (Acorn) instead of regex, which fixes false positives like a `Show` component colliding with unrelated text such as a "Show More" button label. Falls back to regex extraction per-file on any parse failure (always logged via `Rails.logger.warn` with file/line/column when available) — generation never breaks because of this. Without `mini_racer`, behavior is unchanged from today.
152
+ - **Component index**: `react_manifest:generate` (and dev boot/watch) also writes `ux_manifests/component_index.json`, a gitignored build artifact. `react_component` reads it instead of parsing `ux/app` with V8 on the first call in each web process. It is used only when its fingerprint (gem version, config, file list and contents) matches; otherwise the runtime parses as before.
153
+ - **AST-based symbol extraction**: `mini_racer` is a dependency of this gem. Definitions and usages are derived from a real JS/JSX AST (Acorn) instead of regex, which fixes false positives like a `Show` component colliding with unrelated text such as a "Show More" button label. Falls back to regex extraction per-file on any parse failure (always logged via `Rails.logger.warn` with file/line/column when available) — generation never breaks because of this.
153
154
 
154
155
  ## Commands
155
156
 
@@ -164,6 +165,10 @@ bundle exec rails react_manifest:generate
164
165
  DRY_RUN=1 bundle exec rails react_manifest:setup
165
166
  DRY_RUN=1 bundle exec rails react_manifest:generate
166
167
 
168
+ # CI gate: writes nothing; exits 1 and prints each "declared twice" warning to stderr
169
+ # (cross-bundle top-level const/let/class clashes, always_include double-declares)
170
+ bundle exec rails react_manifest:check
171
+
167
172
  # Analyse which shared symbols are used per controller
168
173
  bundle exec rails react_manifest:analyze
169
174
 
@@ -15,6 +15,9 @@ module ReactManifest
15
15
  function __reactManifestExtractDefinitions(content) {
16
16
  return JSON.stringify(__astExtractor.extractDefinitions(content));
17
17
  }
18
+ function __reactManifestExtractLexicalBindings(content) {
19
+ return JSON.stringify(__astExtractor.extractLexicalBindings(content));
20
+ }
18
21
  function __reactManifestExtractUsages(content) {
19
22
  return JSON.stringify(__astExtractor.extractUsages(content));
20
23
  }
@@ -31,6 +34,14 @@ module ReactManifest
31
34
  nil
32
35
  end
33
36
 
37
+ def extract_lexical_bindings(content, file_path: nil)
38
+ result = call_js("__reactManifestExtractLexicalBindings", content)
39
+ return result["bindings"] if result["success"]
40
+
41
+ report_error(file_path, result["error"])
42
+ nil
43
+ end
44
+
34
45
  def extract_usages(content, file_path: nil)
35
46
  result = call_js("__reactManifestExtractUsages", content)
36
47
  return result["usages"] if result["success"]
@@ -0,0 +1,136 @@
1
+ require "digest"
2
+ require "json"
3
+ require "set"
4
+
5
+ module ReactManifest
6
+ # The component-symbol -> bundle maps behind react_component bundle resolution.
7
+ #
8
+ # Parsing ux/app needs a V8 context (tens of MB per process), so the generator
9
+ # writes the maps to a JSON sidecar next to the generated manifests and the
10
+ # runtime loads that instead. The sidecar carries a fingerprint of everything the
11
+ # maps are derived from (gem version, config, controller dirs, file paths and
12
+ # contents); a missing, unreadable or mismatching sidecar falls back to parsing.
13
+ module ComponentIndex
14
+ FILENAME = "component_index.json".freeze
15
+
16
+ module_function
17
+
18
+ def path(config)
19
+ File.join(config.abs_manifest_dir, FILENAME)
20
+ end
21
+
22
+ # Sidecar maps when fresh, else freshly parsed ones.
23
+ def maps(config)
24
+ controller_dirs = TreeClassifier.new(config).classify.controller_dirs
25
+ load_fresh(config, controller_dirs) || build(config, controller_dirs)
26
+ end
27
+
28
+ # Write the sidecar (skipped when unchanged). Returns the path.
29
+ def write!(config, controller_dirs)
30
+ dest = path(config)
31
+ json = JSON.generate(serialize(config, controller_dirs, build(config, controller_dirs)))
32
+ return dest if File.exist?(dest) && File.read(dest, encoding: "utf-8") == json
33
+
34
+ FileUtils.mkdir_p(File.dirname(dest))
35
+ File.write(dest, json, encoding: "utf-8")
36
+ dest
37
+ end
38
+
39
+ def build(config, controller_dirs)
40
+ symbol_to_bundle = {}
41
+ bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
42
+ bundle_files = Hash.new { |h, k| h[k] = [] }
43
+ bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
44
+
45
+ controller_dirs.each do |ctrl|
46
+ bundle_name = ctrl[:bundle_name]
47
+ files = js_files_in_controller(ctrl[:path], config)
48
+ bundle_files[bundle_name].concat(files)
49
+ isolated = config.isolated_app_dirs.include?(ctrl[:name])
50
+
51
+ files.each do |file_path|
52
+ extract(file_path, :extract_definitions).each do |symbol|
53
+ next unless symbol.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
54
+
55
+ # Keep first writer to ensure deterministic behavior if a symbol is duplicated.
56
+ # Isolated dirs never register into the shared symbol index (see
57
+ # Generator#build_controller_context for the full rationale).
58
+ symbol_to_bundle[symbol] ||= bundle_name unless isolated
59
+ bundle_own_symbols[bundle_name] << symbol
60
+ end
61
+ end
62
+ end
63
+
64
+ bundle_files.each do |bundle_name, files|
65
+ own_symbols = bundle_own_symbols[bundle_name]
66
+
67
+ files.each do |file_path|
68
+ extract(file_path, :extract_usages).each do |symbol|
69
+ # A symbol the bundle defines itself is always satisfied locally —
70
+ # never attribute it to another bundle just because that symbol
71
+ # name happens to collide with something defined elsewhere.
72
+ next if own_symbols.include?(symbol)
73
+
74
+ dep_bundle = symbol_to_bundle[symbol]
75
+ next unless dep_bundle && dep_bundle != bundle_name
76
+
77
+ bundle_dependencies[bundle_name] << dep_bundle
78
+ end
79
+ end
80
+ end
81
+
82
+ { symbol_to_bundle: symbol_to_bundle, bundle_dependencies: bundle_dependencies }
83
+ end
84
+
85
+ def load_fresh(config, controller_dirs)
86
+ data = JSON.parse(File.read(path(config), encoding: "utf-8"))
87
+ return unless data["fingerprint"] == fingerprint(config, controller_dirs)
88
+
89
+ bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
90
+ data["bundle_dependencies"].each { |bundle, deps| bundle_dependencies[bundle] = deps.to_set }
91
+ { symbol_to_bundle: data["symbol_to_bundle"], bundle_dependencies: bundle_dependencies }
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def serialize(config, controller_dirs, maps)
97
+ {
98
+ "fingerprint" => fingerprint(config, controller_dirs),
99
+ "symbol_to_bundle" => maps[:symbol_to_bundle],
100
+ "bundle_dependencies" => maps[:bundle_dependencies].transform_values { |deps| deps.to_a.sort }
101
+ }
102
+ end
103
+
104
+ # Digest of every input the maps depend on. File paths are relative to ux_root so
105
+ # a relocated release dir stays valid; contents (not mtimes) so a checkout that
106
+ # rewrites timestamps cannot make a stale sidecar look fresh or a fresh one stale.
107
+ def fingerprint(config, controller_dirs)
108
+ digest = Digest::SHA256.new
109
+ digest << JSON.generate([VERSION, config.extensions, config.isolated_app_dirs])
110
+ controller_dirs.each do |ctrl|
111
+ digest << "\0" << ctrl[:bundle_name]
112
+ js_files_in_controller(ctrl[:path], config).each do |file|
113
+ digest << "\0" << file.delete_prefix(config.abs_ux_root) << "\0" << File.binread(file)
114
+ rescue SystemCallError
115
+ digest << "\0unreadable"
116
+ end
117
+ end
118
+ digest.hexdigest
119
+ end
120
+
121
+ def js_files_in_controller(dir, config)
122
+ return [] unless Dir.exist?(dir)
123
+
124
+ Dir.glob(File.join(dir, "**", config.extensions_glob))
125
+ .reject { |f| File.directory?(f) }
126
+ .sort
127
+ end
128
+
129
+ def extract(file_path, method)
130
+ content = File.read(file_path, encoding: "utf-8")
131
+ SymbolExtractor.public_send(method, content, file_path: file_path)
132
+ rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
133
+ []
134
+ end
135
+ end
136
+ end
@@ -56,14 +56,7 @@ module ReactManifest
56
56
  # in a second pass so that a failure midway does not leave some bundles
57
57
  # written and others stale/missing.
58
58
  def run!
59
- classification = @classifier.classify
60
- controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
61
-
62
- # Phase 1: build all content in memory — no I/O.
63
- shared_manifest = build_shared(classification.shared_dirs, controller_context[:promoted_files])
64
- manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
65
- build_controller(ctrl, controller_context)
66
- end
59
+ classification, manifests = build_manifests
67
60
 
68
61
  migrate_legacy_manifests!
69
62
 
@@ -76,10 +69,20 @@ module ReactManifest
76
69
  expected_filenames = manifests.map { |m| m[:filename] }
77
70
  results.concat(remove_orphaned_manifests(expected_filenames))
78
71
 
72
+ ComponentIndex.write!(@config, classification.controller_dirs) unless @config.dry_run?
73
+
79
74
  print_summary(results) if @config.verbose?
80
75
  results
81
76
  end
82
77
 
78
+ # Analyse without writing anything. Returns the "declared twice" warnings
79
+ # (cross-bundle top-level const/let/class collisions and always_include
80
+ # double-declares); empty when clean. Backs `rake react_manifest:check`.
81
+ def check
82
+ build_manifests
83
+ @clashes
84
+ end
85
+
83
86
  # Remove all AUTO-GENERATED ux_*.js manifests. Silently skips files that
84
87
  # disappear between the directory scan and the read (TOCTOU-safe).
85
88
  # Returns { removed: N, skipped: N }.
@@ -99,6 +102,8 @@ module ReactManifest
99
102
  end
100
103
  end
101
104
 
105
+ FileUtils.rm_f(ComponentIndex.path(@config))
106
+
102
107
  { removed: removed, skipped: skipped }
103
108
  end
104
109
 
@@ -207,34 +212,121 @@ module ReactManifest
207
212
  context
208
213
  end
209
214
 
210
- # Surface the one case dependency arithmetic cannot resolve: an always_include
211
- # bundle that CONSUMES a collided symbol. Its tag inlines the first-writer
212
- # definer file, which also lives in that file's own bundle manifest — so on the
213
- # definer's own page the file loads twice (once via the always_include tag,
214
- # once via the owning bundle), a duplicate declaration. We cannot auto-fix it
215
- # (there is no collision-free page-global home for a collided symbol), so we
216
- # warn with the actionable fix: rename or relocate the symbol.
215
+ # Surface the collision cases dependency arithmetic cannot resolve. A collided
216
+ # symbol (2+ app-dir definers) has no collision-free page-global home, so a file
217
+ # that defines or transitively depends on one can never be promoted to ux_shared;
218
+ # it is inlined into whichever always_include tag(s) reach it and double-declares:
219
+ #
220
+ # * multi-tag (every page): the same file is carried by 2+ always_include tags.
221
+ # Both tags load on every page, so it double-declares everywhere — whether it
222
+ # DEFINES the collided symbol or merely depends on one (a tainted file).
223
+ # * owner-page: the file is carried by a single always_include tag but also lives
224
+ # in its own controller's manifest, so it double-declares on that owner's page.
225
+ #
226
+ # We cannot auto-fix either (there is no safe shared home for a collided symbol),
227
+ # so we warn with the actionable fix: rename or relocate the symbol.
217
228
  def warn_on_always_include_collisions(context, file_owner, file_defs, collided)
218
229
  return if collided.empty?
219
230
 
220
231
  bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
221
- bundles.each do |always_bundle|
222
- always_include_foreign_files(always_bundle, context).each do |file|
223
- owner = file_owner[file]
224
- next if owner.nil? || owner == always_bundle
225
-
226
- collided_syms = (file_defs.fetch(file, Set.new) & collided).to_a.sort
227
- next if collided_syms.empty?
228
-
229
- plural = collided_syms.size > 1 ? "s" : ""
230
- log_warn "always_include bundle '#{always_bundle}' pulls in " \
231
- "'#{relative_require_path(file)}' (defines collided symbol#{plural} " \
232
- "#{collided_syms.join(', ')}) owned by '#{owner}'. On #{owner}'s own page " \
233
- "that file loads twice — once via the #{always_bundle} tag and once via " \
234
- "#{owner} — causing a duplicate declaration. Rename the symbol or move it " \
235
- "to a uniquely-named shared component."
232
+ return if bundles.empty?
233
+
234
+ tags_by_file = Hash.new { |h, k| h[k] = [] }
235
+ bundles.each { |b| always_bundle_tag_files(b, context).each { |f| tags_by_file[f] << b } }
236
+
237
+ tags_by_file.each do |file, tags|
238
+ owner = file_owner[file]
239
+ next if tags.size < 2 && (owner.nil? || tags.include?(owner))
240
+
241
+ syms = collided_symbols_reachable(file, file_defs, context, collided).to_a.sort
242
+ next if syms.empty?
243
+
244
+ what = "'#{relative_require_path(file)}' (#{collision_phrase(file, file_defs, collided, syms)})"
245
+ fix = "Rename the symbol or move it to a uniquely-named shared component."
246
+ if tags.size > 1
247
+ report_clash "always_include bundles #{tags.sort.join(', ')} all pull in #{what}. Each of those " \
248
+ "bundles loads on every page via its own <script> tag, so that file loads more " \
249
+ "than once on every page — a duplicate declaration. #{fix}"
250
+ else
251
+ report_clash "always_include bundle '#{tags.first}' pulls in #{what} owned by '#{owner}'. On " \
252
+ "#{owner}'s own page that file loads twice — once via the #{tags.first} tag and " \
253
+ "once via #{owner} — causing a duplicate declaration. #{fix}"
254
+ end
255
+ end
256
+ end
257
+
258
+ # Classic scripts share ONE global lexical scope, and under Turbolinks one window
259
+ # loads many pages' bundles. Two DIFFERENT files declaring the same top-level
260
+ # const/let/class in different bundles (a controller bundle vs another, or vs
261
+ # ux_shared) throw "Identifier already declared" and the later bundle is rejected.
262
+ # Works from the built manifests so a promoted file counts once (in ux_shared) and
263
+ # one file inlined into two bundles (the existing double-declare case) is not
264
+ # reported here: it takes two distinct declaring files across 2+ bundles.
265
+ def warn_on_cross_bundle_lexical_collisions(manifests, classification)
266
+ file_for = {}
267
+ (classification.shared_dirs + classification.controller_dirs).each do |d|
268
+ js_files_in(d[:path]).each { |f| file_for[relative_require_path(f)] = f }
269
+ end
270
+
271
+ bundles_by_name_file = Hash.new { |h, name| h[name] = Hash.new { |h2, f| h2[f] = Set.new } }
272
+ bindings = Hash.new { |h, f| h[f] = ComponentIndex.extract(f, :extract_lexical_bindings) }
273
+ manifests.each do |m|
274
+ bundle = File.basename(m[:filename], ".js")
275
+ m[:content].scan(%r{^//= require (\S+)$}) do |(req)|
276
+ file = file_for[req] or next
277
+ bindings[file].each { |name| bundles_by_name_file[name][file] << bundle }
278
+ end
279
+ end
280
+
281
+ bundles_by_name_file.sort.each do |name, files|
282
+ next if files.size < 2 || files.values.reduce(:|).size < 2
283
+
284
+ where = files.map { |f, bs| "#{relative_require_path(f)} (#{bs.to_a.sort.join(', ')})" }.join("; ")
285
+ report_clash "Top-level binding '#{name}' is declared by different files in different bundles: #{where}. " \
286
+ "Under Turbolinks these share one global scope, so loading both bundles throws " \
287
+ "\"Identifier '#{name}' has already been declared\" and rejects the second. " \
288
+ "Move it inside the component body, rename it, or move the file into a shared directory."
289
+ end
290
+ end
291
+
292
+ # "defines collided symbol X" for a direct definer, "depends on ..." for a tainted file.
293
+ def collision_phrase(file, file_defs, collided, syms)
294
+ relation = (file_defs.fetch(file, Set.new) & collided).empty? ? "depends on" : "defines"
295
+ "#{relation} collided symbol#{'s' if syms.size > 1} #{syms.join(', ')}"
296
+ end
297
+
298
+ # Every file an always_include bundle's tag delivers: its own non-promoted files
299
+ # plus the foreign dependency files it inlines. Used to detect a file carried by
300
+ # more than one tag (double-loaded on every page).
301
+ def always_bundle_tag_files(always_bundle, context)
302
+ own = context[:bundle_files].fetch(always_bundle, [])
303
+ .reject { |f| context[:promoted_files].include?(f) }
304
+ own + always_include_foreign_files(always_bundle, context).to_a
305
+ end
306
+
307
+ # Collided symbols a file defines or transitively depends on: follows file_uses →
308
+ # canonical definer file, collecting every collided symbol reached along the way.
309
+ def collided_symbols_reachable(file, file_defs, context, collided)
310
+ file_uses = context[:file_uses]
311
+ sym2file = context[:symbol_to_file]
312
+ result = Set.new
313
+ seen = Set.new
314
+ stack = [file]
315
+
316
+ until stack.empty?
317
+ current = stack.pop
318
+ next if seen.include?(current)
319
+
320
+ seen << current
321
+ result.merge(file_defs.fetch(current, Set.new) & collided)
322
+ file_uses.fetch(current, Set.new).each do |sym|
323
+ result << sym if collided.include?(sym)
324
+ dep = sym2file[sym]
325
+ stack << dep if dep && !seen.include?(dep)
236
326
  end
237
327
  end
328
+
329
+ result
238
330
  end
239
331
 
240
332
  # Files an always_include bundle's tag delivers from OTHER bundles: under
@@ -371,7 +463,7 @@ module ReactManifest
371
463
 
372
464
  files.each do |file_path|
373
465
  file_owner[file_path] = bundle_name
374
- extract_defined_symbols(file_path).each do |sym|
466
+ ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
375
467
  next unless sym.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
376
468
 
377
469
  file_defs[file_path] << sym
@@ -398,7 +490,7 @@ module ReactManifest
398
490
  def record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
399
491
  bundle_files.each do |bundle_name, files|
400
492
  files.each do |file_path|
401
- extract_used_component_symbols(file_path).each do |sym|
493
+ ComponentIndex.extract(file_path, :extract_usages).each do |sym|
402
494
  file_uses[file_path] << sym
403
495
  symbol_used_by_bundles[sym] << bundle_name
404
496
  end
@@ -407,7 +499,7 @@ module ReactManifest
407
499
 
408
500
  shared_dirs.each do |dir|
409
501
  js_files_in(dir[:path]).each do |file_path|
410
- extract_used_component_symbols(file_path).each do |sym|
502
+ ComponentIndex.extract(file_path, :extract_usages).each do |sym|
411
503
  symbol_used_by_bundles[sym] << @config.shared_bundle
412
504
  end
413
505
  end
@@ -424,7 +516,7 @@ module ReactManifest
424
516
  external_js_files_in(abs_root).each do |file_path|
425
517
  req_path = relative_require_path(file_path)
426
518
  warn_on_external_controller_references(file_path, symbol_to_bundle)
427
- extract_defined_symbols(file_path).each do |sym|
519
+ ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
428
520
  external_symbol_to_require[sym] ||= req_path
429
521
  end
430
522
  end
@@ -567,6 +659,26 @@ module ReactManifest
567
659
  end
568
660
  end
569
661
 
662
+ # Phase 1: build all content in memory — no I/O besides reading sources.
663
+ def build_manifests
664
+ @clashes = []
665
+ classification = @classifier.classify
666
+ controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
667
+
668
+ shared_manifest = build_shared(classification.shared_dirs, controller_context[:promoted_files])
669
+ manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
670
+ build_controller(ctrl, controller_context)
671
+ end
672
+
673
+ warn_on_cross_bundle_lexical_collisions(manifests, classification)
674
+ [classification, manifests]
675
+ end
676
+
677
+ def report_clash(message)
678
+ @clashes << message
679
+ log_warn message
680
+ end
681
+
570
682
  # --------------------------------------------------------------- write
571
683
 
572
684
  def write_manifest(filename, content)
@@ -689,20 +801,6 @@ module ReactManifest
689
801
  strip_asset_extension(rel)
690
802
  end
691
803
 
692
- def extract_defined_symbols(file_path)
693
- content = File.read(file_path, encoding: "utf-8")
694
- SymbolExtractor.extract_definitions(content, file_path: file_path)
695
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
696
- []
697
- end
698
-
699
- def extract_used_component_symbols(file_path)
700
- content = File.read(file_path, encoding: "utf-8")
701
- SymbolExtractor.extract_usages(content, file_path: file_path)
702
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
703
- []
704
- end
705
-
706
804
  def external_js_files_in(dir)
707
805
  return [] unless Dir.exist?(dir)
708
806
 
@@ -723,7 +821,7 @@ module ReactManifest
723
821
  end
724
822
 
725
823
  def warn_on_external_controller_references(file_path, symbol_to_bundle)
726
- extract_used_component_symbols(file_path).each do |sym|
824
+ ComponentIndex.extract(file_path, :extract_usages).each do |sym|
727
825
  dep_bundle = symbol_to_bundle[sym]
728
826
  next unless dep_bundle
729
827
 
@@ -30,6 +30,11 @@ module ReactManifest
30
30
  result.appended
31
31
  end
32
32
 
33
+ # The component-index sidecar written next to the manifests (see ComponentIndex).
34
+ def json_pattern
35
+ File.join(File.dirname(pattern), "*.json")
36
+ end
37
+
33
38
  # Path (relative to Rails.root) that should be ignored, e.g.
34
39
  # "app/assets/javascripts/ux_manifests/*.js".
35
40
  def pattern
@@ -46,13 +51,14 @@ module ReactManifest
46
51
 
47
52
  def ensure_gitignore_entry
48
53
  path = gitignore_path
49
- line = pattern
50
54
  content = File.exist?(path) ? File.read(path, encoding: "utf-8") : ""
51
- return false if content.split("\n").map(&:strip).include?(line)
55
+ lines = content.split("\n").map(&:strip)
56
+ missing = [pattern, json_pattern] - lines
57
+ return false if missing.empty?
52
58
 
53
59
  prefix = content.empty? || content.end_with?("\n") ? "" : "\n"
54
- File.open(path, "a") { |f| f.write("#{prefix}#{line}\n") }
55
- log_info "Added #{line} to .gitignore"
60
+ File.open(path, "a") { |f| f.write("#{prefix}#{missing.join("\n")}\n") }
61
+ log_info "Added #{missing.join(', ')} to .gitignore"
56
62
  true
57
63
  rescue Errno::EACCES, Errno::ENOENT => e
58
64
  log_warn "Could not update .gitignore: #{e.message}"
@@ -44,6 +44,20 @@ module ReactManifest
44
44
  regex_extract_definitions(content)
45
45
  end
46
46
 
47
+ # Top-level const/let/class names of any casing (destructured ones included).
48
+ # These are the only declarations that throw when two classic scripts share a
49
+ # global lexical scope; var and function redeclaration is legal.
50
+ def extract_lexical_bindings(content, file_path: nil)
51
+ return [] unless content
52
+
53
+ if ast_engine_available?
54
+ result = AstExtractor.extract_lexical_bindings(content, file_path: file_path)
55
+ return result if result
56
+ end
57
+
58
+ regex_extract_lexical_bindings(content)
59
+ end
60
+
47
61
  def extract_usages(content, file_path: nil)
48
62
  return [] unless content
49
63
 
@@ -75,6 +89,19 @@ module ReactManifest
75
89
  symbols.uniq
76
90
  end
77
91
 
92
+ # ponytail: column-0 declarations only, flat destructuring; the AST path is exact.
93
+ def regex_extract_lexical_bindings(content)
94
+ names = content.scan(/^(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/).flatten
95
+ content.scan(/^(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)/) { |m| names << m[0] }
96
+ content.scan(/^(?:export\s+)?(?:const|let)\s*[{\[]([^}\]]*)[}\]]\s*=[^=]/) do |m|
97
+ m[0].split(",").each do |part|
98
+ name = part.split("=").first.split(":").last.to_s.sub("...", "").strip
99
+ names << name if name.match?(/\A[A-Za-z_$][\w$]*\z/)
100
+ end
101
+ end
102
+ names.uniq
103
+ end
104
+
78
105
  def regex_extract_usages(content)
79
106
  local_syms = Set.new
80
107
  DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }