react-manifest-rails 0.2.28 → 0.2.30

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.
@@ -1,3 +1,5 @@
1
+ require_relative "path_utils"
2
+
1
3
  module ReactManifest
2
4
  # Scans JS/JSX (and optionally TS/TSX) files using regex — no AST, no Node.js required.
3
5
  #
@@ -16,63 +18,56 @@ module ReactManifest
16
18
  # Phase 3 — emits non-fatal warnings.
17
19
  # rubocop:disable Metrics/ClassLength
18
20
  class Scanner
19
- # Patterns to detect symbol definitions (CommonJS and ES module style)
20
- DEFINITION_PATTERNS = [
21
- # CommonJS / variable-assignment style
22
- /(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/, # const FooBar =
23
- /function\s+([A-Z][A-Za-z0-9_]*)\s*\(/, # function FooBar(
24
- /class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/, # class FooBar
25
- /(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/, # const useFoo = (hooks)
26
- /function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/, # function useFoo(
27
-
28
- # ES module style (export default / named exports)
29
- /^export\s+default\s+(?:function|class)\s+([A-Z][A-Za-z0-9_]*)/,
30
- /^export\s+default\s+(?:function|class)\s+(use[A-Z][A-Za-z0-9_]*)/,
31
- /^export\s+(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/,
32
- /^export\s+(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/,
33
- /^export\s+function\s+([A-Z][A-Za-z0-9_]*)\s*\(/,
34
- /^export\s+function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/,
35
- /^export\s+class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/
36
- ].freeze
37
-
38
- # Patterns to detect usage in controller files.
39
- # Token-based patterns match any identifier occurrence regardless of syntax
40
- # context (JSX, constructor, assignment, array, function argument, etc.).
41
- PASCAL_TOKEN_PATTERN = /\b([A-Z][A-Za-z0-9_]*)\b/
42
- HOOK_TOKEN_PATTERN = /\b(use[A-Z][A-Za-z0-9_]*)\b/
43
- # Lib calls matched against known lib symbols to reduce false positives
44
- LIB_CALL_PATTERN = /\b([a-z][A-Za-z0-9_]{2,})\s*\(/
45
-
46
- # Common JS built-ins to exclude from lib-call matching
47
- JS_BUILTINS = %w[
48
- require function return typeof instanceof delete void
49
- console document window location history navigator
50
- setTimeout setInterval clearTimeout clearInterval
51
- parseInt parseFloat isNaN isFinite encodeURI decodeURI
52
- fetch Promise Object Array String Number Boolean Math JSON
53
- Object Array String Number Boolean Symbol Map Set WeakMap
54
- ].freeze
21
+ include PathUtils
22
+ include ReactManifest::Logging
23
+
24
+ DEFINITION_PATTERNS = SymbolExtractor::DEFINITION_PATTERNS
25
+ PASCAL_TOKEN_PATTERN = SymbolExtractor::PASCAL_TOKEN_PATTERN
26
+ HOOK_TOKEN_PATTERN = SymbolExtractor::HOOK_TOKEN_PATTERN
27
+ LIB_CALL_PATTERN = SymbolExtractor::LIB_CALL_PATTERN
28
+ JS_BUILTINS = SymbolExtractor::JS_BUILTINS
55
29
 
56
30
  Result = Struct.new(:symbol_index, :controller_usages, :warnings, :shared_violations,
57
31
  :external_violations, keyword_init: true)
58
32
 
33
+ class << self
34
+ def file_symbol_cache
35
+ @file_symbol_cache ||= {}
36
+ end
37
+
38
+ def clear_cache!
39
+ @file_symbol_cache = {}
40
+ end
41
+
42
+ def invalidate(file_path)
43
+ file_symbol_cache.delete(file_path)
44
+ end
45
+ end
46
+
59
47
  def initialize(config = ReactManifest.configuration)
60
48
  @config = config
61
49
  end
62
50
 
63
- # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
51
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
64
52
  def scan(classification)
65
53
  warnings = Set.new
66
54
  symbol_index = {}
67
55
  external_file_paths = {} # file_path => relative_require_path for external_roots files
68
56
 
69
- # Phase 1a: index symbols from shared dirs
70
- shared_file_paths = {} # file_path => relative_require_path for all shared files
57
+ # Phase 1a: index symbols from shared dirs; cache content for violation detection
58
+ shared_file_paths = {} # file_path => relative_require_path
59
+ shared_file_content = {} # file_path => raw content string
71
60
  classification.shared_dirs.each do |shared_dir|
72
61
  js_files_in(shared_dir[:path]).each do |file_path|
73
62
  relative = relative_require_path(file_path)
74
63
  shared_file_paths[file_path] = relative
75
- symbols = extract_definitions(file_path)
64
+ content = begin
65
+ File.read(file_path, encoding: "utf-8")
66
+ rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
67
+ nil
68
+ end
69
+ shared_file_content[file_path] = content
70
+ symbols = extract_definitions_from(file_path, content)
76
71
  symbols.each do |sym|
77
72
  if symbol_index.key?(sym)
78
73
  warnings.add("Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})")
@@ -101,31 +96,15 @@ module ReactManifest
101
96
  symbol_index[sym] = require_path
102
97
  end
103
98
 
104
- # Phase 1d: build controller (app-dir) symbol index for violation detection
105
- controller_symbol_index = {}
106
- classification.controller_dirs.each do |ctrl|
107
- js_files_in(ctrl[:path]).each do |file_path|
108
- extract_definitions(file_path).each do |sym|
109
- controller_symbol_index[sym] ||= {
110
- file: relative_require_path(file_path),
111
- controller: ctrl[:name]
112
- }
113
- end
114
- end
115
- end
99
+ log_debug "Shared symbol index: #{symbol_index.size} symbols indexed" if @config.verbose?
116
100
 
117
- $stdout.puts "[ReactManifest] Shared symbol index: #{symbol_index.size} symbols indexed" if @config.verbose?
118
-
119
- # Phase 1e: detect shared files that use app-dir (controller) symbols
120
- shared_violations = detect_shared_violations(shared_file_paths, controller_symbol_index, warnings)
121
- external_violations = detect_external_root_violations(external_file_paths, controller_symbol_index, warnings)
122
-
123
- # Phase 2: scan controller dirs for usage
101
+ # Phase 2: single pass over controller dirs build violation index AND detect usages
102
+ controller_symbol_index = {}
124
103
  controller_usages = {}
125
104
 
126
105
  classification.controller_dirs.each do |ctrl|
127
- files = js_files_in(ctrl[:path])
128
- used = Set.new
106
+ files = js_files_in(ctrl[:path])
107
+ used = Set.new
129
108
 
130
109
  warnings.add("Controller dir '#{ctrl[:name]}' has no JS/JSX files") if files.empty? && @config.verbose?
131
110
 
@@ -133,13 +112,25 @@ module ReactManifest
133
112
  content = read_controller_file(file_path, warnings)
134
113
  next unless content
135
114
 
115
+ extract_definitions_from(file_path, content).each do |sym|
116
+ controller_symbol_index[sym] ||= {
117
+ file: relative_require_path(file_path),
118
+ controller: ctrl[:name]
119
+ }
120
+ end
121
+
136
122
  used.merge(extract_used_shared_paths(content, symbol_index))
137
123
  end
138
124
 
139
125
  controller_usages[ctrl[:name]] = used.to_a.sort
140
126
  end
141
127
 
142
- # Phase 3: additional warnings
128
+ # Phase 3a: detect shared/external files that use app-dir (controller) symbols
129
+ shared_violations = detect_shared_violations(shared_file_paths, shared_file_content, controller_symbol_index,
130
+ warnings)
131
+ external_violations = detect_external_root_violations(external_file_paths, controller_symbol_index, warnings)
132
+
133
+ # Phase 3b: additional warnings
143
134
  emit_fanout_warnings(controller_usages, warnings)
144
135
 
145
136
  Result.new(
@@ -150,7 +141,7 @@ module ReactManifest
150
141
  external_violations: external_violations
151
142
  )
152
143
  end
153
- # rubocop:enable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
144
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
154
145
 
155
146
  private
156
147
 
@@ -159,22 +150,37 @@ module ReactManifest
159
150
 
160
151
  Dir.glob(File.join(dir, "**", @config.extensions_glob))
161
152
  .reject { |f| File.directory?(f) }
162
- .reject { |f| excluded_path?(f) }
153
+ .reject { |f| @config.excluded_path?(f) }
163
154
  .sort
164
155
  end
165
156
 
166
- # Returns true if the file path contains a segment matching any exclude_path.
167
- def excluded_path?(abs_path)
168
- parts = abs_path.split(File::SEPARATOR)
169
- @config.exclude_paths.any? { |ep| parts.include?(ep) }
157
+ def extract_definitions(file_path)
158
+ cache = self.class.file_symbol_cache
159
+ return cache[file_path] if cache.key?(file_path)
160
+
161
+ cache[file_path] = scan_file_definitions(file_path)
170
162
  end
171
163
 
172
- def extract_definitions(file_path)
164
+ # Like extract_definitions but uses pre-read content, populating the cache as a side-effect.
165
+ def extract_definitions_from(file_path, content)
166
+ cache = self.class.file_symbol_cache
167
+ return cache[file_path] if cache.key?(file_path)
168
+
169
+ cache[file_path] = parse_definitions(content)
170
+ end
171
+
172
+ def scan_file_definitions(file_path)
173
173
  begin
174
174
  content = File.read(file_path, encoding: "utf-8")
175
175
  rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
176
176
  return []
177
177
  end
178
+ parse_definitions(content)
179
+ end
180
+
181
+ def parse_definitions(content)
182
+ return [] unless content
183
+
178
184
  symbols = []
179
185
  DEFINITION_PATTERNS.each do |pattern|
180
186
  content.scan(pattern) { |m| symbols << m[0] }
@@ -186,35 +192,24 @@ module ReactManifest
186
192
  # Build relative to output_dir (configurable) rather than a hardcoded path.
187
193
  base = @config.abs_output_dir + File::SEPARATOR
188
194
  rel = abs_path.sub(base, "")
189
- # Strip Sprockets-understood extensions: .js.jsx/.jsx/.js -> logical path.
190
- rel.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
195
+ strip_asset_extension(rel)
191
196
  end
192
197
 
193
- def detect_shared_violations(shared_file_paths, controller_symbol_index, warnings)
198
+ def detect_shared_violations(shared_file_paths, shared_file_content, controller_symbol_index, warnings)
194
199
  violations = []
195
200
  shared_file_paths.each do |file_path, relative|
196
- content = begin
197
- File.read(file_path, encoding: "utf-8")
198
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
199
- next
200
- end
201
-
202
- local_syms = Set.new
203
- DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
204
-
205
- [PASCAL_TOKEN_PATTERN, HOOK_TOKEN_PATTERN].each do |pattern|
206
- content.scan(pattern) do |match|
207
- sym = match[0]
208
- next if local_syms.include?(sym)
209
- next unless controller_symbol_index.key?(sym)
210
-
211
- info = controller_symbol_index[sym]
212
- violations << { shared_file: relative, symbol: sym,
213
- controller: info[:controller], app_file: info[:file] }
214
- warnings.add("Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
215
- "(from ux/app/#{info[:controller]}). " \
216
- "Move '#{sym}' to a shared dir or the shared file will be incomplete.")
217
- end
201
+ content = shared_file_content[file_path]
202
+ next unless content
203
+
204
+ SymbolExtractor.extract_usages(content).each do |sym|
205
+ next unless controller_symbol_index.key?(sym)
206
+
207
+ info = controller_symbol_index[sym]
208
+ violations << { shared_file: relative, symbol: sym,
209
+ controller: info[:controller], app_file: info[:file] }
210
+ warnings.add("Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
211
+ "(from ux/app/#{info[:controller]}). " \
212
+ "Move '#{sym}' to a shared dir or the shared file will be incomplete.")
218
213
  end
219
214
  end
220
215
  violations
@@ -229,22 +224,15 @@ module ReactManifest
229
224
  next
230
225
  end
231
226
 
232
- local_syms = Set.new
233
- DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
234
-
235
- [PASCAL_TOKEN_PATTERN, HOOK_TOKEN_PATTERN].each do |pattern|
236
- content.scan(pattern) do |match|
237
- sym = match[0]
238
- next if local_syms.include?(sym)
239
- next unless controller_symbol_index.key?(sym)
240
-
241
- info = controller_symbol_index[sym]
242
- violations << { external_file: relative, symbol: sym,
243
- controller: info[:controller], app_file: info[:file] }
244
- warnings.add("External file '#{relative}' uses app-dir symbol '#{sym}' " \
245
- "(from ux/app/#{info[:controller]}). " \
246
- "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations.")
247
- end
227
+ SymbolExtractor.extract_usages(content).each do |sym|
228
+ next unless controller_symbol_index.key?(sym)
229
+
230
+ info = controller_symbol_index[sym]
231
+ violations << { external_file: relative, symbol: sym,
232
+ controller: info[:controller], app_file: info[:file] }
233
+ warnings.add("External file '#{relative}' uses app-dir symbol '#{sym}' " \
234
+ "(from ux/app/#{info[:controller]}). " \
235
+ "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations.")
248
236
  end
249
237
  end
250
238
  violations
@@ -314,26 +302,6 @@ module ReactManifest
314
302
  used
315
303
  end
316
304
 
317
- def scan_component_usage(content, pattern, symbol_index, used)
318
- content.scan(pattern) do |match|
319
- sym = match[0]
320
- next unless symbol_index.key?(sym)
321
-
322
- used << symbol_index[sym]
323
- end
324
- end
325
-
326
- def scan_array_component_usage(content, symbol_index, used)
327
- content.scan(ARRAY_COMPONENT_LIST_PATTERN) do |match|
328
- list = match[0]
329
- list.split(/\s*,\s*/).each do |sym|
330
- next unless symbol_index.key?(sym)
331
-
332
- used << symbol_index[sym]
333
- end
334
- end
335
- end
336
-
337
305
  def abs_external_root(path)
338
306
  return path if Pathname.new(path).absolute?
339
307
 
@@ -8,6 +8,8 @@ module ReactManifest
8
8
  # Usage:
9
9
  # ReactManifest::SprocketsManifestPatcher.new(config).patch!
10
10
  class SprocketsManifestPatcher
11
+ include ReactManifest::Logging
12
+
11
13
  MANIFEST_GLOB = "app/assets/config/manifest.js".freeze
12
14
 
13
15
  Result = Struct.new(:file, :status, :detail, keyword_init: true)
@@ -37,13 +39,13 @@ module ReactManifest
37
39
  new_content = append_directive(content, directive)
38
40
 
39
41
  if @config.dry_run?
40
- $stdout.puts "[ReactManifest] DRY-RUN: would patch #{short(path)}"
41
- $stdout.puts " + #{directive.strip}"
42
+ log_info "DRY-RUN: would patch #{short(path)}"
43
+ log_info " + #{directive.strip}"
42
44
  return Result.new(file: path, status: :dry_run, detail: nil)
43
45
  end
44
46
 
45
47
  File.write(path, new_content, encoding: "utf-8")
46
- $stdout.puts "[ReactManifest] Patched Sprockets manifest: #{short(path)}"
48
+ log_info "Patched Sprockets manifest: #{short(path)}"
47
49
  Result.new(file: path, status: :patched, detail: nil)
48
50
  end
49
51
 
@@ -0,0 +1,69 @@
1
+ module ReactManifest
2
+ module SymbolExtractor
3
+ DEFINITION_PATTERNS = [
4
+ /(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/,
5
+ /function\s+([A-Z][A-Za-z0-9_]*)\s*\(/,
6
+ /class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/,
7
+ /(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/,
8
+ /function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/,
9
+ /^export\s+default\s+(?:function|class)\s+([A-Z][A-Za-z0-9_]*)/,
10
+ /^export\s+default\s+(?:function|class)\s+(use[A-Z][A-Za-z0-9_]*)/,
11
+ /^export\s+(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/,
12
+ /^export\s+(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/,
13
+ /^export\s+function\s+([A-Z][A-Za-z0-9_]*)\s*\(/,
14
+ /^export\s+function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/,
15
+ /^export\s+class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/
16
+ ].freeze
17
+
18
+ PASCAL_TOKEN_PATTERN = /\b([A-Z][A-Za-z0-9_]*)\b/
19
+ HOOK_TOKEN_PATTERN = /\b(use[A-Z][A-Za-z0-9_]*)\b/
20
+ LIB_CALL_PATTERN = /\b([a-z][A-Za-z0-9_]{2,})\s*\(/
21
+
22
+ JS_BUILTINS = %w[
23
+ require function return typeof instanceof delete void
24
+ console document window location history navigator
25
+ setTimeout setInterval clearTimeout clearInterval
26
+ parseInt parseFloat isNaN isFinite encodeURI decodeURI
27
+ fetch Promise Object Array String Number Boolean Math JSON
28
+ Object Array String Number Boolean Symbol Map Set WeakMap
29
+ ].freeze
30
+
31
+ module_function
32
+
33
+ def extract_definitions(content)
34
+ return [] unless content
35
+
36
+ symbols = []
37
+ DEFINITION_PATTERNS.each do |pattern|
38
+ content.scan(pattern) { |m| symbols << m[0] }
39
+ end
40
+ symbols.uniq
41
+ end
42
+
43
+ def extract_usages(content)
44
+ return [] unless content
45
+
46
+ local_syms = Set.new
47
+ DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
48
+
49
+ used = []
50
+
51
+ content.scan(PASCAL_TOKEN_PATTERN) do |match|
52
+ sym = match[0]
53
+ used << sym unless local_syms.include?(sym)
54
+ end
55
+
56
+ content.scan(HOOK_TOKEN_PATTERN) do |match|
57
+ sym = match[0]
58
+ used << sym unless local_syms.include?(sym)
59
+ end
60
+
61
+ content.scan(LIB_CALL_PATTERN) do |match|
62
+ sym = match[0]
63
+ used << sym unless JS_BUILTINS.include?(sym) || local_syms.include?(sym)
64
+ end
65
+
66
+ used.uniq
67
+ end
68
+ end
69
+ end
@@ -5,6 +5,8 @@ module ReactManifest
5
5
  #
6
6
  # No hard-coded dir names — anything that is not app_dir is shared.
7
7
  class TreeClassifier
8
+ include ReactManifest::Logging
9
+
8
10
  Result = Struct.new(:controller_dirs, :shared_dirs, keyword_init: true)
9
11
 
10
12
  def initialize(config = ReactManifest.configuration)
@@ -16,8 +18,8 @@ module ReactManifest
16
18
  shared_dirs = []
17
19
 
18
20
  unless Dir.exist?(@config.abs_ux_root)
19
- warn "[ReactManifest] ux_root does not exist: #{@config.abs_ux_root}. " \
20
- "Create the directory and run `rails react_manifest:generate`."
21
+ log_warn "ux_root does not exist: #{@config.abs_ux_root}. " \
22
+ "Create the directory and run `rails react_manifest:generate`."
21
23
  return Result.new(controller_dirs: [], shared_dirs: [])
22
24
  end
23
25
 
@@ -40,7 +42,7 @@ module ReactManifest
40
42
  }
41
43
  end
42
44
  rescue Errno::EACCES => e
43
- warn "[ReactManifest] Permission denied reading #{full_path}: #{e.message}"
45
+ log_warn "Permission denied reading #{full_path}: #{e.message}"
44
46
  end
45
47
  else
46
48
  shared_dirs << {
@@ -50,7 +52,7 @@ module ReactManifest
50
52
  end
51
53
  end
52
54
  rescue Errno::EACCES => e
53
- warn "[ReactManifest] Permission denied reading ux_root #{@config.abs_ux_root}: #{e.message}"
55
+ log_warn "Permission denied reading ux_root #{@config.abs_ux_root}: #{e.message}"
54
56
  return Result.new(controller_dirs: [], shared_dirs: [])
55
57
  end
56
58
 
@@ -1,3 +1,3 @@
1
1
  module ReactManifest
2
- VERSION = "0.2.28".freeze
2
+ VERSION = "0.2.30".freeze
3
3
  end
@@ -7,27 +7,40 @@ module ReactManifest
7
7
  #
8
8
  # Watches ux_root recursively so newly added controller directories are
9
9
  # picked up without a server restart.
10
+ #
11
+ # File-change callbacks are debounced by the listen gem and handled on a
12
+ # background thread. Rapid back-to-back changes are coalesced: if a
13
+ # regeneration is already in progress when a new change arrives, only one
14
+ # additional regeneration is queued (not one per file event).
10
15
  module Watcher
11
16
  DEBOUNCE_SECONDS = 0.3
17
+ BRANCH_SWITCH_COOLDOWN = 3
12
18
 
13
19
  class << self
20
+ include ReactManifest::Logging
21
+
14
22
  def start(config = ReactManifest.configuration)
15
23
  begin
16
24
  require "listen"
17
25
  rescue LoadError
18
- log "listen gem not available — file watching disabled. " \
19
- "Add `gem 'listen'` to the development group in your Gemfile."
26
+ log_warn "listen gem not available — file watching disabled. " \
27
+ "Add `gem 'listen'` to the development group in your Gemfile."
20
28
  return
21
29
  end
22
30
 
23
31
  root = config.abs_ux_root
24
32
 
25
33
  unless Dir.exist?(root)
26
- log "ux_root does not exist (#{root}) — file watching disabled until directory is created."
34
+ log_warn "ux_root does not exist (#{root}) — file watching disabled until directory is created."
27
35
  return
28
36
  end
29
37
 
30
- log "Watching #{root.sub("#{Rails.root}/", '')} for changes..."
38
+ @regen_mutex = Mutex.new
39
+ @git_dir = detect_git_dir
40
+ @last_git_head = read_git_head
41
+ @branch_switch_until = nil
42
+
43
+ log_info "Watching #{root.sub("#{Rails.root}/", '')} for changes..."
31
44
 
32
45
  @listener = Listen.to(
33
46
  root,
@@ -35,8 +48,8 @@ module ReactManifest
35
48
  latency: DEBOUNCE_SECONDS
36
49
  ) do |modified, added, removed|
37
50
  changed = (modified + added + removed).map { |f| File.basename(f) }
38
- log "File change detected: #{changed.join(', ')}"
39
- regenerate!(config)
51
+ log_info "File change detected: #{changed.join(', ')}"
52
+ handle_file_changes(modified, added, removed, config)
40
53
  end
41
54
 
42
55
  @listener.start
@@ -45,31 +58,114 @@ module ReactManifest
45
58
  def stop
46
59
  @listener&.stop
47
60
  @listener = nil
61
+ @regen_thread&.join(5)
62
+ @regen_thread = nil
48
63
  end
49
64
 
50
65
  def running?
51
66
  !@listener.nil?
52
67
  end
53
68
 
69
+ # Kill the background regen thread and reset all regen state.
70
+ # Intended for use in tests only.
71
+ def reset_regen_state!
72
+ thread = @regen_thread
73
+ if thread&.alive?
74
+ thread.kill
75
+ thread.join(1)
76
+ end
77
+ @regen_thread = nil
78
+ @regen_pending = false
79
+ @regen_mutex = nil
80
+ @git_dir = nil
81
+ @last_git_head = nil
82
+ @branch_switch_until = nil
83
+ end
84
+
54
85
  private
55
86
 
56
- def regenerate!(config)
57
- Generator.new(config).run!
58
- log "Manifests regenerated"
59
- rescue StandardError => e
60
- log "Error during regeneration: #{e.message}"
61
- log e.backtrace.first(5).join("\n") if config.verbose?
87
+ def handle_file_changes(modified, added, removed, config)
88
+ if branch_switch_detected?
89
+ log_info "Branch change detected — skipping regeneration"
90
+ return
91
+ end
92
+
93
+ (modified + added + removed).each { |f| Scanner.invalidate(f) }
94
+ schedule_regeneration(config)
95
+ end
96
+
97
+ def branch_switch_detected?
98
+ return false unless @git_dir
99
+
100
+ return true if @branch_switch_until && Time.now < @branch_switch_until
101
+
102
+ current_head = read_git_head
103
+ return false unless current_head && @last_git_head
104
+
105
+ if current_head != @last_git_head
106
+ @last_git_head = current_head
107
+ @branch_switch_until = Time.now + BRANCH_SWITCH_COOLDOWN
108
+ return true
109
+ end
110
+
111
+ false
112
+ end
113
+
114
+ def detect_git_dir
115
+ git_path = File.join(Rails.root.to_s, ".git")
116
+ if File.file?(git_path)
117
+ content = File.read(git_path).strip
118
+ return content.sub("gitdir: ", "").strip if content.start_with?("gitdir: ")
119
+ elsif File.directory?(git_path)
120
+ return git_path
121
+ end
122
+ nil
123
+ rescue StandardError
124
+ nil
62
125
  end
63
126
 
64
- def log(message)
65
- msg = "[ReactManifest] #{message}"
66
- if defined?(Rails) && Rails.logger
67
- Rails.logger.info(msg)
68
- $stdout.puts(msg) if Rails.env.development? && ReactManifest.configuration.stdout_logging?
69
- else
70
- $stdout.puts msg
127
+ def read_git_head
128
+ return nil unless @git_dir
129
+
130
+ File.read(File.join(@git_dir, "HEAD")).strip
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ # Schedule a regeneration on the background thread. Coalesces rapid
136
+ # back-to-back file events: if the regen thread is already running,
137
+ # we just set @regen_pending and return immediately so the listen
138
+ # callback is never blocked.
139
+ def schedule_regeneration(config)
140
+ @regen_mutex ||= Mutex.new
141
+ mutex = @regen_mutex
142
+ mutex.synchronize do
143
+ @regen_pending = true
144
+ return if @regen_thread&.alive?
145
+
146
+ @regen_thread = Thread.new { regen_loop(config, mutex) }
71
147
  end
72
148
  end
149
+
150
+ # Background thread: regenerate, then check whether another change
151
+ # arrived while we were busy. If so, regenerate again; otherwise exit.
152
+ def regen_loop(config, mutex)
153
+ loop do
154
+ mutex.synchronize { @regen_pending = false }
155
+ regenerate!(config)
156
+ still_pending = mutex.synchronize { @regen_pending }
157
+ break unless still_pending
158
+ end
159
+ end
160
+
161
+ def regenerate!(config)
162
+ results = Generator.new(config).run!
163
+ written = results.count { |r| r[:status] == :written }
164
+ log_info "#{written} manifest(s) written" if written.positive?
165
+ rescue StandardError => e
166
+ log_warn "Error during regeneration: #{e.message}"
167
+ log_debug e.backtrace.first(5).join("\n") if config.verbose?
168
+ end
73
169
  end
74
170
  end
75
171
  end