Class: Lilac::CLI::ScriptAnalyzer

Inherits:
Object
  • Object
show all
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

Classes: Result, Visitor

Constant Summary collapse

SIGNAL_FACTORIES =
%w[signal computed resource persistent_signal].freeze

Class Method Summary collapse

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_resultObject

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