react-manifest-rails 0.2.34 → 0.3.1

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.
@@ -42,20 +42,21 @@ module ReactManifest
42
42
  @classifier = TreeClassifier.new(config)
43
43
  end
44
44
 
45
+ # After a run (or on demand), maps a promoted file's require path to the set of
46
+ # bundle names that forced its promotion. Used by react_manifest:analyze.
47
+ def promotion_reasons
48
+ classification = @classifier.classify
49
+ build_controller_context(classification.controller_dirs, classification.shared_dirs)
50
+ @promotion_reasons || {}
51
+ end
52
+
45
53
  # Run full generation. Returns array of {path:, status:} hashes.
46
54
  #
47
55
  # All manifest content is built first (no filesystem writes), then written
48
56
  # in a second pass so that a failure midway does not leave some bundles
49
57
  # written and others stale/missing.
50
58
  def run!
51
- classification = @classifier.classify
52
- controller_context = build_controller_context(classification.controller_dirs)
53
-
54
- # Phase 1: build all content in memory — no I/O.
55
- shared_manifest = build_shared(classification.shared_dirs)
56
- manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
57
- build_controller(ctrl, controller_context)
58
- end
59
+ classification, manifests = build_manifests
59
60
 
60
61
  migrate_legacy_manifests!
61
62
 
@@ -68,10 +69,20 @@ module ReactManifest
68
69
  expected_filenames = manifests.map { |m| m[:filename] }
69
70
  results.concat(remove_orphaned_manifests(expected_filenames))
70
71
 
72
+ ComponentIndex.write!(@config, classification.controller_dirs) unless @config.dry_run?
73
+
71
74
  print_summary(results) if @config.verbose?
72
75
  results
73
76
  end
74
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
+
75
86
  # Remove all AUTO-GENERATED ux_*.js manifests. Silently skips files that
76
87
  # disappear between the directory scan and the read (TOCTOU-safe).
77
88
  # Returns { removed: N, skipped: N }.
@@ -91,6 +102,8 @@ module ReactManifest
91
102
  end
92
103
  end
93
104
 
105
+ FileUtils.rm_f(ComponentIndex.path(@config))
106
+
94
107
  { removed: removed, skipped: skipped }
95
108
  end
96
109
 
@@ -98,10 +111,9 @@ module ReactManifest
98
111
 
99
112
  # ------------------------------------------------------------------ shared
100
113
 
101
- def build_shared(shared_dirs)
114
+ def build_shared(shared_dirs, promoted_files = Set.new)
102
115
  lines = header_lines
103
- reqs = shared_dirs
104
- .flat_map { |d| js_files_in(d[:path]) }
116
+ reqs = (shared_dirs.flat_map { |d| js_files_in(d[:path]) } + promoted_files.to_a)
105
117
  .map { |f| normalize_require_path(relative_require_path(f)) }
106
118
  .uniq
107
119
  .sort
@@ -119,13 +131,21 @@ module ReactManifest
119
131
 
120
132
  def build_controller(ctrl, controller_context)
121
133
  lines = header_lines
122
- always_include_reqs = controller_context[:always_include_requires].fetch(ctrl[:bundle_name], [])
123
- dep_requires = controller_dependency_requires(ctrl[:bundle_name], controller_context)
124
- ext_reqs = controller_context[:external_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
125
-
126
- files = js_files_in(ctrl[:path])
134
+ promoted = controller_context[:promoted_files]
135
+ bundle_name = ctrl[:bundle_name]
136
+ # always_include bundles are NOT inlined here: resolve_bundles /
137
+ # react_component deliver them on every page via their own <script> tag,
138
+ # so inlining would load the same file twice and double-declare.
139
+ dep_requires = if @config.auto_shared?
140
+ promoted_aware_dependency_requires(bundle_name, controller_context)
141
+ else
142
+ controller_dependency_requires(bundle_name, controller_context)
143
+ end
144
+ ext_reqs = controller_context[:external_requires].fetch(bundle_name, Set.new).to_a.sort
145
+
146
+ files = js_files_in(ctrl[:path]).reject { |f| promoted.include?(f) }
127
147
  own_requires = files.map { |f| relative_require_path(f) }
128
- all_requires = (always_include_reqs + dep_requires + ext_reqs + own_requires).uniq
148
+ all_requires = (dep_requires + ext_reqs + own_requires).uniq
129
149
 
130
150
  if all_requires.empty?
131
151
  lines << "// (no JSX files found in #{ctrl[:name]}/)"
@@ -136,66 +156,390 @@ module ReactManifest
136
156
  { filename: "#{ctrl[:bundle_name]}.js", content: "#{lines.join("\n")}\n" }
137
157
  end
138
158
 
139
- def build_controller_context(controller_dirs)
159
+ def build_controller_context(controller_dirs, shared_dirs)
160
+ bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
161
+ symbol_definers, bundle_own_symbols = index_controller_symbols(controller_dirs)
162
+
163
+ file_uses = Hash.new { |h, k| h[k] = Set.new }
164
+ symbol_used_by_bundles = Hash.new { |h, k| h[k] = Set.new }
165
+ record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
166
+
167
+ collided = symbol_definers.each_with_object(Set.new) do |(sym, files), acc|
168
+ acc << sym if files.size >= 2
169
+ end
170
+
171
+ external_symbol_to_require = index_external_symbols(symbol_to_bundle)
172
+
173
+ dependencies, external_requires = build_dependency_graph(
174
+ bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle, external_symbol_to_require
175
+ )
176
+
177
+ symbol_index = {
178
+ file_defs: file_defs,
179
+ symbol_to_file: symbol_to_file,
180
+ symbol_used_by_bundles: symbol_used_by_bundles
181
+ }
182
+ promoted_files =
183
+ if @config.auto_shared?
184
+ compute_promoted_files(file_owner, file_uses, symbol_index, collided)
185
+ else
186
+ Set.new
187
+ end
188
+
189
+ context = {
190
+ bundle_files: bundle_files,
191
+ dependencies: dependencies,
192
+ external_requires: external_requires,
193
+ promoted_files: promoted_files,
194
+ file_uses: file_uses,
195
+ symbol_to_file: symbol_to_file,
196
+ bundle_own_symbols: bundle_own_symbols
197
+ }
198
+
199
+ # Bundles guaranteed loaded on every page by always_include (used to exclude
200
+ # them from the legacy whole-bundle inline path and to self-exclude an
201
+ # always_include bundle's own manifest from the skip below).
202
+ context[:always_loaded_bundles] = compute_always_loaded_bundles(dependencies)
203
+ # Symbols actually delivered on every page by the always_include <script>
204
+ # tag(s) — derived from the real files those tags carry, NOT from whole dep
205
+ # bundles (which would over-claim symbols the tag does not load and cause a
206
+ # consumer to skip a definer it still needs). Unused by the legacy path.
207
+ context[:always_provided_symbols] =
208
+ @config.auto_shared? ? compute_always_provided_symbols(context, file_defs) : Set.new
209
+
210
+ warn_on_always_include_collisions(context, file_owner, file_defs, collided)
211
+
212
+ context
213
+ end
214
+
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.
228
+ def warn_on_always_include_collisions(context, file_owner, file_defs, collided)
229
+ return if collided.empty?
230
+
231
+ bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
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)
326
+ end
327
+ end
328
+
329
+ result
330
+ end
331
+
332
+ # Files an always_include bundle's tag delivers from OTHER bundles: under
333
+ # auto_shared, the exact non-promoted dependency files it inlines; in legacy
334
+ # mode, every file of the dependency bundles it inlines wholesale.
335
+ def always_include_foreign_files(always_bundle, context)
336
+ if @config.auto_shared?
337
+ promoted_aware_dependency_files(always_bundle, context, Set.new)
338
+ else
339
+ transitive_dependencies(always_bundle, context[:dependencies])
340
+ .flat_map { |dep| context[:bundle_files].fetch(dep, []) }
341
+ end
342
+ end
343
+
344
+ # Symbols the always_include tag(s) put on every page: for each always_include
345
+ # bundle, the symbols defined by its own non-promoted files plus the canonical
346
+ # dependency files it inlines (its manifest's exact file closure).
347
+ def compute_always_provided_symbols(context, file_defs)
348
+ bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
349
+ return Set.new if bundles.empty?
350
+
351
+ always_files = Set.new
352
+ bundles.each do |b|
353
+ context[:bundle_files].fetch(b, []).each do |file|
354
+ always_files << file unless context[:promoted_files].include?(file)
355
+ end
356
+ promoted_aware_dependency_files(b, context, Set.new).each { |file| always_files << file }
357
+ end
358
+
359
+ always_files.each_with_object(Set.new) { |file, syms| syms.merge(file_defs.fetch(file, Set.new)) }
360
+ end
361
+
362
+ # Files that can never be promoted to ux_shared: any file defining a "collided"
363
+ # symbol (a name with 2+ app-dir definers — promoting one copy into the always-
364
+ # loaded shared bundle would double-declare against the other copy on its page),
365
+ # plus any file that transitively depends on such a file (it can't live in shared
366
+ # if a dependency can't). These fall back to legacy per-consumer inlining.
367
+ def unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
368
+ unpromotable = Set.new
369
+ file_owner.each_key do |file_path|
370
+ unpromotable << file_path if file_defs[file_path].any? { |s| collided.include?(s) }
371
+ end
372
+
373
+ loop do
374
+ added = false
375
+ file_owner.each_key do |file_path|
376
+ next if unpromotable.include?(file_path)
377
+
378
+ taints = file_uses.fetch(file_path, Set.new).any? do |sym|
379
+ dep = symbol_to_file[sym]
380
+ dep && unpromotable.include?(dep)
381
+ end
382
+ if taints
383
+ unpromotable << file_path
384
+ added = true
385
+ end
386
+ end
387
+ break unless added
388
+ end
389
+
390
+ unpromotable
391
+ end
392
+
393
+ # A controller file is promoted to the shared bundle when a symbol it canonically
394
+ # defines is used by a bundle other than its owner, then transitively for anything
395
+ # a promoted file depends on. Collided/tainted files (see unpromotable_files) are
396
+ # never promoted. Records why each file was promoted for react_manifest:analyze.
397
+ #
398
+ # symbol_index bundles {file_defs:, symbol_to_file:, symbol_used_by_bundles:} into
399
+ # one param so this stays under Metrics/ParameterLists (rubocop flags 6 positional
400
+ # args) without changing the underlying algorithm.
401
+ def compute_promoted_files(file_owner, file_uses, symbol_index, collided)
402
+ file_defs = symbol_index[:file_defs]
403
+ symbol_to_file = symbol_index[:symbol_to_file]
404
+ symbol_used_by_bundles = symbol_index[:symbol_used_by_bundles]
405
+ unpromotable = unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
406
+ promoted = Set.new
407
+ reasons = Hash.new { |h, k| h[k] = Set.new }
408
+
409
+ file_owner.each do |file_path, owner|
410
+ next if unpromotable.include?(file_path)
411
+
412
+ forcing = Set.new
413
+ file_defs[file_path].each do |sym|
414
+ # canonical-definer guard: only the file that canonically defines the
415
+ # symbol is eligible (resolves collisions to one file; excludes isolated
416
+ # dirs, which are never registered in symbol_to_file).
417
+ next unless symbol_to_file[sym] == file_path
418
+
419
+ (symbol_used_by_bundles[sym] - [owner]).each { |b| forcing << b }
420
+ end
421
+ next if forcing.empty?
422
+
423
+ promoted << file_path
424
+ reasons[relative_require_path(file_path)] = forcing
425
+ end
426
+
427
+ worklist = promoted.to_a
428
+ until worklist.empty?
429
+ current = worklist.pop
430
+ file_uses.fetch(current, Set.new).each do |sym|
431
+ dep_file = symbol_to_file[sym]
432
+ next unless dep_file
433
+ next if unpromotable.include?(dep_file)
434
+ next if promoted.include?(dep_file)
435
+
436
+ promoted << dep_file
437
+ reasons[relative_require_path(dep_file)] << "(transitive)"
438
+ worklist << dep_file
439
+ end
440
+ end
441
+
442
+ @promotion_reasons = reasons.transform_values(&:to_a)
443
+ promoted
444
+ end
445
+
446
+ # Indexes each controller dir's files and the PascalCase symbols they define.
447
+ # Returns [bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
448
+ # symbol_definers, bundle_own_symbols].
449
+ def index_controller_symbols(controller_dirs)
140
450
  bundle_files = {}
451
+ file_owner = {}
452
+ file_defs = Hash.new { |h, k| h[k] = Set.new }
141
453
  symbol_to_bundle = {}
454
+ symbol_to_file = {}
455
+ symbol_definers = Hash.new { |h, k| h[k] = Set.new }
142
456
  bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
143
- external_symbol_to_require = {}
144
- dependencies = Hash.new { |h, k| h[k] = Set.new }
145
- external_requires = Hash.new { |h, k| h[k] = Set.new }
146
457
 
147
458
  controller_dirs.each do |ctrl|
148
- # Index controller-defined symbols for cross-app detection
149
459
  bundle_name = ctrl[:bundle_name]
150
460
  files = js_files_in(ctrl[:path])
151
461
  bundle_files[bundle_name] = files
152
-
153
462
  isolated = @config.isolated_app_dirs.include?(ctrl[:name])
154
463
 
155
464
  files.each do |file_path|
156
- extract_defined_symbols(file_path).each do |sym|
465
+ file_owner[file_path] = bundle_name
466
+ ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
157
467
  next unless sym.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
158
468
 
159
- # Isolated dirs never register into the shared symbol index, so
160
- # no other bundle can ever be inferred to depend on them —
161
- # regardless of what their own symbol names happen to collide
162
- # with (regex-based usage detection can't tell a real component
163
- # reference from an unrelated word appearing as plain JSX text).
164
- symbol_to_bundle[sym] ||= bundle_name unless isolated
469
+ file_defs[file_path] << sym
470
+ # Isolated dirs never register into the shared symbol index, so no
471
+ # other bundle can ever be inferred to depend on them.
472
+ unless isolated
473
+ symbol_to_bundle[sym] ||= bundle_name
474
+ symbol_to_file[sym] ||= file_path
475
+ symbol_definers[sym] << file_path
476
+ end
165
477
  bundle_own_symbols[bundle_name] << sym
166
478
  end
167
479
  end
168
480
  end
169
481
 
170
- # Index symbols from external_roots dirs
482
+ [bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
483
+ symbol_definers, bundle_own_symbols]
484
+ end
485
+
486
+ # Records, per controller file and per bundle, which symbols are used —
487
+ # plus a pseudo-bundle entry (config.shared_bundle) for any controller
488
+ # symbol referenced from a shared-dir file (shared code loads on every
489
+ # page, so that symbol's owning file must be treated as externally used).
490
+ def record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
491
+ bundle_files.each do |bundle_name, files|
492
+ files.each do |file_path|
493
+ ComponentIndex.extract(file_path, :extract_usages).each do |sym|
494
+ file_uses[file_path] << sym
495
+ symbol_used_by_bundles[sym] << bundle_name
496
+ end
497
+ end
498
+ end
499
+
500
+ shared_dirs.each do |dir|
501
+ js_files_in(dir[:path]).each do |file_path|
502
+ ComponentIndex.extract(file_path, :extract_usages).each do |sym|
503
+ symbol_used_by_bundles[sym] << @config.shared_bundle
504
+ end
505
+ end
506
+ end
507
+ end
508
+
509
+ # Indexes symbols from external_roots dirs, then lets explicit
510
+ # external_providers win over scanned roots on symbol conflicts.
511
+ def index_external_symbols(symbol_to_bundle)
512
+ external_symbol_to_require = {}
513
+
171
514
  @config.external_roots.each do |root_path|
172
515
  abs_root = abs_external_root(root_path)
173
516
  external_js_files_in(abs_root).each do |file_path|
174
517
  req_path = relative_require_path(file_path)
175
-
176
518
  warn_on_external_controller_references(file_path, symbol_to_bundle)
177
-
178
- extract_defined_symbols(file_path).each do |sym|
519
+ ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
179
520
  external_symbol_to_require[sym] ||= req_path
180
521
  end
181
522
  end
182
523
  end
183
524
 
184
- # Explicit external_providers win over scanned roots on symbol conflicts
185
525
  @config.external_providers.each do |sym, req_path|
186
526
  external_symbol_to_require[sym] = req_path
187
527
  end
188
528
 
189
- # Compute per-bundle cross-app and external dependencies
529
+ external_symbol_to_require
530
+ end
531
+
532
+ # Cross-app dependency graph + external requires (dependencies kept for
533
+ # reporting). Returns [dependencies, external_requires].
534
+ def build_dependency_graph(bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle,
535
+ external_symbol_to_require)
536
+ dependencies = Hash.new { |h, k| h[k] = Set.new }
537
+ external_requires = Hash.new { |h, k| h[k] = Set.new }
538
+
190
539
  bundle_files.each do |bundle_name, files|
191
540
  own_symbols = bundle_own_symbols[bundle_name]
192
-
193
541
  files.each do |file_path|
194
- extract_used_component_symbols(file_path).each do |sym|
195
- # A symbol the bundle defines itself (in any of its own files) is
196
- # always satisfied locally — never attribute it to another bundle
197
- # or an external provider just because that symbol name happens
198
- # to collide with something defined elsewhere.
542
+ file_uses[file_path].each do |sym|
199
543
  next if own_symbols.include?(sym)
200
544
 
201
545
  dep_bundle = symbol_to_bundle[sym]
@@ -207,24 +551,82 @@ module ReactManifest
207
551
  end
208
552
  end
209
553
 
210
- always_include_requires = build_always_include_requires(bundle_files, dependencies)
554
+ [dependencies, external_requires]
555
+ end
211
556
 
212
- {
213
- bundle_files: bundle_files,
214
- dependencies: dependencies,
215
- always_include_requires: always_include_requires,
216
- external_requires: external_requires
217
- }
557
+ # Under auto_shared, single-definer cross-used files live in ux_shared, so a
558
+ # controller only needs the canonical files for the symbols it uses that are NOT
559
+ # promoted (collided names kept bundle-local, plus their transitive needs).
560
+ # File-level + transitive so we never over-inline a whole dependency bundle
561
+ # (which could double-declare against an always_include bundle's own copy).
562
+ def promoted_aware_dependency_requires(bundle_name, controller_context)
563
+ # Symbols already on every page via an always_include tag. Skipped so a
564
+ # controller never inlines a (possibly colliding) second definer. Disabled
565
+ # when building an always_include bundle's own manifest (it needs its deps).
566
+ always_provided = always_provided_for(bundle_name, controller_context)
567
+ promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
568
+ .map { |f| relative_require_path(f) }
569
+ .uniq
570
+ .sort
571
+ end
572
+
573
+ # Absolute paths of the non-promoted files a bundle must inline: for each
574
+ # symbol it uses (transitively) that is neither its own, promoted to shared,
575
+ # nor +always_provided+ on the page, the canonical (first-writer) definer file.
576
+ def promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
577
+ own = controller_context[:bundle_own_symbols].fetch(bundle_name, Set.new)
578
+ promoted = controller_context[:promoted_files]
579
+ file_uses = controller_context[:file_uses]
580
+ sym2file = controller_context[:symbol_to_file]
581
+ own_files = controller_context[:bundle_files].fetch(bundle_name, [])
582
+
583
+ needed = Set.new
584
+ seen = Set.new
585
+ worklist = own_files.flat_map { |f| file_uses.fetch(f, Set.new).to_a }
586
+
587
+ until worklist.empty?
588
+ sym = worklist.pop
589
+ next if seen.include?(sym)
590
+
591
+ seen << sym
592
+ next if own.include?(sym)
593
+ next if always_provided.include?(sym)
594
+
595
+ dep_file = sym2file[sym]
596
+ next unless dep_file
597
+ next if promoted.include?(dep_file) # already served by ux_shared
598
+ next if own_files.include?(dep_file) # own file, added via own_requires
599
+ next if needed.include?(dep_file)
600
+
601
+ needed << dep_file
602
+ file_uses.fetch(dep_file, Set.new).each { |s| worklist << s unless seen.include?(s) }
603
+ end
604
+
605
+ needed
218
606
  end
219
607
 
220
608
  def controller_dependency_requires(bundle_name, controller_context)
221
609
  deps = transitive_dependencies(bundle_name, controller_context[:dependencies])
610
+ # Drop always_include bundles (and their transitive deps): they load on
611
+ # every page via their own <script> tag, so inlining them here would
612
+ # double-declare. Kept when the current bundle IS an always_include bundle.
613
+ always = controller_context[:always_loaded_bundles]
614
+ deps -= always.to_a unless always.include?(bundle_name)
222
615
  deps.flat_map { |dep_bundle| controller_context[:bundle_files].fetch(dep_bundle, []) }
223
616
  .map { |abs_path| relative_require_path(abs_path) }
224
617
  .uniq
225
618
  .sort
226
619
  end
227
620
 
621
+ # Symbols delivered on every page by an always_include tag, for +bundle_name+.
622
+ # Empty when building an always_include bundle's own manifest so it still
623
+ # inlines the dependencies it needs.
624
+ def always_provided_for(bundle_name, controller_context)
625
+ return Set.new if controller_context[:always_loaded_bundles].include?(bundle_name)
626
+
627
+ controller_context[:always_provided_symbols]
628
+ end
629
+
228
630
  def transitive_dependencies(bundle_name, dependency_map)
229
631
  ordered = []
230
632
  visiting = Set.new
@@ -245,30 +647,36 @@ module ReactManifest
245
647
  ordered
246
648
  end
247
649
 
248
- def build_always_include_requires(bundle_files, dependencies)
650
+ # The set of bundles guaranteed loaded on every page by always_include: each
651
+ # configured always_include bundle plus its transitive dependencies. These
652
+ # arrive via their own <script> tag (resolve_bundles / react_component), so
653
+ # controller manifests must never inline their files or symbols.
654
+ def compute_always_loaded_bundles(dependencies)
249
655
  bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
250
- return Hash.new { |h, k| h[k] = [] } if bundles.empty?
251
-
252
- requires_by_bundle = Hash.new { |h, k| h[k] = [] }
253
-
254
- bundle_files.each_key do |bundle_name|
255
- requires = Set.new
256
-
257
- bundles.each do |always_bundle|
258
- next if always_bundle == bundle_name
656
+ bundles.each_with_object(Set.new) do |always_bundle, acc|
657
+ acc << always_bundle
658
+ transitive_dependencies(always_bundle, dependencies).each { |dep| acc << dep }
659
+ end
660
+ end
259
661
 
260
- transitive = [always_bundle] + transitive_dependencies(always_bundle, dependencies)
261
- transitive.each do |dep_bundle|
262
- bundle_files.fetch(dep_bundle, []).each do |abs_path|
263
- requires << relative_require_path(abs_path)
264
- end
265
- end
266
- end
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)
267
667
 
268
- requires_by_bundle[bundle_name] = requires.to_a.sort
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)
269
671
  end
270
672
 
271
- requires_by_bundle
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
272
680
  end
273
681
 
274
682
  # --------------------------------------------------------------- write
@@ -393,20 +801,6 @@ module ReactManifest
393
801
  strip_asset_extension(rel)
394
802
  end
395
803
 
396
- def extract_defined_symbols(file_path)
397
- content = File.read(file_path, encoding: "utf-8")
398
- SymbolExtractor.extract_definitions(content, file_path: file_path)
399
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
400
- []
401
- end
402
-
403
- def extract_used_component_symbols(file_path)
404
- content = File.read(file_path, encoding: "utf-8")
405
- SymbolExtractor.extract_usages(content, file_path: file_path)
406
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
407
- []
408
- end
409
-
410
804
  def external_js_files_in(dir)
411
805
  return [] unless Dir.exist?(dir)
412
806
 
@@ -427,7 +821,7 @@ module ReactManifest
427
821
  end
428
822
 
429
823
  def warn_on_external_controller_references(file_path, symbol_to_bundle)
430
- extract_used_component_symbols(file_path).each do |sym|
824
+ ComponentIndex.extract(file_path, :extract_usages).each do |sym|
431
825
  dep_bundle = symbol_to_bundle[sym]
432
826
  next unless dep_bundle
433
827