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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/exe/lilac +6 -0
- data/lib/lilac/cli/build/build_context.rb +31 -0
- data/lib/lilac/cli/build/build_error.rb +50 -0
- data/lib/lilac/cli/build/builder.rb +326 -0
- data/lib/lilac/cli/build/bundle_asset_writer.rb +129 -0
- data/lib/lilac/cli/build/bytecode_builder.rb +212 -0
- data/lib/lilac/cli/build/compiled_boot_module.rb +103 -0
- data/lib/lilac/cli/build/compiled_runtime_resolver.rb +64 -0
- data/lib/lilac/cli/build/component_name.rb +57 -0
- data/lib/lilac/cli/build/component_scripts_assembler.rb +76 -0
- data/lib/lilac/cli/build/directive.rb +51 -0
- data/lib/lilac/cli/build/full_runtime_resolver.rb +61 -0
- data/lib/lilac/cli/build/html_emitter.rb +58 -0
- data/lib/lilac/cli/build/package_stager.rb +111 -0
- data/lib/lilac/cli/build/page_compiler.rb +405 -0
- data/lib/lilac/cli/build/runtime_resolver.rb +150 -0
- data/lib/lilac/cli/build/sfc.rb +150 -0
- data/lib/lilac/cli/build/template_ast.rb +304 -0
- data/lib/lilac/cli/build/template_ast_cache.rb +58 -0
- data/lib/lilac/cli/build/vendor_writer.rb +58 -0
- data/lib/lilac/cli/build/wasm_mrbc_driver.rb +183 -0
- data/lib/lilac/cli/command.rb +110 -0
- data/lib/lilac/cli/config.rb +150 -0
- data/lib/lilac/cli/config_loader.rb +97 -0
- data/lib/lilac/cli/dev_server.rb +156 -0
- data/lib/lilac/cli/doctor.rb +310 -0
- data/lib/lilac/cli/lint/build_linter.rb +95 -0
- data/lib/lilac/cli/lint/cross_ref_linter.rb +336 -0
- data/lib/lilac/cli/lint/lint_warning.rb +53 -0
- data/lib/lilac/cli/lint/script_analyzer.rb +255 -0
- data/lib/lilac/cli/live_reload.rb +194 -0
- data/lib/lilac/cli/offline_verifier.rb +117 -0
- data/lib/lilac/cli/package_build.rb +68 -0
- data/lib/lilac/cli/package_discovery.rb +77 -0
- data/lib/lilac/cli/preview_server.rb +70 -0
- data/lib/lilac/cli/scaffold.rb +85 -0
- data/lib/lilac/cli/source_location.rb +16 -0
- data/lib/lilac/cli/subcommand/base.rb +65 -0
- data/lib/lilac/cli/subcommand/build.rb +82 -0
- data/lib/lilac/cli/subcommand/dev.rb +58 -0
- data/lib/lilac/cli/subcommand/doctor.rb +39 -0
- data/lib/lilac/cli/subcommand/new.rb +81 -0
- data/lib/lilac/cli/subcommand/option_helpers.rb +58 -0
- data/lib/lilac/cli/subcommand/package_build.rb +51 -0
- data/lib/lilac/cli/subcommand/preview.rb +50 -0
- data/lib/lilac/cli/templates/Gemfile +13 -0
- data/lib/lilac/cli/templates/README.md +125 -0
- data/lib/lilac/cli/templates/components/counter.lil +18 -0
- data/lib/lilac/cli/templates/gitignore +5 -0
- data/lib/lilac/cli/templates/lilac.config.rb +24 -0
- data/lib/lilac/cli/templates/pages/index.html +46 -0
- data/lib/lilac/cli/templates/public/.gitkeep +0 -0
- data/lib/lilac/cli/version.rb +7 -0
- data/lib/lilac/cli/watcher.rb +62 -0
- data/lib/lilac/cli.rb +20 -0
- data/lib/lilac/directives/class_parser.rb +179 -0
- data/lib/lilac/directives/collision_rules.rb +49 -0
- data/lib/lilac/directives/grammar.rb +76 -0
- data/lib/lilac/directives/lints.rb +128 -0
- data/lib/lilac/directives/value.rb +85 -0
- data/lib/lilac/directives.rb +16 -0
- 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
|