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.
@@ -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