lilac-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +144 -0
  4. data/exe/lilac +6 -0
  5. data/lib/lilac/cli/build/build_context.rb +31 -0
  6. data/lib/lilac/cli/build/build_error.rb +50 -0
  7. data/lib/lilac/cli/build/builder.rb +326 -0
  8. data/lib/lilac/cli/build/bundle_asset_writer.rb +129 -0
  9. data/lib/lilac/cli/build/bytecode_builder.rb +212 -0
  10. data/lib/lilac/cli/build/compiled_boot_module.rb +103 -0
  11. data/lib/lilac/cli/build/compiled_runtime_resolver.rb +64 -0
  12. data/lib/lilac/cli/build/component_name.rb +57 -0
  13. data/lib/lilac/cli/build/component_scripts_assembler.rb +76 -0
  14. data/lib/lilac/cli/build/directive.rb +51 -0
  15. data/lib/lilac/cli/build/full_runtime_resolver.rb +61 -0
  16. data/lib/lilac/cli/build/html_emitter.rb +58 -0
  17. data/lib/lilac/cli/build/package_stager.rb +111 -0
  18. data/lib/lilac/cli/build/page_compiler.rb +405 -0
  19. data/lib/lilac/cli/build/runtime_resolver.rb +150 -0
  20. data/lib/lilac/cli/build/sfc.rb +150 -0
  21. data/lib/lilac/cli/build/template_ast.rb +304 -0
  22. data/lib/lilac/cli/build/template_ast_cache.rb +58 -0
  23. data/lib/lilac/cli/build/vendor_writer.rb +58 -0
  24. data/lib/lilac/cli/build/wasm_mrbc_driver.rb +183 -0
  25. data/lib/lilac/cli/command.rb +110 -0
  26. data/lib/lilac/cli/config.rb +150 -0
  27. data/lib/lilac/cli/config_loader.rb +97 -0
  28. data/lib/lilac/cli/dev_server.rb +156 -0
  29. data/lib/lilac/cli/doctor.rb +310 -0
  30. data/lib/lilac/cli/lint/build_linter.rb +95 -0
  31. data/lib/lilac/cli/lint/cross_ref_linter.rb +336 -0
  32. data/lib/lilac/cli/lint/lint_warning.rb +53 -0
  33. data/lib/lilac/cli/lint/script_analyzer.rb +255 -0
  34. data/lib/lilac/cli/live_reload.rb +194 -0
  35. data/lib/lilac/cli/offline_verifier.rb +117 -0
  36. data/lib/lilac/cli/package_build.rb +68 -0
  37. data/lib/lilac/cli/package_discovery.rb +77 -0
  38. data/lib/lilac/cli/preview_server.rb +70 -0
  39. data/lib/lilac/cli/scaffold.rb +85 -0
  40. data/lib/lilac/cli/source_location.rb +16 -0
  41. data/lib/lilac/cli/subcommand/base.rb +65 -0
  42. data/lib/lilac/cli/subcommand/build.rb +82 -0
  43. data/lib/lilac/cli/subcommand/dev.rb +58 -0
  44. data/lib/lilac/cli/subcommand/doctor.rb +39 -0
  45. data/lib/lilac/cli/subcommand/new.rb +81 -0
  46. data/lib/lilac/cli/subcommand/option_helpers.rb +58 -0
  47. data/lib/lilac/cli/subcommand/package_build.rb +51 -0
  48. data/lib/lilac/cli/subcommand/preview.rb +50 -0
  49. data/lib/lilac/cli/templates/Gemfile +13 -0
  50. data/lib/lilac/cli/templates/README.md +125 -0
  51. data/lib/lilac/cli/templates/components/counter.lil +18 -0
  52. data/lib/lilac/cli/templates/gitignore +5 -0
  53. data/lib/lilac/cli/templates/lilac.config.rb +24 -0
  54. data/lib/lilac/cli/templates/pages/index.html +46 -0
  55. data/lib/lilac/cli/templates/public/.gitkeep +0 -0
  56. data/lib/lilac/cli/version.rb +7 -0
  57. data/lib/lilac/cli/watcher.rb +62 -0
  58. data/lib/lilac/cli.rb +20 -0
  59. data/lib/lilac/directives/class_parser.rb +179 -0
  60. data/lib/lilac/directives/collision_rules.rb +49 -0
  61. data/lib/lilac/directives/grammar.rb +76 -0
  62. data/lib/lilac/directives/lints.rb +128 -0
  63. data/lib/lilac/directives/value.rb +85 -0
  64. data/lib/lilac/directives.rb +16 -0
  65. metadata +159 -0
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "script_analyzer"
4
+ require_relative "lint_warning"
5
+ require_relative "../source_location"
6
+ require_relative "../../directives" # Lilac::Directives::ClassParser
7
+
8
+ module Lilac
9
+ module CLI
10
+ # Build-time cross-reference linter. Compares identifiers used in
11
+ # template directives (ivars in `data-text`, methods in
12
+ # `data-on-X`, etc.) against the declarations extracted from
13
+ # `<script type="text/ruby">` by ScriptAnalyzer (AST-based).
14
+ # Undeclared references emit a diagnostic to stderr (or any
15
+ # caller-provided IO).
16
+ #
17
+ # Pure scan: never raises. Returns a `Result(warnings:, errors:)`
18
+ # struct — the caller fails the build when `errors > 0`. All
19
+ # current checks are warning-level; `errors` is retained for the
20
+ # Result contract so future fatal checks can fail the build
21
+ # without reshaping the API.
22
+ #
23
+ # Class-shaped (instead of a module of `self.lint_*` methods) so
24
+ # the per-call state — analysis, directives, refs_map, component
25
+ # name, file, IO — lives in ivars rather than being threaded
26
+ # through every helper as 5 positional arguments.
27
+ class CrossRefLinter
28
+ # Tunable: maximum Levenshtein distance for the "Did you mean?"
29
+ # suggestion to be offered. 2 keeps it useful for typos without
30
+ # surfacing unrelated names.
31
+ SUGGESTION_MAX_DISTANCE = 2
32
+
33
+ # Aggregate counts returned from `lint`. `errors` tracks fatal
34
+ # violations (build should fail); `warnings` tracks non-fatal
35
+ # ones. `total` is the "any diagnostic count" sum used by callers
36
+ # that don't need to distinguish severity.
37
+ Result = Struct.new(:warnings, :errors, keyword_init: true) do
38
+ def total
39
+ warnings + errors
40
+ end
41
+
42
+ def errors?
43
+ errors.positive?
44
+ end
45
+ end
46
+
47
+ # Methods the framework calls without an explicit `def name` →
48
+ # `data-on-X` wiring; never flag them as dead even if no
49
+ # template directive references them.
50
+ LIFECYCLE_METHODS = %w[
51
+ setup initialize bind_template_hook prepare_setup
52
+ ].freeze
53
+
54
+ # `data-ref` names that collide with Object / Kernel methods —
55
+ # `refs.X` then dispatches to the Ruby method instead of
56
+ # `Refs#method_missing`, producing a confusing NoMethodError or
57
+ # silent wrong behaviour.
58
+ RESERVED_REF_NAMES = %w[
59
+ p puts print pp format sprintf printf
60
+ gets getc
61
+ raise throw catch fail exit abort
62
+ lambda proc method methods
63
+ caller inspect class send public_send tap then itself
64
+ nil? frozen? is_a? kind_of? respond_to? object_id hash eql? to_s freeze
65
+ ].freeze
66
+
67
+ # Public entry. Keeps the kwargs-only call shape from before the
68
+ # class refactor so existing callers (PageCompiler's assembler,
69
+ # tests) are unchanged.
70
+ def self.lint(script_text:, directives:, component_name:, file:, refs_map: {}, out: $stderr)
71
+ new(
72
+ script_text: script_text, directives: directives,
73
+ component_name: component_name, file: file,
74
+ refs_map: refs_map, out: out
75
+ ).run
76
+ end
77
+
78
+ def initialize(script_text:, directives:, component_name:, file:, refs_map: {}, out: $stderr)
79
+ # Narrow the AST walk to the named class so a multi-class
80
+ # script (e.g. a page-inline page with sibling components, or
81
+ # a `.lil` that declares helper classes alongside the
82
+ # component) doesn't bleed sibling-class declarations into
83
+ # this component's dead-signal / dead-method checks.
84
+ @analysis = ScriptAnalyzer.analyze(script_text, class_name: component_name)
85
+ @directives = directives
86
+ @component_name = component_name
87
+ @file = file
88
+ @refs_map = refs_map
89
+ @out = out
90
+ end
91
+
92
+ def run
93
+ warnings = 0
94
+ warnings += lint_undeclared_signals
95
+ warnings += lint_undefined_methods
96
+ warnings += lint_each_without_key
97
+ warnings += lint_reserved_ref_names
98
+ warnings += lint_dead_signals
99
+ warnings += lint_dead_methods
100
+ Result.new(warnings: warnings, errors: 0)
101
+ end
102
+
103
+ private
104
+
105
+ def lint_undeclared_signals
106
+ warnings = 0
107
+ @directives.each do |directive|
108
+ ivars_in_directive(directive).each do |ivar|
109
+ next if @analysis.declares_signal?(ivar)
110
+ # Soft fallback: any `@x = ...` somewhere in the script
111
+ # could be a helper-style signal init the static check
112
+ # can't see — suppress the warning rather than blame the
113
+ # user for a pattern Ruby allows.
114
+ next if @analysis.assigns_ivar?(ivar)
115
+
116
+ emit_signal_warning(directive, ivar)
117
+ warnings += 1
118
+ end
119
+ end
120
+ warnings
121
+ end
122
+
123
+ def lint_undefined_methods
124
+ warnings = 0
125
+ @directives.each do |directive|
126
+ method = method_in_directive(directive)
127
+ next unless method
128
+ next if @analysis.declares_method?(method)
129
+
130
+ emit_method_warning(directive, method)
131
+ warnings += 1
132
+ end
133
+ warnings
134
+ end
135
+
136
+ def lint_each_without_key
137
+ keyed = @directives.select { |d| d.kind == :key }.map(&:ref_id)
138
+ warnings = 0
139
+ @directives.each do |d|
140
+ next unless d.kind == :each
141
+ next if keyed.include?(d.ref_id)
142
+
143
+ emit_each_without_key_warning(d)
144
+ warnings += 1
145
+ end
146
+ warnings
147
+ end
148
+
149
+ def lint_reserved_ref_names
150
+ warnings = 0
151
+ @refs_map.each do |name, line|
152
+ next unless RESERVED_REF_NAMES.include?(name)
153
+
154
+ emit_reserved_ref_warning(name, line)
155
+ warnings += 1
156
+ end
157
+ warnings
158
+ end
159
+
160
+ # A signal is dead if it's declared but neither read anywhere in
161
+ # the script (e.g. inside `computed { @x.value }`, another
162
+ # method body, etc.) NOR referenced by any template directive.
163
+ def lint_dead_signals
164
+ template_refs = collect_referenced_ivars
165
+ warnings = 0
166
+ @analysis.declared_signals.each do |ivar, line|
167
+ next if @analysis.references_ivar?(ivar)
168
+ next if template_refs.include?(ivar)
169
+
170
+ emit_dead_signal_warning(ivar, line)
171
+ warnings += 1
172
+ end
173
+ warnings
174
+ end
175
+
176
+ # A method is dead if it's declared but neither called in the
177
+ # script (including `send(:name)`, `method(:name)`, helper
178
+ # delegation) NOR referenced by any `data-on-X` directive, and
179
+ # it isn't a framework-called lifecycle method.
180
+ def lint_dead_methods
181
+ template_refs = collect_referenced_methods
182
+ warnings = 0
183
+ @analysis.declared_methods.each do |method, line|
184
+ next if LIFECYCLE_METHODS.include?(method)
185
+ next if @analysis.calls_method?(method)
186
+ next if template_refs.include?(method)
187
+
188
+ emit_dead_method_warning(method, line)
189
+ warnings += 1
190
+ end
191
+ warnings
192
+ end
193
+
194
+ # All ivars referenced by a single directive. For most kinds the
195
+ # directive value is itself the ivar (or a bare ident which we
196
+ # skip). For data-class, multiple ivars hide inside the hash.
197
+ def ivars_in_directive(directive)
198
+ case directive.kind
199
+ when :text, :unsafe_html, :show, :hide, :each, :attr, :css
200
+ value = directive.value.to_s.strip
201
+ value.start_with?("@") ? [value] : []
202
+ when :class_
203
+ pairs =
204
+ begin
205
+ Lilac::Directives::ClassParser.parse(directive.value)
206
+ rescue Lilac::Directives::ClassParser::Error
207
+ # The runtime scanner surfaces the parse error at mount;
208
+ # we silently skip lint here so the user sees one clear
209
+ # message instead of two competing ones.
210
+ []
211
+ end
212
+ pairs.map { |_, v| v.strip }.select { |v| v.start_with?("@") }
213
+ else
214
+ []
215
+ end
216
+ end
217
+
218
+ def method_in_directive(directive)
219
+ return nil unless directive.kind == :on
220
+
221
+ directive.value.to_s.strip
222
+ end
223
+
224
+ def collect_referenced_ivars
225
+ @directives.flat_map { |d| ivars_in_directive(d) }.uniq
226
+ end
227
+
228
+ def collect_referenced_methods
229
+ @directives.filter_map { |d| method_in_directive(d) }.uniq
230
+ end
231
+
232
+ def emit_signal_warning(directive, ivar)
233
+ declared = @analysis.declared_signals.keys
234
+ emit(LintWarning.new(
235
+ at: directive.source_location(@file),
236
+ body: "Signal #{ivar} is not declared via signal/computed/resource/persistent_signal " \
237
+ "in #{@component_name}. Possible typo or dynamic declaration.",
238
+ declared_label: "Declared signals", declared: declared,
239
+ suggestion: suggest(ivar, declared),
240
+ ))
241
+ end
242
+
243
+ def emit_method_warning(directive, method)
244
+ declared = @analysis.declared_methods.keys
245
+ emit(LintWarning.new(
246
+ at: directive.source_location(@file),
247
+ body: "Method `#{method}` (referenced by data-on-#{directive.name}) is not defined " \
248
+ "in #{@component_name}. Possible typo or external delegation.",
249
+ declared_label: "Declared methods", declared: declared,
250
+ suggestion: suggest(method, declared),
251
+ ))
252
+ end
253
+
254
+ def emit_each_without_key_warning(directive)
255
+ emit(LintWarning.new(
256
+ at: directive.source_location(@file),
257
+ body: "data-each without data-key falls back to object_id, which causes unstable " \
258
+ "re-renders when the list is rebuilt from raw data.",
259
+ suggestion: "Add `data-key=\"id\"` (or another stable field) on the same element.",
260
+ ))
261
+ end
262
+
263
+ def emit_reserved_ref_warning(name, line)
264
+ emit(LintWarning.new(
265
+ at: SourceLocation.new(file: @file, line: line),
266
+ body: "data-ref #{name.inspect} collides with a Ruby Kernel/Object method. " \
267
+ "`refs.#{name}` will dispatch to the built-in method instead of the ref lookup.",
268
+ suggestion: "Rename the ref (e.g. `#{name}_el`) or access it via `refs[:#{name}]`.",
269
+ ))
270
+ end
271
+
272
+ def emit_dead_signal_warning(ivar, line)
273
+ emit(LintWarning.new(
274
+ at: SourceLocation.new(file: @file, line: line),
275
+ body: "Signal #{ivar} is declared in #{@component_name} but never read — " \
276
+ "no `#{ivar}` reference in the script and no template directive uses it.",
277
+ suggestion: "Remove the declaration if it's unused, or wire it into a directive.",
278
+ ))
279
+ end
280
+
281
+ def emit_dead_method_warning(method, line)
282
+ emit(LintWarning.new(
283
+ at: SourceLocation.new(file: @file, line: line),
284
+ body: "Method `#{method}` is defined in #{@component_name} but never called — " \
285
+ "no call in the script and no `data-on-X` directive references it.",
286
+ suggestion: "Remove if unused, or wire it via `data-on-<event>=\"#{method}\"`.",
287
+ ))
288
+ end
289
+
290
+ # Writes the warning followed by a blank line so consecutive
291
+ # warnings stay visually separated in stderr output.
292
+ def emit(warning)
293
+ @out.puts(warning.to_s)
294
+ @out.puts
295
+ end
296
+
297
+ # Closest declared name within SUGGESTION_MAX_DISTANCE, or nil.
298
+ # Wrapped as "Did you mean: X?" by the caller; returns just the
299
+ # candidate name (or nil) so the call sites read symmetrically.
300
+ def suggest(needle, declared)
301
+ candidate = nearest(needle, declared)
302
+ candidate && "Did you mean: #{candidate}?"
303
+ end
304
+
305
+ def nearest(needle, haystack)
306
+ return nil if haystack.empty?
307
+
308
+ candidate, dist = haystack.map { |c| [c, levenshtein(needle, c)] }.min_by { |_, d| d }
309
+ dist <= SUGGESTION_MAX_DISTANCE ? candidate : nil
310
+ end
311
+
312
+ # Standard Levenshtein edit distance — insertions, deletions,
313
+ # substitutions. Damerau's transposition adds noise for the
314
+ # typo set we actually see (off-by-one letter swaps), so the
315
+ # plainer metric is enough.
316
+ def levenshtein(a, b)
317
+ return b.length if a.empty?
318
+ return a.length if b.empty?
319
+
320
+ m = a.length
321
+ n = b.length
322
+ dp = (0..n).to_a
323
+ (1..m).each do |i|
324
+ prev = dp[0]
325
+ dp[0] = i
326
+ (1..n).each do |j|
327
+ tmp = dp[j]
328
+ dp[j] = a[i - 1] == b[j - 1] ? prev : [prev, dp[j], dp[j - 1]].min + 1
329
+ prev = tmp
330
+ end
331
+ end
332
+ dp[n]
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lilac
4
+ module CLI
5
+ # Value object for a single cross-reference lint warning. Centralises
6
+ # the multi-line format so all current and future warning kinds
7
+ # (signal-not-declared, method-not-defined, future dead-signal, ...)
8
+ # render identically:
9
+ #
10
+ # lilac: lint warning in <file>:<line>
11
+ # <body>
12
+ # <declared_label>: a, b, c.
13
+ # Did you mean: <suggestion>?
14
+ #
15
+ # `declared` and `suggestion` are optional — when empty/nil the
16
+ # corresponding lines are omitted so single-fact warnings stay
17
+ # compact.
18
+ #
19
+ # `suggestion` is rendered verbatim as an indented bullet — callers
20
+ # add their own framing (`"Did you mean: ..."` / `"Use ..."` /
21
+ # etc.) so the same warning shape carries both typo hints and
22
+ # corrective advice.
23
+ class LintWarning
24
+ # `severity:` is `:warning` (default — non-fatal, build continues)
25
+ # or `:error` (fatal — build fails with non-zero exit code).
26
+ # Errors are reserved for violations that runtime would `raise`
27
+ # (so the build/runtime severity stays aligned).
28
+ def initialize(at:, body:, declared_label: nil, declared: [], suggestion: nil, severity: :warning)
29
+ @at = at
30
+ @body = body
31
+ @declared_label = declared_label
32
+ @declared = declared
33
+ @suggestion = suggestion
34
+ @severity = severity
35
+ end
36
+
37
+ attr_reader :severity
38
+
39
+ def error?
40
+ @severity == :error
41
+ end
42
+
43
+ def to_s
44
+ label = error? ? "lint error" : "lint warning"
45
+ parts = ["lilac: #{label} in #{@at}"]
46
+ @body.to_s.each_line { |l| parts << " #{l.chomp}" }
47
+ parts << " #{@declared_label}: #{@declared.join(', ')}." if @declared_label && !@declared.empty?
48
+ parts << " #{@suggestion}" if @suggestion
49
+ parts.join("\n")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Lilac
6
+ module CLI
7
+ # AST-based scanner for the user's `<script type="text/ruby">` body.
8
+ # Walks the prism AST to extract:
9
+ #
10
+ # - **Signal declarations**: `@x = signal/computed/resource/
11
+ # persistent_signal(...)` — name + line.
12
+ # - **Method declarations**: `def name` — name + line.
13
+ # - **Ivar reads**: every `@x` read (not write). Used by the
14
+ # dead-code linter so a signal consumed only inside
15
+ # `computed { ... }` or another method body is correctly
16
+ # recognised as live.
17
+ # - **Method calls**: every `name(...)` invocation including
18
+ # `send(:name)` / `public_send(:name)` / `method(:name)` when
19
+ # the argument is a symbol literal.
20
+ #
21
+ # Cannot resolve genuinely dynamic dispatch (`send(name)` with
22
+ # variable name, `instance_eval` blocks against external strings,
23
+ # methods added via runtime `include`). Those manifest as
24
+ # occasional false-positive dead-code warnings; users currently
25
+ # have no suppression marker, so the linter trades 95% precision
26
+ # for completeness.
27
+ class ScriptAnalyzer
28
+ SIGNAL_FACTORIES = %w[signal computed resource persistent_signal].freeze
29
+
30
+ Result = Struct.new(
31
+ :declared_signals,
32
+ :declared_methods,
33
+ :referenced_ivars,
34
+ :method_calls,
35
+ :assigned_ivars,
36
+ keyword_init: true,
37
+ ) do
38
+ def declares_signal?(name)
39
+ declared_signals.key?(name)
40
+ end
41
+
42
+ def declares_method?(name)
43
+ declared_methods.key?(name)
44
+ end
45
+
46
+ def references_ivar?(name)
47
+ referenced_ivars.include?(name)
48
+ end
49
+
50
+ def calls_method?(name)
51
+ method_calls.include?(name)
52
+ end
53
+
54
+ # Soft fallback for the linter's "signal not declared" warning.
55
+ # `@x = make_counter` doesn't put `@x` in `declared_signals`
56
+ # (the RHS isn't a recognised signal factory), but the user
57
+ # has plausibly initialised it via a helper. AST inter-
58
+ # procedural analysis would be needed to be sure, so we
59
+ # silence the warning when any assignment to the ivar exists.
60
+ def assigns_ivar?(name)
61
+ assigned_ivars.include?(name)
62
+ end
63
+ end
64
+
65
+ def self.analyze(script_text, class_name: nil)
66
+ parse = Prism.parse(script_text.to_s)
67
+ # `failure?` covers hard syntax errors; on those, hand back a
68
+ # fresh empty result so the linter doesn't fire spurious
69
+ # "undeclared" warnings on top of the user's actual parse
70
+ # error.
71
+ return empty_result if parse.failure?
72
+
73
+ # When the caller knows which class the directives belong to,
74
+ # narrow the walk to that class's body. Page-inline scripts
75
+ # carry sibling classes (e.g. `Crud` + `CrudRow`) and otherwise
76
+ # the dead-signal pass would attribute every sibling's signal
77
+ # to the lint target, causing spurious "declared but never
78
+ # read" warnings.
79
+ root_node = parse.value
80
+ if class_name
81
+ scoped = find_class_body(root_node, class_name.to_s)
82
+ # If the class isn't found, fall back to whole-script
83
+ # analysis — it's better to over-include than to silently
84
+ # report empty results.
85
+ root_node = scoped if scoped
86
+ end
87
+
88
+ visitor = Visitor.new
89
+ root_node.accept(visitor)
90
+ visitor.to_result
91
+ end
92
+
93
+ # DFS for a `class <name>` declaration. Returns the class node's
94
+ # body (so the walker only visits its descendants) or nil when
95
+ # absent. Stops at the first match so nested redefinitions don't
96
+ # silently expand the scope.
97
+ def self.find_class_body(node, target_name)
98
+ return nil unless node.respond_to?(:child_nodes)
99
+ node.child_nodes.each do |child|
100
+ next if child.nil?
101
+ if child.is_a?(Prism::ClassNode) && constant_path_name(child.constant_path) == target_name
102
+ return child.body
103
+ end
104
+ found = find_class_body(child, target_name)
105
+ return found if found
106
+ end
107
+ nil
108
+ end
109
+
110
+ # Returns the set of top-level class names declared at the
111
+ # outermost scope of `script_text`. Used by the builder's R4
112
+ # guard (proposal §A.R4) to detect when a page-inline script
113
+ # declares a class whose name collides with a `.lil`-derived
114
+ # component class. On parse failure returns an empty set —
115
+ # ScriptAnalyzer prefers under-reporting over double-error noise
116
+ # when the user already has a syntax error.
117
+ def self.extract_top_level_class_names(script_text)
118
+ parse = Prism.parse(script_text.to_s)
119
+ return [] if parse.failure?
120
+
121
+ names = []
122
+ collect_top_level_class_names(parse.value, names)
123
+ names
124
+ end
125
+
126
+ def self.collect_top_level_class_names(node, names)
127
+ return unless node.respond_to?(:child_nodes)
128
+ node.child_nodes.each do |child|
129
+ next if child.nil?
130
+ if child.is_a?(Prism::ClassNode)
131
+ n = constant_path_name(child.constant_path)
132
+ names << n if n
133
+ # do NOT recurse: nested classes are scoped to their
134
+ # parent and don't participate in top-level collision
135
+ else
136
+ collect_top_level_class_names(child, names)
137
+ end
138
+ end
139
+ end
140
+
141
+ def self.constant_path_name(node)
142
+ return node.name.to_s if node.is_a?(Prism::ConstantReadNode)
143
+ node.slice if node.respond_to?(:slice)
144
+ end
145
+
146
+ # Fresh per call — `Struct.new(...).freeze` only freezes the
147
+ # struct, not its Hash/Array contents, so a shared constant
148
+ # would be mutable by any caller that called e.g.
149
+ # `result.referenced_ivars << x` and silently poison every
150
+ # later analyze call.
151
+ def self.empty_result
152
+ Result.new(
153
+ declared_signals: {}, declared_methods: {},
154
+ referenced_ivars: [], method_calls: [], assigned_ivars: [],
155
+ )
156
+ end
157
+
158
+ class Visitor < Prism::Visitor
159
+ def initialize
160
+ super
161
+ @declared_signals = {}
162
+ @declared_methods = {}
163
+ @referenced_ivars = []
164
+ @assigned_ivars = []
165
+ @method_calls = []
166
+ end
167
+
168
+ def to_result
169
+ Result.new(
170
+ declared_signals: @declared_signals,
171
+ declared_methods: @declared_methods,
172
+ referenced_ivars: @referenced_ivars.uniq,
173
+ method_calls: @method_calls.uniq,
174
+ assigned_ivars: @assigned_ivars.uniq,
175
+ )
176
+ end
177
+
178
+ def visit_def_node(node)
179
+ @declared_methods[node.name.to_s] ||= node.location.start_line
180
+ super
181
+ end
182
+
183
+ def visit_instance_variable_write_node(node)
184
+ name = node.name.to_s
185
+ @assigned_ivars << name
186
+ if signal_factory_call?(node.value)
187
+ @declared_signals[name] ||= node.location.start_line
188
+ end
189
+ super
190
+ end
191
+
192
+ # `@x ||= signal(...)` — operator-write counts as declaration
193
+ # for the same reason as plain `=`. Prism splits this into
194
+ # three node kinds depending on the operator: `+=` etc. use
195
+ # `InstanceVariableOperatorWriteNode`, `||=` uses
196
+ # `InstanceVariableOrWriteNode`, `&&=` uses
197
+ # `InstanceVariableAndWriteNode`.
198
+ def visit_instance_variable_operator_write_node(node)
199
+ record_ivar_op_write(node)
200
+ super
201
+ end
202
+
203
+ def visit_instance_variable_or_write_node(node)
204
+ record_ivar_op_write(node)
205
+ super
206
+ end
207
+
208
+ def visit_instance_variable_and_write_node(node)
209
+ record_ivar_op_write(node)
210
+ super
211
+ end
212
+
213
+ def visit_instance_variable_read_node(node)
214
+ @referenced_ivars << node.name.to_s
215
+ super
216
+ end
217
+
218
+ def visit_call_node(node)
219
+ @method_calls << node.name.to_s
220
+ record_metaprogramming_target(node)
221
+ super
222
+ end
223
+
224
+ private
225
+
226
+ # `send(:foo)` / `public_send(:foo)` / `method(:foo)` with a
227
+ # symbol-literal argument: record the named method as called
228
+ # so dead-method lint doesn't flag a method that's only
229
+ # reached via metaprogramming. User-defined `def method`
230
+ # would shadow `Object#method` here — a minor ambiguity we
231
+ # accept (component code rarely defines a `def method`).
232
+ METAPROGRAMMING_DISPATCH = %w[send public_send method].freeze
233
+
234
+ def record_metaprogramming_target(node)
235
+ return unless METAPROGRAMMING_DISPATCH.include?(node.name.to_s)
236
+
237
+ first = node.arguments&.arguments&.first
238
+ @method_calls << first.unescaped if first.is_a?(Prism::SymbolNode)
239
+ end
240
+
241
+ def record_ivar_op_write(node)
242
+ name = node.name.to_s
243
+ @assigned_ivars << name
244
+ @declared_signals[name] ||= node.location.start_line if signal_factory_call?(node.value)
245
+ end
246
+
247
+ def signal_factory_call?(node)
248
+ return false unless node.is_a?(Prism::CallNode)
249
+
250
+ SIGNAL_FACTORIES.include?(node.name.to_s)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end