zen_apropos 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,156 @@
1
+ # Search engine that ties together parsing, indexing, searching, grouping, and formatting
2
+ #
3
+ # Usage:
4
+ # ZenApropos::Engine.new.search("employee")
5
+ # ZenApropos::Engine.new.search("team:finance safety:destructive")
6
+ module ZenApropos
7
+ class Engine
8
+ attr_reader :index, :plain, :last_results
9
+
10
+ def initialize(root_path: Dir.pwd, plain: false, glob_patterns: nil)
11
+ source = Sources::RakeSource.new(
12
+ root_path: root_path,
13
+ glob_patterns: glob_patterns || Sources::RakeSource::DEFAULT_PATHS
14
+ )
15
+ @index = Index.new(source: source)
16
+ @plain = plain
17
+ end
18
+
19
+ def help
20
+ <<~HELP
21
+
22
+ ๐Ÿ” ZenApropos - Rake Task Search
23
+
24
+ Usage:
25
+ CLI: zen_apropos <query> [--plain]
26
+ Console: apropos "query"
27
+
28
+ Query Syntax:
29
+ Free text Search task names, descriptions, annotations, and source
30
+ team:<name> Filter by team annotation
31
+ safety:<level> Filter by safety level (safe, caution, destructive)
32
+ namespace:<ns> Filter by rake namespace
33
+ keyword:<word> Filter by declared keyword
34
+
35
+ Examples:
36
+ apropos "employee"
37
+ apropos "reindex"
38
+ apropos "namespace:indexer"
39
+ apropos "team:finance safety:destructive"
40
+ apropos "Employee.find_each"
41
+ apropos "reindex", plain: true
42
+
43
+ HELP
44
+ end
45
+
46
+ def search(raw_query)
47
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+
49
+ parsed = QueryParser.parse(raw_query)
50
+ results = find_entries(parsed)
51
+ matches = build_match_context(results, parsed[:text])
52
+
53
+ grouper = ResultGrouper.new(results, active_filters: parsed[:filters])
54
+ grouped = grouper.group
55
+ group_dimension = results.length >= 5 ? grouper.best_dimension : nil
56
+
57
+ # Store results in display order so interactive viewer matches the numbered output
58
+ @last_results = grouped.values.flatten
59
+
60
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
61
+
62
+ formatter = ResultFormatter.new(
63
+ results: results,
64
+ grouped_results: grouped,
65
+ query: raw_query,
66
+ group_dimension: group_dimension,
67
+ matches: matches,
68
+ elapsed: elapsed
69
+ )
70
+
71
+ plain ? formatter.format_plain : formatter.format_rich
72
+ end
73
+
74
+ # Returns raw filtered results (for interactive mode)
75
+ def search_entries(raw_query)
76
+ find_entries(QueryParser.parse(raw_query))
77
+ end
78
+
79
+ private
80
+
81
+ def find_entries(parsed)
82
+ results = filter(index.entries, parsed)
83
+ text_search(results, parsed[:text])
84
+ end
85
+
86
+ def filter(entries, parsed)
87
+ filters = parsed[:filters]
88
+ return entries if filters.empty?
89
+
90
+ entries.select do |entry|
91
+ filters.all? do |key, value|
92
+ case key
93
+ when :team then entry.team&.downcase == value.downcase
94
+ when :safety then entry.safety&.downcase == value.downcase
95
+ when :namespace then entry.namespace&.downcase == value.downcase
96
+ when :keyword then entry.keywords.any? { |k| k.downcase == value.downcase }
97
+ else true
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ def text_search(entries, text)
104
+ return entries if text.nil? || text.empty?
105
+
106
+ query_lower = text.downcase
107
+
108
+ entries.select do |entry|
109
+ entry.name.downcase.include?(query_lower) ||
110
+ entry.description.downcase.include?(query_lower) ||
111
+ entry.keywords.any? { |k| k.downcase.include?(query_lower) } ||
112
+ entry.source.downcase.include?(query_lower)
113
+ end
114
+ end
115
+
116
+ # Builds 3-line source context for entries that matched on source code
117
+ def build_match_context(entries, text)
118
+ return {} if text.nil? || text.empty?
119
+
120
+ context = {}
121
+ query_lower = text.downcase
122
+
123
+ entries.each do |entry|
124
+ # Only show source context if the match was in source (not name/desc)
125
+ next if entry.name.downcase.include?(query_lower)
126
+ next if entry.description.downcase.include?(query_lower)
127
+
128
+ next unless entry.source.downcase.include?(query_lower)
129
+
130
+ source_lines = entry.source.lines
131
+ source_lines.each_with_index do |line, i|
132
+ next unless line.downcase.include?(query_lower)
133
+
134
+ actual_line = entry.line_number + i
135
+ context_lines = {}
136
+
137
+ # 1 line before
138
+ if i > 0
139
+ context_lines[actual_line - 1] = source_lines[i - 1]
140
+ end
141
+ # matched line
142
+ context_lines[actual_line] = line
143
+ # 1 line after
144
+ if i < source_lines.length - 1
145
+ context_lines[actual_line + 1] = source_lines[i + 1]
146
+ end
147
+
148
+ context[entry.name] = { source_lines: context_lines, matched_line: actual_line }
149
+ break # only show first match
150
+ end
151
+ end
152
+
153
+ context
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,38 @@
1
+ # Represents a single searchable rake task entry
2
+ module ZenApropos
3
+ Entry = Struct.new(
4
+ :name, # full task name e.g. "indexer:employees"
5
+ :namespace, # namespace portion e.g. "indexer"
6
+ :description, # the desc string
7
+ :file_path, # absolute path to .rake file
8
+ :line_number, # line where task is defined
9
+ :source, # full source of the task block
10
+ :annotations, # hash of @zen_desc metadata { team:, safety:, keywords: }
11
+ :args, # array of argument names e.g. ["days_ago"]
12
+ keyword_init: true
13
+ ) do
14
+ def team
15
+ annotations[:team]
16
+ end
17
+
18
+ def safety
19
+ annotations[:safety]
20
+ end
21
+
22
+ def keywords
23
+ annotations[:keywords] || []
24
+ end
25
+
26
+ def relative_path
27
+ file_path.sub("#{Dir.pwd}/", '')
28
+ end
29
+
30
+ def usage
31
+ if args&.any?
32
+ "bundle exec rake #{name}[#{args.join(',')}]"
33
+ else
34
+ "bundle exec rake #{name}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,56 @@
1
+ require 'fileutils'
2
+
3
+ # Builds and caches the search index with mtime-based invalidation
4
+ #
5
+ # Cache is stored in tmp/zen_apropos_index.cache
6
+ # Rebuilds automatically when any .rake file is newer than the cache
7
+ module ZenApropos
8
+ class Index
9
+ CACHE_PATH = 'tmp/zen_apropos_index.cache'.freeze
10
+
11
+ attr_reader :source
12
+
13
+ def initialize(source:)
14
+ @source = source
15
+ end
16
+
17
+ def entries
18
+ return @cached_entries if cache_fresh?
19
+
20
+ build_and_cache
21
+ end
22
+
23
+ private
24
+
25
+ def cache_fresh?
26
+ return false unless File.exist?(cache_path)
27
+
28
+ cached = Marshal.load(File.binread(cache_path))
29
+ return false unless cached.is_a?(Hash) && cached[:glob_patterns] == source.glob_patterns
30
+
31
+ cache_mtime = File.mtime(cache_path)
32
+ fresh = source.scan.none? { |file| File.mtime(file) > cache_mtime }
33
+ @cached_entries = cached[:entries] if fresh
34
+ fresh
35
+ rescue StandardError
36
+ false
37
+ end
38
+
39
+ def build_and_cache
40
+ all_entries = source.entries
41
+
42
+ begin
43
+ FileUtils.mkdir_p(File.dirname(cache_path))
44
+ File.binwrite(cache_path, Marshal.dump({ entries: all_entries, glob_patterns: source.glob_patterns }))
45
+ rescue SystemCallError
46
+ # Permission error or read-only filesystem โ€” skip caching, return entries
47
+ end
48
+
49
+ all_entries
50
+ end
51
+
52
+ def cache_path
53
+ File.join(source.root_path, CACHE_PATH)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,84 @@
1
+ # Checks rake tasks for missing annotations
2
+ #
3
+ # Usage:
4
+ # ZenApropos::Linter.new.run # Check all rake files
5
+ # ZenApropos::Linter.new(changed_only: true).run # Check only changed files
6
+ module ZenApropos
7
+ class Linter
8
+ attr_reader :warnings
9
+
10
+ def initialize(changed_only: false)
11
+ @changed_only = changed_only
12
+ @warnings = []
13
+ end
14
+
15
+ def run
16
+ files = rake_files
17
+ files.each { |file| check_file(file) }
18
+ print_results
19
+ warnings.any? ? 1 : 0
20
+ end
21
+
22
+ private
23
+
24
+ def tag
25
+ ZenApropos.configuration.tag
26
+ end
27
+
28
+ def rake_files
29
+ if @changed_only
30
+ changed = `git diff --name-only --diff-filter=ACMR HEAD~1 2>/dev/null`.strip.split("\n")
31
+ changed.select { |f| f.end_with?('.rake') && File.exist?(f) }
32
+ else
33
+ patterns = ZenApropos.configuration.glob_patterns || Sources::RakeSource::DEFAULT_PATHS
34
+ patterns.flat_map { |p| Dir[p] }.sort
35
+ end
36
+ end
37
+
38
+ def check_file(file_path)
39
+ lines = File.readlines(file_path)
40
+
41
+ lines.each_with_index do |line, index|
42
+ next unless line =~ /^\s*desc\s+['"]/
43
+ next if preceding_has_tags?(lines, index)
44
+
45
+ task_name = extract_next_task_name(lines, index)
46
+ next unless task_name
47
+
48
+ warnings << { file: file_path, line: index + 1, task_name: task_name }
49
+ end
50
+ end
51
+
52
+ def preceding_has_tags?(lines, desc_index)
53
+ i = desc_index - 1
54
+ while i >= 0
55
+ line = lines[i]
56
+ return true if ZenApropos.configuration.tag_pattern.match?(line)
57
+ return true if line =~ /^\s*zen_desc\s+/
58
+ break unless line =~ /^\s*(#|$)/
59
+ i -= 1
60
+ end
61
+ false
62
+ end
63
+
64
+ def extract_next_task_name(lines, desc_index)
65
+ lines[desc_index + 1..desc_index + 3]&.each do |line|
66
+ return Regexp.last_match(1) if line =~ /^\s*task\s+[:'"]?(\w+)/
67
+ end
68
+ nil
69
+ end
70
+
71
+ def print_results
72
+ if warnings.empty?
73
+ puts "\nโœ… All rake tasks have @#{tag} annotations.\n"
74
+ return
75
+ end
76
+
77
+ puts ''
78
+ warnings.each do |w|
79
+ puts "โš  #{w[:file]}:#{w[:line]} โ€” task \"#{w[:task_name]}\" has no @#{tag} annotations"
80
+ end
81
+ puts "\n#{warnings.length} task(s) missing annotations. Consider adding @#{tag} tags or using zen_desc.\n"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,38 @@
1
+ # Parses a search query into structured filters and free-text terms
2
+ #
3
+ # Examples:
4
+ # "employee" => { text: "employee", filters: {} }
5
+ # "team:finance safety:destructive" => { text: "", filters: { team: "finance", safety: "destructive" } }
6
+ # "employee team:search" => { text: "employee", filters: { team: "search" } }
7
+ module ZenApropos
8
+ class QueryParser
9
+ FILTER_KEYS = %w[team safety namespace keyword].freeze
10
+
11
+ def initialize(raw_query)
12
+ @raw_query = raw_query.to_s.strip
13
+ end
14
+
15
+ def parse
16
+ filters = {}
17
+ text_parts = []
18
+
19
+ @raw_query.split(/\s+/).each do |token|
20
+ key, value = token.split(':', 2)
21
+
22
+ if FILTER_KEYS.include?(key) && value && !value.empty?
23
+ filters[key.to_sym] = value
24
+ else
25
+ text_parts << token
26
+ end
27
+ end
28
+
29
+ { text: text_parts.join(' '), filters: filters }
30
+ end
31
+
32
+ class << self
33
+ def parse(raw_query)
34
+ new(raw_query).parse
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,122 @@
1
+ # Formats search results for terminal output
2
+ #
3
+ # Two modes:
4
+ # - Rich (default): colored boxes grouped by dimension
5
+ # - Plain (--plain): pipeable tab-separated output
6
+ module ZenApropos
7
+ class ResultFormatter
8
+ SAFETY_BADGES = {
9
+ 'safe' => "\e[32mโšก safe\e[0m",
10
+ 'caution' => "\e[33mโš ๏ธ caution\e[0m",
11
+ 'destructive' => "\e[31m๐Ÿ”ด destructive\e[0m"
12
+ }.freeze
13
+
14
+ RESET = "\e[0m".freeze
15
+ CYAN = "\e[36m".freeze
16
+ BOLD = "\e[1m".freeze
17
+ DIM = "\e[2m".freeze
18
+ YELLOW = "\e[33m".freeze
19
+
20
+ attr_reader :results, :grouped_results, :query, :group_dimension, :matches, :elapsed
21
+
22
+ def initialize(results:, grouped_results:, query:, group_dimension:, matches: {}, elapsed: 0)
23
+ @results = results
24
+ @grouped_results = grouped_results
25
+ @query = query
26
+ @group_dimension = group_dimension
27
+ @matches = matches
28
+ @elapsed = elapsed
29
+ end
30
+
31
+ def format_rich
32
+ output = []
33
+ output << header_box
34
+ output << ''
35
+
36
+ global_index = 0
37
+ grouped_results.each do |group_name, entries|
38
+ output << format_group(group_name, entries, start_index: global_index)
39
+ output << ''
40
+ global_index += entries.length
41
+ end
42
+
43
+ output << summary_line
44
+ output.join("\n")
45
+ end
46
+
47
+ def format_plain
48
+ results.map do |entry|
49
+ safety = entry.safety || '-'
50
+ team = entry.team || '-'
51
+ [
52
+ entry.name.ljust(40),
53
+ (entry.description || '').ljust(50),
54
+ safety.ljust(12),
55
+ team
56
+ ].join(' | ')
57
+ end.join("\n")
58
+ end
59
+
60
+ private
61
+
62
+ def header_box
63
+ title = "ZenApropos โ€” #{results.length} results for \"#{query}\""
64
+ group_label = group_dimension ? "Grouped by: #{group_dimension}" : nil
65
+
66
+ width = 60
67
+ lines = []
68
+ lines << "#{CYAN}โ•ญ#{'โ”€' * width}โ•ฎ#{RESET}"
69
+ lines << "#{CYAN}โ”‚#{RESET} #{BOLD}#{title}#{RESET}#{' ' * [0, width - title.length - 2].max}#{CYAN}โ”‚#{RESET}"
70
+ if group_label
71
+ lines << "#{CYAN}โ”‚#{RESET} #{DIM}#{group_label}#{RESET}#{' ' * [0, width - group_label.length - 2].max}#{CYAN}โ”‚#{RESET}"
72
+ end
73
+ lines << "#{CYAN}โ•ฐ#{'โ”€' * width}โ•ฏ#{RESET}"
74
+ lines.join("\n")
75
+ end
76
+
77
+ def format_group(group_name, entries, start_index: 0)
78
+ lines = []
79
+ lines << "#{CYAN}โ”Œ #{BOLD}#{group_name}#{RESET} #{'โ”€' * [0, 55 - group_name.to_s.length].max}"
80
+
81
+ entries.each_with_index do |entry, i|
82
+ number = start_index + i + 1
83
+ lines << "#{CYAN}โ”‚#{RESET} #{DIM}[#{number}]#{RESET} #{BOLD}rake #{entry.name}#{RESET}"
84
+ lines << "#{CYAN}โ”‚#{RESET} #{entry.description}" unless entry.description.empty?
85
+ lines << "#{CYAN}โ”‚#{RESET} #{DIM}$ #{entry.usage}#{RESET}"
86
+ lines << "#{CYAN}โ”‚#{RESET} #{format_badges(entry)}" if entry.safety || entry.team
87
+ lines << format_match_context(entry) if matches[entry.name]
88
+ lines << "#{CYAN}โ”‚#{RESET}" if i < entries.length - 1
89
+ end
90
+
91
+ lines << "#{CYAN}โ””#{'โ”€' * 58}#{RESET}"
92
+ lines.join("\n")
93
+ end
94
+
95
+ def format_badges(entry)
96
+ parts = []
97
+ parts << SAFETY_BADGES[entry.safety] if entry.safety
98
+ parts << "#{DIM}team: #{entry.team}#{RESET}" if entry.team
99
+ parts.join(" ยท ")
100
+ end
101
+
102
+ def format_match_context(entry)
103
+ match_info = matches[entry.name]
104
+ return '' unless match_info && match_info[:source_lines]
105
+
106
+ lines = []
107
+ match_info[:source_lines].each do |line_num, content|
108
+ prefix = match_info[:matched_line] == line_num ? "#{YELLOW}>" : " "
109
+ lines << "#{CYAN}โ”‚#{RESET} #{DIM}#{prefix} #{line_num}:#{RESET} #{content.rstrip}"
110
+ end
111
+ lines.join("\n")
112
+ end
113
+
114
+ def summary_line
115
+ group_count = grouped_results.keys.length
116
+ group_word = group_dimension || 'groups'
117
+ elapsed_str = format('%.2fs', elapsed)
118
+
119
+ "#{results.length} results across #{group_count} #{group_word}#{group_count > 1 ? 's' : ''} (#{elapsed_str})"
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,58 @@
1
+ # Auto-groups search results by the most useful dimension
2
+ #
3
+ # Logic:
4
+ # - Skip any dimension used as a filter (it's constant across results)
5
+ # - Pick the dimension with the best cluster distribution
6
+ # - Fall back to namespace if all else is equal
7
+ # - No grouping for < 5 results
8
+ module ZenApropos
9
+ class ResultGrouper
10
+ DIMENSIONS = %i[namespace team safety].freeze
11
+
12
+ attr_reader :entries, :active_filters
13
+
14
+ def initialize(entries, active_filters: {})
15
+ @entries = entries
16
+ @active_filters = active_filters
17
+ end
18
+
19
+ def group
20
+ return { nil => entries } if entries.length < 5
21
+
22
+ dimension = best_dimension
23
+ grouped = entries.group_by { |e| dimension_value(e, dimension) || '(none)' }
24
+
25
+ # Sort groups by size descending
26
+ grouped.sort_by { |_key, items| -items.length }.to_h
27
+ end
28
+
29
+ def best_dimension
30
+ candidates = DIMENSIONS.reject { |d| active_filters.key?(d) }
31
+ return :namespace if candidates.empty?
32
+
33
+ # Pick the dimension that creates the most balanced groups
34
+ # (not 1 giant group, not all singletons)
35
+ candidates.max_by { |d| grouping_score(d) } || :namespace
36
+ end
37
+
38
+ private
39
+
40
+ def grouping_score(dimension)
41
+ groups = entries.group_by { |e| dimension_value(e, dimension) }
42
+
43
+ return 0 if groups.length <= 1 # everything in one group = useless
44
+ return 0 if groups.length == entries.length # all singletons = useless
45
+
46
+ # Prefer more groups with reasonable sizes
47
+ groups.length.to_f / entries.length
48
+ end
49
+
50
+ def dimension_value(entry, dimension)
51
+ case dimension
52
+ when :namespace then entry.namespace.to_s.empty? ? nil : entry.namespace
53
+ when :team then entry.team
54
+ when :safety then entry.safety
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,21 @@
1
+ # Base class for source adapters
2
+ #
3
+ # Subclasses must implement:
4
+ # #entries - returns Array of Entry objects
5
+ # #scan - returns Array of file paths to process
6
+ # #parse - returns Array of Entry objects for a single file
7
+ module ZenApropos
8
+ class Source
9
+ def entries
10
+ raise NotImplementedError
11
+ end
12
+
13
+ def scan
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def parse(_file_path)
18
+ raise NotImplementedError
19
+ end
20
+ end
21
+ end