react-manifest-rails 0.2.28 → 0.2.29
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 +4 -4
- data/lib/react_manifest/application_analyzer.rb +11 -9
- data/lib/react_manifest/application_migrator.rb +19 -10
- data/lib/react_manifest/configuration.rb +9 -0
- data/lib/react_manifest/dependency_map.rb +12 -10
- data/lib/react_manifest/generator.rb +44 -120
- data/lib/react_manifest/layout_patcher.rb +14 -5
- data/lib/react_manifest/logging.rb +31 -0
- data/lib/react_manifest/path_utils.rb +11 -0
- data/lib/react_manifest/reporter.rb +12 -10
- data/lib/react_manifest/scanner.rb +98 -130
- data/lib/react_manifest/sprockets_manifest_patcher.rb +5 -3
- data/lib/react_manifest/symbol_extractor.rb +69 -0
- data/lib/react_manifest/tree_classifier.rb +6 -4
- data/lib/react_manifest/version.rb +1 -1
- data/lib/react_manifest/watcher.rb +65 -19
- data/lib/react_manifest.rb +16 -22
- data/tasks/react_manifest.rake +3 -20
- metadata +30 -27
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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 = {}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
used
|
|
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
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -7,27 +7,36 @@ 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
|
|
12
17
|
|
|
13
18
|
class << self
|
|
19
|
+
include ReactManifest::Logging
|
|
20
|
+
|
|
14
21
|
def start(config = ReactManifest.configuration)
|
|
15
22
|
begin
|
|
16
23
|
require "listen"
|
|
17
24
|
rescue LoadError
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
log_warn "listen gem not available — file watching disabled. " \
|
|
26
|
+
"Add `gem 'listen'` to the development group in your Gemfile."
|
|
20
27
|
return
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
root = config.abs_ux_root
|
|
24
31
|
|
|
25
32
|
unless Dir.exist?(root)
|
|
26
|
-
|
|
33
|
+
log_warn "ux_root does not exist (#{root}) — file watching disabled until directory is created."
|
|
27
34
|
return
|
|
28
35
|
end
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
@regen_mutex = Mutex.new
|
|
38
|
+
|
|
39
|
+
log_info "Watching #{root.sub("#{Rails.root}/", '')} for changes..."
|
|
31
40
|
|
|
32
41
|
@listener = Listen.to(
|
|
33
42
|
root,
|
|
@@ -35,8 +44,8 @@ module ReactManifest
|
|
|
35
44
|
latency: DEBOUNCE_SECONDS
|
|
36
45
|
) do |modified, added, removed|
|
|
37
46
|
changed = (modified + added + removed).map { |f| File.basename(f) }
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
log_info "File change detected: #{changed.join(', ')}"
|
|
48
|
+
handle_file_changes(modified, added, removed, config)
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
@listener.start
|
|
@@ -45,31 +54,68 @@ module ReactManifest
|
|
|
45
54
|
def stop
|
|
46
55
|
@listener&.stop
|
|
47
56
|
@listener = nil
|
|
57
|
+
@regen_thread&.join(5)
|
|
58
|
+
@regen_thread = nil
|
|
48
59
|
end
|
|
49
60
|
|
|
50
61
|
def running?
|
|
51
62
|
!@listener.nil?
|
|
52
63
|
end
|
|
53
64
|
|
|
65
|
+
# Kill the background regen thread and reset all regen state.
|
|
66
|
+
# Intended for use in tests only.
|
|
67
|
+
def reset_regen_state!
|
|
68
|
+
thread = @regen_thread
|
|
69
|
+
if thread&.alive?
|
|
70
|
+
thread.kill
|
|
71
|
+
thread.join(1)
|
|
72
|
+
end
|
|
73
|
+
@regen_thread = nil
|
|
74
|
+
@regen_pending = false
|
|
75
|
+
@regen_mutex = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
54
78
|
private
|
|
55
79
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
rescue StandardError => e
|
|
60
|
-
log "Error during regeneration: #{e.message}"
|
|
61
|
-
log e.backtrace.first(5).join("\n") if config.verbose?
|
|
80
|
+
def handle_file_changes(modified, added, removed, config)
|
|
81
|
+
(modified + added + removed).each { |f| Scanner.invalidate(f) }
|
|
82
|
+
schedule_regeneration(config)
|
|
62
83
|
end
|
|
63
84
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
85
|
+
# Schedule a regeneration on the background thread. Coalesces rapid
|
|
86
|
+
# back-to-back file events: if the regen thread is already running,
|
|
87
|
+
# we just set @regen_pending and return immediately so the listen
|
|
88
|
+
# callback is never blocked.
|
|
89
|
+
def schedule_regeneration(config)
|
|
90
|
+
@regen_mutex ||= Mutex.new
|
|
91
|
+
mutex = @regen_mutex
|
|
92
|
+
mutex.synchronize do
|
|
93
|
+
@regen_pending = true
|
|
94
|
+
return if @regen_thread&.alive?
|
|
95
|
+
|
|
96
|
+
@regen_thread = Thread.new { regen_loop(config, mutex) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Background thread: regenerate, then check whether another change
|
|
101
|
+
# arrived while we were busy. If so, regenerate again; otherwise exit.
|
|
102
|
+
def regen_loop(config, mutex)
|
|
103
|
+
loop do
|
|
104
|
+
mutex.synchronize { @regen_pending = false }
|
|
105
|
+
regenerate!(config)
|
|
106
|
+
still_pending = mutex.synchronize { @regen_pending }
|
|
107
|
+
break unless still_pending
|
|
71
108
|
end
|
|
72
109
|
end
|
|
110
|
+
|
|
111
|
+
def regenerate!(config)
|
|
112
|
+
results = Generator.new(config).run!
|
|
113
|
+
written = results.count { |r| r[:status] == :written }
|
|
114
|
+
log_info "#{written} manifest(s) written" if written.positive?
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
log_warn "Error during regeneration: #{e.message}"
|
|
117
|
+
log_debug e.backtrace.first(5).join("\n") if config.verbose?
|
|
118
|
+
end
|
|
73
119
|
end
|
|
74
120
|
end
|
|
75
121
|
end
|