Class: Lilac::CLI::ScriptAnalyzer
- Inherits:
-
Object
- Object
- Lilac::CLI::ScriptAnalyzer
- Defined in:
- lib/lilac/cli/lint/script_analyzer.rb
Overview
AST-based scanner for the user's <script type="text/ruby"> body.
Walks the prism AST to extract:
- **Signal declarations**: `@x = signal/computed/resource/
persistent_signal(...)` — name + line.
- **Method declarations**: `def name` — name + line.
- **Ivar reads**: every `@x` read (not write). Used by the
dead-code linter so a signal consumed only inside
`computed { ... }` or another method body is correctly
recognised as live.
- **Method calls**: every `name(...)` invocation including
`send(:name)` / `public_send(:name)` / `method(:name)` when
the argument is a symbol literal.
Cannot resolve genuinely dynamic dispatch (send(name) with
variable name, instance_eval blocks against external strings,
methods added via runtime include). Those manifest as
occasional false-positive dead-code warnings; users currently
have no suppression marker, so the linter trades 95% precision
for completeness.
Defined Under Namespace
Constant Summary collapse
- SIGNAL_FACTORIES =
%w[signal computed resource persistent_signal].freeze
Class Method Summary collapse
- .analyze(script_text, class_name: nil) ⇒ Object
- .collect_top_level_class_names(node, names) ⇒ Object
- .constant_path_name(node) ⇒ Object
-
.empty_result ⇒ Object
Fresh per call —
Struct.new(...).freezeonly freezes the struct, not its Hash/Array contents, so a shared constant would be mutable by any caller that called e.g. -
.extract_top_level_class_names(script_text) ⇒ Object
Returns the set of top-level class names declared at the outermost scope of
script_text. -
.find_class_body(node, target_name) ⇒ Object
DFS for a
class <name>declaration.
Class Method Details
.analyze(script_text, class_name: nil) ⇒ Object
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/lilac/cli/lint/script_analyzer.rb', line 65 def self.analyze(script_text, class_name: nil) parse = Prism.parse(script_text.to_s) # `failure?` covers hard syntax errors; on those, hand back a # fresh empty result so the linter doesn't fire spurious # "undeclared" warnings on top of the user's actual parse # error. return empty_result if parse.failure? # When the caller knows which class the directives belong to, # narrow the walk to that class's body. Page-inline scripts # carry sibling classes (e.g. `Crud` + `CrudRow`) and otherwise # the dead-signal pass would attribute every sibling's signal # to the lint target, causing spurious "declared but never # read" warnings. root_node = parse.value if class_name scoped = find_class_body(root_node, class_name.to_s) # If the class isn't found, fall back to whole-script # analysis — it's better to over-include than to silently # report empty results. root_node = scoped if scoped end visitor = Visitor.new root_node.accept(visitor) visitor.to_result end |
.collect_top_level_class_names(node, names) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/lilac/cli/lint/script_analyzer.rb', line 126 def self.collect_top_level_class_names(node, names) return unless node.respond_to?(:child_nodes) node.child_nodes.each do |child| next if child.nil? if child.is_a?(Prism::ClassNode) n = constant_path_name(child.constant_path) names << n if n # do NOT recurse: nested classes are scoped to their # parent and don't participate in top-level collision else collect_top_level_class_names(child, names) end end end |
.constant_path_name(node) ⇒ Object
141 142 143 144 |
# File 'lib/lilac/cli/lint/script_analyzer.rb', line 141 def self.constant_path_name(node) return node.name.to_s if node.is_a?(Prism::ConstantReadNode) node.slice if node.respond_to?(:slice) end |
.empty_result ⇒ Object
Fresh per call — Struct.new(...).freeze only freezes the
struct, not its Hash/Array contents, so a shared constant
would be mutable by any caller that called e.g.
result.referenced_ivars << x and silently poison every
later analyze call.
151 152 153 154 155 156 |
# File 'lib/lilac/cli/lint/script_analyzer.rb', line 151 def self.empty_result Result.new( declared_signals: {}, declared_methods: {}, referenced_ivars: [], method_calls: [], assigned_ivars: [], ) end |
.extract_top_level_class_names(script_text) ⇒ Object
Returns the set of top-level class names declared at the
outermost scope of script_text. Used by the builder's R4
guard (proposal §A.R4) to detect when a page-inline script
declares a class whose name collides with a .lil-derived
component class. On parse failure returns an empty set —
ScriptAnalyzer prefers under-reporting over double-error noise
when the user already has a syntax error.
117 118 119 120 121 122 123 124 |
# File 'lib/lilac/cli/lint/script_analyzer.rb', line 117 def self.extract_top_level_class_names(script_text) parse = Prism.parse(script_text.to_s) return [] if parse.failure? names = [] collect_top_level_class_names(parse.value, names) names end |
.find_class_body(node, target_name) ⇒ Object
DFS for a class <name> declaration. Returns the class node's
body (so the walker only visits its descendants) or nil when
absent. Stops at the first match so nested redefinitions don't
silently expand the scope.
97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/lilac/cli/lint/script_analyzer.rb', line 97 def self.find_class_body(node, target_name) return nil unless node.respond_to?(:child_nodes) node.child_nodes.each do |child| next if child.nil? if child.is_a?(Prism::ClassNode) && constant_path_name(child.constant_path) == target_name return child.body end found = find_class_body(child, target_name) return found if found end nil end |