keela 0.0.2 → 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 +4 -4
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +346 -0
- data/exe/keela +174 -0
- data/lib/keela/baseline.rb +82 -0
- data/lib/keela/config_file.rb +79 -0
- data/lib/keela/configuration.rb +44 -0
- data/lib/keela/reporter.rb +84 -0
- data/lib/keela/scanner.rb +167 -0
- data/lib/keela/strategies/attributes.rb +44 -0
- data/lib/keela/strategies/constants.rb +53 -0
- data/lib/keela/strategies/delegations.rb +70 -0
- data/lib/keela/strategies/i18n_keys.rb +88 -0
- data/lib/keela/strategies/methods.rb +35 -0
- data/lib/keela/strategies/scopes.rb +29 -0
- data/lib/keela/strategy.rb +43 -0
- data/lib/keela/version.rb +5 -0
- data/lib/keela.rb +32 -1
- metadata +69 -5
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
# Loads configuration from a YAML file.
|
|
7
|
+
#
|
|
8
|
+
# Looks for config files in this order:
|
|
9
|
+
# 1. keela.yml
|
|
10
|
+
# 2. .keela.yml
|
|
11
|
+
#
|
|
12
|
+
# Supported keys:
|
|
13
|
+
# - extensions: Array of file extensions to scan
|
|
14
|
+
# - directory_patterns: Array of glob patterns for directories to scan
|
|
15
|
+
# - exclude_patterns: Array of glob patterns for files to exclude
|
|
16
|
+
# - excluded_path: Path to YAML file of excluded items
|
|
17
|
+
# - baseline_path: Path to baseline YAML file
|
|
18
|
+
# - required_directory: Directory that must exist for scanning to proceed
|
|
19
|
+
#
|
|
20
|
+
# Example:
|
|
21
|
+
# # keela.yml
|
|
22
|
+
# directory_patterns:
|
|
23
|
+
# - "app/**/*.%<ext>s"
|
|
24
|
+
# - "lib/**/*.%<ext>s"
|
|
25
|
+
# - "ee/app/**/*.%<ext>s"
|
|
26
|
+
# - "ee/lib/**/*.%<ext>s"
|
|
27
|
+
# extensions:
|
|
28
|
+
# - rb
|
|
29
|
+
# - haml
|
|
30
|
+
# - erb
|
|
31
|
+
#
|
|
32
|
+
module ConfigFile
|
|
33
|
+
CONFIG_FILENAMES = %w[keela.yml .keela.yml].freeze
|
|
34
|
+
|
|
35
|
+
ALLOWED_KEYS = %w[
|
|
36
|
+
extensions
|
|
37
|
+
directory_patterns
|
|
38
|
+
include_patterns
|
|
39
|
+
exclude_patterns
|
|
40
|
+
excluded_path
|
|
41
|
+
baseline_path
|
|
42
|
+
required_directory
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Load configuration from a YAML file.
|
|
47
|
+
#
|
|
48
|
+
# @param path [String, nil] Optional path to config file. If nil, searches
|
|
49
|
+
# for keela.yml or .keela.yml in the current directory.
|
|
50
|
+
# @return [Boolean] true if a config file was loaded, false otherwise
|
|
51
|
+
#
|
|
52
|
+
def load(path: nil)
|
|
53
|
+
config_path = path || find_config_file
|
|
54
|
+
return false unless config_path && File.exist?(config_path)
|
|
55
|
+
|
|
56
|
+
config = YAML.load_file(config_path) || {}
|
|
57
|
+
apply_config(config)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def find_config_file
|
|
64
|
+
CONFIG_FILENAMES.find { |filename| File.exist?(filename) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_config(config)
|
|
68
|
+
configuration = Keela.configuration
|
|
69
|
+
|
|
70
|
+
ALLOWED_KEYS.each do |key|
|
|
71
|
+
next unless config.key?(key)
|
|
72
|
+
|
|
73
|
+
value = config[key]
|
|
74
|
+
configuration.public_send("#{key}=", value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
class Configuration
|
|
5
|
+
# File extensions to scan for code usage
|
|
6
|
+
attr_accessor :extensions
|
|
7
|
+
|
|
8
|
+
# Directory patterns to scan (use %<ext>s as placeholder for extension)
|
|
9
|
+
attr_accessor :directory_patterns
|
|
10
|
+
|
|
11
|
+
# Path to YAML file listing excluded items (won't be flagged as unused)
|
|
12
|
+
attr_accessor :excluded_path
|
|
13
|
+
|
|
14
|
+
# Path to YAML file tracking known unused items (baseline)
|
|
15
|
+
attr_accessor :baseline_path
|
|
16
|
+
|
|
17
|
+
# Optional directory that must exist for scanning to proceed (e.g., "ee" for GitLab)
|
|
18
|
+
attr_accessor :required_directory
|
|
19
|
+
|
|
20
|
+
# Whether to show progress during scanning
|
|
21
|
+
attr_accessor :show_progress
|
|
22
|
+
|
|
23
|
+
# Glob patterns for files to exclude from scanning
|
|
24
|
+
attr_accessor :exclude_patterns
|
|
25
|
+
|
|
26
|
+
# Additional directory patterns to include (added to directory_patterns)
|
|
27
|
+
attr_accessor :include_patterns
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@extensions = %w[rb haml erb].freeze
|
|
31
|
+
@directory_patterns = %w[
|
|
32
|
+
app/**/*.%<ext>s
|
|
33
|
+
lib/**/*.%<ext>s
|
|
34
|
+
config/**/*.%<ext>s
|
|
35
|
+
].freeze
|
|
36
|
+
@excluded_path = nil
|
|
37
|
+
@baseline_path = nil
|
|
38
|
+
@required_directory = nil
|
|
39
|
+
@show_progress = true
|
|
40
|
+
@exclude_patterns = []
|
|
41
|
+
@include_patterns = []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rainbow"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Keela
|
|
7
|
+
class Reporter
|
|
8
|
+
attr_reader :strategy_name
|
|
9
|
+
|
|
10
|
+
def initialize(strategy_name)
|
|
11
|
+
@strategy_name = strategy_name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def print_full_report(unused_collection, elapsed_time)
|
|
15
|
+
unused_count = unused_collection.values.flatten.size
|
|
16
|
+
|
|
17
|
+
if unused_count > 0
|
|
18
|
+
puts "\nFound #{unused_count} unused #{strategy_name}:\n\n"
|
|
19
|
+
puts format_yaml(unused_collection)
|
|
20
|
+
puts "\n"
|
|
21
|
+
else
|
|
22
|
+
puts Rainbow("No unused #{strategy_name} were found.").green.bright
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "Finished in #{elapsed_time.round(2)} seconds."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print_diff_report(new_unused, removed, excluded_path:, baseline_path:)
|
|
29
|
+
print_new_unused(new_unused, excluded_path) unless new_unused.empty?
|
|
30
|
+
|
|
31
|
+
if new_unused.size + removed.size > 0
|
|
32
|
+
puts Rainbow("~" * 80).white.bright
|
|
33
|
+
puts "\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
print_removed(removed, baseline_path) unless removed.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_yaml(collection)
|
|
40
|
+
indent_yaml_list_items(collection.sort.to_h.to_yaml)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def print_new_unused(new_unused, excluded_path)
|
|
46
|
+
error = <<~MESSAGE
|
|
47
|
+
We have detected #{new_unused.size} newly unused #{strategy_name}.
|
|
48
|
+
|
|
49
|
+
Please remove these #{strategy_name}, or if in use, add to #{excluded_path}.
|
|
50
|
+
MESSAGE
|
|
51
|
+
|
|
52
|
+
puts Rainbow(error).red.bright
|
|
53
|
+
puts Rainbow(format_yaml(parse_diff(new_unused))).red.bright
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def print_removed(removed, baseline_path)
|
|
57
|
+
message = <<~MESSAGE
|
|
58
|
+
It appears you have removed unused #{strategy_name}. Thank you!
|
|
59
|
+
|
|
60
|
+
Please update #{File.basename(baseline_path)} and remove entries for these #{strategy_name}.
|
|
61
|
+
MESSAGE
|
|
62
|
+
|
|
63
|
+
puts Rainbow(message).yellow.bright
|
|
64
|
+
puts Rainbow(format_yaml(parse_diff(removed))).yellow.bright
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_diff(diff_to_parse)
|
|
68
|
+
result = Hash.new { |hash, key| hash[key] = [] }
|
|
69
|
+
|
|
70
|
+
diff_to_parse.each do |file_name, name|
|
|
71
|
+
result[file_name] << name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Indents YAML list items that are not already indented.
|
|
78
|
+
# Ruby's to_yaml outputs list items without indentation (e.g., "- item"),
|
|
79
|
+
# but we want 2-space indentation (e.g., " - item").
|
|
80
|
+
def indent_yaml_list_items(yaml_string)
|
|
81
|
+
yaml_string.gsub(/\n-(\s+\S)/, "\n -\\1")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parallel"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Keela
|
|
7
|
+
class Scanner
|
|
8
|
+
attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :new_unused, :removed
|
|
9
|
+
|
|
10
|
+
def initialize(strategy:, configuration: Keela.configuration, baseline: nil)
|
|
11
|
+
@strategy = strategy
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@baseline = baseline || Baseline.new(configuration.baseline_path)
|
|
14
|
+
@source_files = {}
|
|
15
|
+
@unused_collection = Hash.new { |hash, key| hash[key] = [] }
|
|
16
|
+
@new_unused = []
|
|
17
|
+
@removed = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(force_report: false, update_baseline: false, silent: false)
|
|
21
|
+
return true unless should_run?
|
|
22
|
+
|
|
23
|
+
validate_configuration!
|
|
24
|
+
|
|
25
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
|
|
27
|
+
load_source_files
|
|
28
|
+
definitions = find_definitions
|
|
29
|
+
definitions = filter_excluded(definitions)
|
|
30
|
+
|
|
31
|
+
# Determine mode: report if forced, updating baseline, or no baseline exists
|
|
32
|
+
report_mode = force_report || update_baseline || !baseline.exists?
|
|
33
|
+
|
|
34
|
+
find_unused(definitions, show_progress: report_mode && !silent)
|
|
35
|
+
|
|
36
|
+
if report_mode
|
|
37
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
38
|
+
reporter.print_full_report(unused_collection, elapsed) unless silent
|
|
39
|
+
if update_baseline
|
|
40
|
+
baseline.set(strategy.name, unused_collection)
|
|
41
|
+
# Note: caller is responsible for calling baseline.save after all strategies run
|
|
42
|
+
end
|
|
43
|
+
return true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Baseline mode: compare against known unused code
|
|
47
|
+
compare_with_baseline
|
|
48
|
+
unless silent
|
|
49
|
+
reporter.print_diff_report(
|
|
50
|
+
new_unused,
|
|
51
|
+
removed,
|
|
52
|
+
excluded_path: configuration.excluded_path || "excluded.yml",
|
|
53
|
+
baseline_path: baseline.path
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
new_unused.empty? && removed.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def file_globs
|
|
61
|
+
all_patterns = configuration.directory_patterns + configuration.include_patterns
|
|
62
|
+
|
|
63
|
+
configuration.extensions.flat_map do |ext|
|
|
64
|
+
all_patterns.map { |pattern| format(pattern, ext: ext) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def should_run?
|
|
71
|
+
return true unless configuration.required_directory
|
|
72
|
+
|
|
73
|
+
Dir.exist?(configuration.required_directory)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_configuration!
|
|
77
|
+
custom_directory_patterns = configuration.directory_patterns != default_directory_patterns
|
|
78
|
+
has_include_patterns = !configuration.include_patterns.empty?
|
|
79
|
+
has_exclude_patterns = !configuration.exclude_patterns.empty?
|
|
80
|
+
|
|
81
|
+
return unless custom_directory_patterns && (has_include_patterns || has_exclude_patterns)
|
|
82
|
+
|
|
83
|
+
raise ConfigurationError,
|
|
84
|
+
"Cannot use include_patterns or exclude_patterns with custom directory_patterns. " \
|
|
85
|
+
"Use directory_patterns for full control, OR use include/exclude to tweak the defaults."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def default_directory_patterns
|
|
89
|
+
Keela::Configuration.new.directory_patterns
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reporter
|
|
93
|
+
@reporter ||= Reporter.new(strategy.name)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def load_source_files
|
|
97
|
+
Dir.glob(file_globs).each do |filename|
|
|
98
|
+
next if excluded_file?(filename)
|
|
99
|
+
|
|
100
|
+
@source_files[filename] = File.readlines(filename)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def excluded_file?(filename)
|
|
105
|
+
return false if configuration.exclude_patterns.empty?
|
|
106
|
+
|
|
107
|
+
configuration.exclude_patterns.any? do |pattern|
|
|
108
|
+
File.fnmatch?(pattern, filename, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_definitions
|
|
113
|
+
source_files.keys.grep(strategy.definition_file_pattern).flat_map do |filename|
|
|
114
|
+
lines = source_files[filename]
|
|
115
|
+
|
|
116
|
+
# Allow strategies to override file parsing (e.g., for YAML files)
|
|
117
|
+
custom_definitions = strategy.extract_definitions_from_file(filename, lines)
|
|
118
|
+
next custom_definitions if custom_definitions
|
|
119
|
+
|
|
120
|
+
# Default: line-by-line parsing
|
|
121
|
+
lines.flat_map do |line|
|
|
122
|
+
next [] if strategy.skip_comments? && line.strip.start_with?("#")
|
|
123
|
+
|
|
124
|
+
name = strategy.extract_definition(line)
|
|
125
|
+
name ? [{ name: name, file: filename }] : []
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def filter_excluded(definitions)
|
|
131
|
+
return definitions unless configuration.excluded_path
|
|
132
|
+
return definitions unless File.exist?(configuration.excluded_path)
|
|
133
|
+
|
|
134
|
+
excluded = YAML.load_file(configuration.excluded_path, symbolize_names: true) || {}
|
|
135
|
+
|
|
136
|
+
definitions.reject do |h|
|
|
137
|
+
excluded_for_file = excluded[h[:file].to_sym]
|
|
138
|
+
excluded_for_file&.flat_map(&:keys)&.include?(h[:name].to_sym)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def find_unused(definitions, show_progress: false)
|
|
143
|
+
source_code = source_files.values.flatten.join
|
|
144
|
+
|
|
145
|
+
progress_label = show_progress ? "Checking #{strategy.name}" : nil
|
|
146
|
+
|
|
147
|
+
unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
|
|
148
|
+
regex = strategy.usage_regex(definition[:name])
|
|
149
|
+
regex.match?(source_code) ? [] : definition
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
unused.each do |unused_def|
|
|
153
|
+
@unused_collection[unused_def[:file]] << unused_def[:name]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def compare_with_baseline
|
|
158
|
+
baseline_for_strategy = baseline.get(strategy.name)
|
|
159
|
+
baseline_items = baseline_for_strategy.flat_map { |f, names| [f].product(names) }
|
|
160
|
+
|
|
161
|
+
current_items = unused_collection.flat_map { |f, names| [f].product(names) }
|
|
162
|
+
|
|
163
|
+
@new_unused = current_items - baseline_items
|
|
164
|
+
@removed = baseline_items - current_items
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Attributes < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"attributes"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
# Match app/ and lib/ directories, but exclude spec/ and test/
|
|
12
|
+
%r{(?:^|/)(?:ee/)?(?:app|lib)/}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def extract_definition(line)
|
|
16
|
+
# Match attr_accessor, attr_reader, attr_writer declarations
|
|
17
|
+
# But NOT other attr_* DSLs like attr_encrypted, attr_spammable, etc.
|
|
18
|
+
return nil unless line =~ /^\s*attr_(accessor|reader|writer)\s+/
|
|
19
|
+
|
|
20
|
+
# Extract the first symbol after the attr_* declaration
|
|
21
|
+
return nil unless line =~ /attr_(?:accessor|reader|writer)\s+:(\w+)/
|
|
22
|
+
|
|
23
|
+
Regexp.last_match(1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage_regex(name)
|
|
27
|
+
# Match usage of the attribute:
|
|
28
|
+
# - Getter: obj.name, name (without receiver)
|
|
29
|
+
# - Setter: obj.name = value, self.name = value
|
|
30
|
+
# - Instance variable: @name (direct access)
|
|
31
|
+
#
|
|
32
|
+
# Exclude:
|
|
33
|
+
# - Symbol notation (:name)
|
|
34
|
+
# - The attr_* definition itself
|
|
35
|
+
# - Partial word matches (username shouldn't match name)
|
|
36
|
+
/(?:(?<!:)(?<!attr_accessor\s)(?<!attr_reader\s)(?<!attr_writer\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)|@#{Regexp.quote(name)}(?!\w))/
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def skip_comments?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Constants < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"constants"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
# Match app/ and lib/ directories, but exclude spec/ and test/
|
|
12
|
+
%r{(?:^|/)(?:ee/)?(?:app|lib)/}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def extract_definition(line)
|
|
16
|
+
# Match constant definitions like:
|
|
17
|
+
# MAX_SIZE = 100
|
|
18
|
+
# ALLOWED_TYPES = %w[foo bar].freeze
|
|
19
|
+
# OPTIONS = { foo: 1 }.freeze
|
|
20
|
+
#
|
|
21
|
+
# Must start with uppercase letter followed by uppercase letters,
|
|
22
|
+
# digits, or underscores, then = (with optional whitespace)
|
|
23
|
+
#
|
|
24
|
+
# Avoid matching:
|
|
25
|
+
# - Comparisons: MAX_SIZE == 100
|
|
26
|
+
# - Namespaced access: Foo::BAR
|
|
27
|
+
# - Class/module definitions
|
|
28
|
+
|
|
29
|
+
# First check it's not a comparison
|
|
30
|
+
return nil if line =~ /[!=]=/
|
|
31
|
+
|
|
32
|
+
# Match the constant definition pattern
|
|
33
|
+
return nil unless line =~ /^\s*([A-Z][A-Z0-9_]*)\s*=/
|
|
34
|
+
|
|
35
|
+
Regexp.last_match(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def usage_regex(name)
|
|
39
|
+
# Match usage of the constant, but not its definition
|
|
40
|
+
# Uses negative lookbehind to avoid matching when preceded by
|
|
41
|
+
# uppercase letters/digits/underscores (partial match)
|
|
42
|
+
# Uses negative lookahead to avoid:
|
|
43
|
+
# - partial matches (followed by uppercase letters/digits/underscores)
|
|
44
|
+
# - definitions (followed by optional whitespace then =, but not ==)
|
|
45
|
+
/(?<![A-Z0-9_])#{Regexp.quote(name)}(?![A-Z0-9_])(?!\s*=(?!=))/
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def skip_comments?
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Delegations < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"delegations"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
# Match app/models/ directories (including concerns), but exclude spec/test
|
|
12
|
+
%r{(?:^|/)(?:ee/)?app/models/}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def extract_definition(line)
|
|
16
|
+
# Match delegate declarations like:
|
|
17
|
+
# delegate :name, to: :user
|
|
18
|
+
# delegate :name, :email, to: :user
|
|
19
|
+
# delegate :name, to: :user, prefix: true
|
|
20
|
+
# delegate :name, to: :user, prefix: :owner
|
|
21
|
+
# delegate :name, to: :user, allow_nil: true
|
|
22
|
+
return nil unless line =~ /^\s*delegate\s+/
|
|
23
|
+
|
|
24
|
+
# Extract the target for prefix detection
|
|
25
|
+
target = line[/to:\s*:[@]?(\w+)/, 1]
|
|
26
|
+
|
|
27
|
+
# Check for prefix option
|
|
28
|
+
prefix = if line =~ /prefix:\s*:(\w+)/
|
|
29
|
+
Regexp.last_match(1)
|
|
30
|
+
elsif line =~ /prefix:\s*true/
|
|
31
|
+
target
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Extract all method symbols from the delegate call
|
|
35
|
+
# Match :symbol patterns before 'to:'
|
|
36
|
+
# Include ? and ! for predicate and bang methods
|
|
37
|
+
delegate_part = line.split(/,\s*to:/)[0]
|
|
38
|
+
methods = delegate_part.scan(/:(\w+[?!]?)/).flatten
|
|
39
|
+
|
|
40
|
+
return nil if methods.empty?
|
|
41
|
+
|
|
42
|
+
# Apply prefix if present
|
|
43
|
+
if prefix
|
|
44
|
+
methods = methods.map { |m| "#{prefix}_#{m}" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Return single string for single method (scanner expects this)
|
|
48
|
+
# For multiple methods, return first one only
|
|
49
|
+
# The scanner will create one definition entry per extract_definition call
|
|
50
|
+
# To handle multiple delegations per line, we'd need to change the scanner
|
|
51
|
+
# For now, return just the first method
|
|
52
|
+
methods.first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def usage_regex(name)
|
|
56
|
+
# Match usage of the delegated method, but not the delegate declaration
|
|
57
|
+
# Uses negative lookbehind to avoid matching:
|
|
58
|
+
# - Symbol notation (:name)
|
|
59
|
+
# - Part of delegate declaration
|
|
60
|
+
# Uses word boundary to avoid partial matches
|
|
61
|
+
# Note: Regexp.quote handles ? and ! in method names
|
|
62
|
+
/(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)/i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def skip_comments?
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
module Strategies
|
|
7
|
+
# Detects unused I18n translation keys in locale files.
|
|
8
|
+
#
|
|
9
|
+
# Definitions are extracted from YAML locale files (config/locales/*.yml)
|
|
10
|
+
# and flattened to dot notation (e.g., "users.show.title").
|
|
11
|
+
#
|
|
12
|
+
# Usage is detected by searching for:
|
|
13
|
+
# - I18n.t("key") or I18n.t('key')
|
|
14
|
+
# - t("key") or t('key')
|
|
15
|
+
# - t(:key)
|
|
16
|
+
# - .human_attribute_name(:attr)
|
|
17
|
+
#
|
|
18
|
+
# Note: Lazy lookup (t('.title') in views) is not yet supported.
|
|
19
|
+
#
|
|
20
|
+
class I18nKeys < Strategy
|
|
21
|
+
def name
|
|
22
|
+
"i18n_keys"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def definition_file_pattern
|
|
26
|
+
# Match locale YAML files
|
|
27
|
+
%r{config/locales/.*\.ya?ml$}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Override: I18n keys need special YAML parsing, not line-by-line
|
|
31
|
+
def extract_definitions_from_file(filepath, _lines)
|
|
32
|
+
return [] unless File.exist?(filepath)
|
|
33
|
+
|
|
34
|
+
content = YAML.load_file(filepath, permitted_classes: [Symbol]) || {}
|
|
35
|
+
flatten_keys(content).map do |key|
|
|
36
|
+
# Remove the locale prefix (e.g., "en.users.show" -> "users.show")
|
|
37
|
+
key_without_locale = key.sub(/^[a-z]{2}(-[A-Z]{2})?\./, "")
|
|
38
|
+
{ name: key_without_locale, file: filepath }
|
|
39
|
+
end
|
|
40
|
+
rescue Psych::SyntaxError => e
|
|
41
|
+
warn "Warning: Could not parse #{filepath}: #{e.message}"
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_definition(_line)
|
|
46
|
+
# Not used - we override extract_definitions_from_file instead
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def usage_regex(name)
|
|
51
|
+
# Match various I18n lookup patterns:
|
|
52
|
+
# I18n.t("users.show.title")
|
|
53
|
+
# I18n.t('users.show.title')
|
|
54
|
+
# t("users.show.title")
|
|
55
|
+
# t('users.show.title')
|
|
56
|
+
# t(:users_show_title) - symbol form (underscored)
|
|
57
|
+
#
|
|
58
|
+
# Also match partial keys for lazy lookup support:
|
|
59
|
+
# t(".title") in a view could match "users.show.title"
|
|
60
|
+
quoted_name = Regexp.quote(name)
|
|
61
|
+
|
|
62
|
+
# Build pattern that matches the key in quotes or as a symbol
|
|
63
|
+
/(?:I18n\.)?t\s*\(\s*["':]+#{quoted_name}["']?\s*[,)]/
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def skip_comments?
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Flatten nested hash to dot-notation keys
|
|
73
|
+
# { "en" => { "users" => { "title" => "..." } } }
|
|
74
|
+
# becomes ["en.users.title"]
|
|
75
|
+
def flatten_keys(hash, prefix = nil)
|
|
76
|
+
hash.flat_map do |key, value|
|
|
77
|
+
full_key = [prefix, key].compact.join(".")
|
|
78
|
+
case value
|
|
79
|
+
when Hash
|
|
80
|
+
flatten_keys(value, full_key)
|
|
81
|
+
else
|
|
82
|
+
[full_key]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Methods < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"methods"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
%r{app/helpers|app/models}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def extract_definition(line)
|
|
15
|
+
return nil unless line =~ /def ([^(;\s]+)/
|
|
16
|
+
|
|
17
|
+
Regexp.last_match(1).chomp
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def usage_regex(name)
|
|
21
|
+
if name.end_with?("=")
|
|
22
|
+
# Setter method: match assignment usage
|
|
23
|
+
/(?<!def )#{Regexp.quote(name.sub(/^self\./, "").chomp("="))}\W=*/
|
|
24
|
+
else
|
|
25
|
+
# Regular method: match calls
|
|
26
|
+
/(?<!def )#{Regexp.quote(name.sub(/^self\./, ""))}\W/
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def skip_comments?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Scopes < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"scopes"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
%r{app/models}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def extract_definition(line)
|
|
15
|
+
return nil unless line =~ /\bscope\s+:(\w+)/
|
|
16
|
+
|
|
17
|
+
Regexp.last_match(1)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def usage_regex(name)
|
|
21
|
+
/(?<!scope :)(?<!def )#{Regexp.quote(name)}\W/
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def skip_comments?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|