parabot 1.0.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +55 -0
  3. data/CLAUDE.md +171 -0
  4. data/DISTRIBUTION.md +287 -0
  5. data/INSTALL.md +242 -0
  6. data/LICENSE +21 -0
  7. data/README.md +371 -0
  8. data/Rakefile +50 -0
  9. data/config/base.yml +12 -0
  10. data/config/commands.yml +28 -0
  11. data/config/core_prompts/system_prompt.yml +66 -0
  12. data/config/core_prompts/test_guidance.yml +24 -0
  13. data/config/languages/elixir.yml +62 -0
  14. data/config/languages/javascript.yml +64 -0
  15. data/config/languages/kotlin.yml +64 -0
  16. data/config/languages/ruby.yml +66 -0
  17. data/config/languages/shell.yml +63 -0
  18. data/config/languages/typescript.yml +63 -0
  19. data/exe/parabot +22 -0
  20. data/lib/parabot/cli/argument_parser.rb +105 -0
  21. data/lib/parabot/cli/command_router.rb +114 -0
  22. data/lib/parabot/cli.rb +71 -0
  23. data/lib/parabot/commands/base.rb +108 -0
  24. data/lib/parabot/commands/custom_commands.rb +63 -0
  25. data/lib/parabot/commands/doctor.rb +196 -0
  26. data/lib/parabot/commands/init.rb +171 -0
  27. data/lib/parabot/commands/message.rb +25 -0
  28. data/lib/parabot/commands/start.rb +35 -0
  29. data/lib/parabot/commands/test.rb +43 -0
  30. data/lib/parabot/commands/version.rb +17 -0
  31. data/lib/parabot/commands.rb +15 -0
  32. data/lib/parabot/configuration.rb +199 -0
  33. data/lib/parabot/dry_run_logging.rb +22 -0
  34. data/lib/parabot/errors.rb +12 -0
  35. data/lib/parabot/language_detector.rb +158 -0
  36. data/lib/parabot/language_inference.rb +82 -0
  37. data/lib/parabot/logging_setup.rb +73 -0
  38. data/lib/parabot/messaging/adapter.rb +53 -0
  39. data/lib/parabot/messaging/adapter_factory.rb +33 -0
  40. data/lib/parabot/messaging/dry_run_adapter.rb +55 -0
  41. data/lib/parabot/messaging/tmux_adapter.rb +82 -0
  42. data/lib/parabot/system.rb +41 -0
  43. data/lib/parabot/test_runner.rb +179 -0
  44. data/lib/parabot/tmux_manager.rb +245 -0
  45. data/lib/parabot/version.rb +5 -0
  46. data/lib/parabot/yaml_text_assembler.rb +155 -0
  47. data/lib/parabot.rb +30 -0
  48. data/parabot.gemspec +44 -0
  49. data/scripts/build-distribution +122 -0
  50. data/scripts/install +152 -0
  51. metadata +221 -0
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+ require_relative "../yaml_text_assembler"
6
+ require_relative "../language_inference"
7
+ require_relative "../logging_setup"
8
+
9
+ module Parabot
10
+ module Commands
11
+ class Init < Base
12
+ desc "Initialize a new Parabot project with default configuration"
13
+
14
+ option :force, type: :boolean, default: false, desc: "Overwrite existing .parabot.yml file"
15
+
16
+ def call(**options)
17
+ init(**options)
18
+
19
+ # Setup logging using the same configuration as normal operation, with defaults
20
+ LoggingSetup.setup_default
21
+
22
+ initialize_project_config
23
+ initialize_system_prompt
24
+ end
25
+
26
+ private
27
+
28
+ def initialize_project_config
29
+ target_file = Parabot::System.determine_system_path(".parabot.yml", options[:project_root])
30
+
31
+ target_dir = File.dirname(target_file)
32
+ unless Dir.exist?(target_dir)
33
+ logger.error("Target directory doesnt exist: #{target_dir}")
34
+ raise Parabot::Error, "Target directory for configuration does not exist"
35
+ end
36
+
37
+ # Check if target already exists
38
+ if File.exist?(target_file) && !options[:force]
39
+ logger.error("Configuration file #{target_file} already exists")
40
+ logger.info("Use --force to overwrite existing configuration")
41
+ raise Parabot::Error, "Configuration file already exists"
42
+ end
43
+
44
+ # Detect languages for this project
45
+ languages = detect_language
46
+ supported, unsupported = YamlTextAssembler.partition_language_support(languages)
47
+
48
+ # Provide user feedback about language support
49
+ if unsupported.any?
50
+ logger.info("Detected languages: #{languages.join(', ')}")
51
+ logger.info("Full support: #{supported.join(', ')}") if supported.any?
52
+ logger.info("Minimal support: #{unsupported.join(', ')}") if unsupported.any?
53
+ elsif supported.any?
54
+ logger.info("All detected languages have full support: #{supported.join(', ')}")
55
+ end
56
+
57
+ dry_run_log "Would create targeted configuration for languages: #{languages.join(', ')}"
58
+ dry_run_log "Target file: #{target_file}"
59
+ return if dry_run?
60
+
61
+ begin
62
+ # Assemble targeted configuration as clean YAML text
63
+ yaml_content = YamlTextAssembler.assemble_for_languages(languages)
64
+
65
+ # Add header comment to generated file
66
+ config_with_header = build_config_header(languages, supported, unsupported) + yaml_content
67
+
68
+ File.write(target_file, config_with_header)
69
+
70
+ # Provide success feedback
71
+ action = File.exist?(target_file) && options[:force] ? "Overwrote" : "Created"
72
+ logger.info("#{action} #{target_file} with configuration for: #{languages.join(', ')}")
73
+
74
+ logger.info("Languages with minimal support: #{unsupported.join(', ')}") if unsupported.any?
75
+ rescue SystemCallError => e
76
+ logger.error("Failed to initialize configuration: #{e.message}")
77
+ raise e
78
+ end
79
+ end
80
+
81
+ def initialize_system_prompt
82
+ target_file = Parabot::System.determine_system_path(".parabot.md", options[:project_root])
83
+
84
+ target_dir = File.dirname(target_file)
85
+ unless Dir.exist?(target_dir)
86
+ logger.error("Target directory doesnt exist: #{target_dir}")
87
+ raise Parabot::Error, "Target directory for system prompt does not exist"
88
+ end
89
+
90
+ # Check if target already exists
91
+ if File.exist?(target_file) && !options[:force]
92
+ logger.error("System prompt file #{target_file} already exists")
93
+ logger.info("Use --force to overwrite existing file")
94
+ raise Parabot::Error, "System prompt file already exists"
95
+ end
96
+
97
+ languages = detect_language
98
+ dry_run_log "Detected languages: #{languages.join(', ')}"
99
+ dry_run_log "Would write system prompt to #{target_file}"
100
+
101
+ # Get system prompt directly from source files, not from config that doesn't exist yet
102
+ prompt = build_system_prompt_for_languages(languages)
103
+ dry_run_log "System prompt:"
104
+ dry_run_log prompt
105
+
106
+ return if dry_run?
107
+
108
+ begin
109
+ File.write(target_file, prompt)
110
+
111
+ # Provide success feedback
112
+ action = File.exist?(target_file) && options[:force] ? "Overwrote" : "Created"
113
+ logger.info("#{action} #{target_file} with system prompt for #{languages.join(', ')}")
114
+
115
+ rescue SystemCallError => e
116
+ logger.error("Failed to initialize system prompt: #{e.message}")
117
+ raise e
118
+ end
119
+ end
120
+
121
+ def build_config_header(languages, supported, unsupported)
122
+ header = "# Generated configuration for: #{languages.join(', ')}\n"
123
+ header += "# \n"
124
+
125
+ if unsupported.any?
126
+ header += "# ⚠️ Languages with minimal support: #{unsupported.join(', ')}\n"
127
+ header += "# These languages use basic TDD guidance only.\n"
128
+ header += "# Consider contributing full language support at:\n"
129
+ header += "# https://github.com/AlexParamonov/parabot#contributing-language-support\n"
130
+ header += "#\n"
131
+ end
132
+
133
+ header += "# This file was generated by: parabot init\n"
134
+ header += "# Detected languages: #{languages.join(', ')}\n"
135
+ header += "# Full support: #{supported.join(', ')}\n" if supported.any?
136
+ header += "# Minimal support: #{unsupported.join(', ')}\n" if unsupported.any?
137
+ header += "\n"
138
+
139
+ header
140
+ end
141
+
142
+ def detect_language
143
+ if options[:language].any?
144
+ options[:language]
145
+ else
146
+ # Delegate to LanguageDetector with all languages loaded
147
+ LanguageDetector.detect_with_all_languages(options[:project_root] || ".")
148
+ end
149
+ end
150
+
151
+ def build_system_prompt_for_languages(languages)
152
+ # Read base system prompt
153
+ config_dir = File.join(__dir__, "../../../config")
154
+ base_content = File.read(File.join(config_dir, "core_prompts/system_prompt.yml"))
155
+ parsed = YAML.load(base_content)
156
+ base_prompt = parsed["system_prompt"]
157
+
158
+ # Add language-specific prompts
159
+ languages.map(&:to_s).each do |lang|
160
+ lang_file = File.join(config_dir, "languages/#{lang}.yml")
161
+ next unless File.exist?(lang_file)
162
+
163
+ lang_config = YAML.load_file(lang_file)
164
+ base_prompt += "\n\n#{lang_config['system_prompt']}" if lang_config["system_prompt"]
165
+ end
166
+
167
+ base_prompt
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ module Commands
5
+ class Message < Base
6
+ desc "Send a message to Claude"
7
+
8
+ argument :message, required: true, desc: "Message to send to Claude"
9
+
10
+ def call(message:, **options)
11
+ init(**options)
12
+
13
+ @message = message
14
+
15
+ send_message(message)
16
+ rescue TmuxError => e
17
+ logger.error("Failed to send message: #{e.message}")
18
+ raise
19
+ rescue StandardError => e
20
+ handle_unexpected_error(e)
21
+ raise
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ module Commands
5
+ class Start < Base
6
+ desc "Start Claude in a new tmux pane"
7
+
8
+ def call(**)
9
+ init(**)
10
+ dry_run_log "Would start Claude session using messaging adapter: #{config.messaging_adapter}"
11
+
12
+ start_claude_session
13
+ rescue TmuxError => e
14
+ handle_tmux_error(e)
15
+ rescue StandardError => e
16
+ handle_unexpected_error(e)
17
+ end
18
+
19
+ private
20
+
21
+ def start_claude_session
22
+ messaging_adapter.start_session()
23
+ end
24
+
25
+ def handle_tmux_error(error)
26
+ logger.error("Failed to start Claude session: #{error.message}")
27
+ end
28
+
29
+ def handle_unexpected_error(error)
30
+ logger.error("Unexpected error: #{error.message}")
31
+ logger.debug(error.backtrace.join("\n")) if options[:verbose]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ module Commands
5
+ class Test < Base
6
+ desc "Run tests and send results to Claude"
7
+
8
+ argument :files, type: :array, required: false, desc: "Test files or arguments to pass to test command"
9
+
10
+ def call(files: [], **options)
11
+ init(**options)
12
+
13
+ test_args = files || []
14
+ dry_run_log("Would run tests with args: #{test_args.join(' ')}")
15
+
16
+ test_results = execute_tests(test_args)
17
+ message = build_message(test_results)
18
+ send_message(message)
19
+
20
+ rescue TestExecutionError => e
21
+ logger.error("Test execution failed: #{e.message}")
22
+ raise
23
+ rescue StandardError => e
24
+ handle_unexpected_error(e)
25
+ raise
26
+ end
27
+
28
+ private
29
+
30
+ def execute_tests(test_args)
31
+ logger.info("Running tests with command detection...")
32
+
33
+ test_runner.run_tests(args: test_args).tap do |test_results|
34
+ raise TestExecutionError unless test_results
35
+ end
36
+ end
37
+
38
+ def build_message(test_results)
39
+ config.test_analysis_prompt.gsub("{{results}}", test_results.output)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ module Commands
5
+ class Version < Base
6
+ desc "Show version information"
7
+
8
+ def call(**)
9
+ puts "Parabot Ruby CLI v#{Parabot::VERSION}"
10
+ puts "A modern Ruby implementation of the Parabot TDD assistant"
11
+ puts
12
+ puts "Ruby version: #{RUBY_VERSION}"
13
+ puts "Platform: #{RUBY_PLATFORM}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/base"
4
+ require_relative "commands/init"
5
+ require_relative "commands/start"
6
+ require_relative "commands/test"
7
+ require_relative "commands/message"
8
+ require_relative "commands/custom_commands"
9
+ require_relative "commands/version"
10
+ require_relative "commands/doctor"
11
+
12
+ module Parabot
13
+ module Commands
14
+ end
15
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "config"
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ require_relative "errors"
8
+ require_relative "language_detector"
9
+ require_relative "system"
10
+
11
+ module Parabot
12
+ class Configuration
13
+ class << self
14
+ def load(file = nil, project_root = nil)
15
+ @instance ||= new(project_root).tap { |config| config.load_config(file) }
16
+ end
17
+
18
+ def current
19
+ @instance || load
20
+ end
21
+
22
+ def reset!
23
+ @instance = nil
24
+ end
25
+ end
26
+
27
+ attr_reader :config, :project_root
28
+
29
+ def initialize(project_root = nil)
30
+ @project_root = project_root || Dir.pwd
31
+ setup_config
32
+ end
33
+
34
+ def load_config(file = nil)
35
+ files = file ? resolve_config_file_path(file)
36
+ : config_files
37
+
38
+ Config.load_and_set_settings(*files)
39
+
40
+ @config = Settings
41
+ rescue StandardError => e
42
+ raise ConfigurationError, "Failed to load configuration: #{e.message}"
43
+ end
44
+
45
+ # Configuration accessors
46
+ def log_level
47
+ config.log_level&.to_sym || :warn
48
+ end
49
+
50
+ def log_file
51
+ config.log_file || "stderr"
52
+ end
53
+
54
+ def test_command(language)
55
+ # Get test command from language-specific configuration
56
+ lang_config = config.languages&.public_send(language.to_sym)
57
+ lang_config&.test_command
58
+ end
59
+
60
+ def custom_commands
61
+ config.commands&.to_h || {}
62
+ end
63
+
64
+ def language
65
+ config.language || "auto"
66
+ end
67
+
68
+ def all_language_extensions
69
+ return {} unless config.languages
70
+
71
+ @all_language_extensions ||=
72
+ config.languages.map do |lang, lang_config|
73
+ extensions = lang_config&.file_extensions&.to_a || []
74
+ [lang, extensions] unless extensions.empty?
75
+ end.compact.to_h
76
+ end
77
+
78
+ def all_language_project_files
79
+ return {} unless config.languages
80
+
81
+ result = {}
82
+ config.languages.each do |lang, lang_config|
83
+ project_files = lang_config&.project_files&.to_a || []
84
+ result[lang] = project_files unless project_files.empty?
85
+ end
86
+ result
87
+ rescue StandardError
88
+ {}
89
+ end
90
+
91
+ # Always returns: base_prompt + language_specific_addition
92
+ def system_prompt(detected_languages = [])
93
+ base_prompt = config.system_prompt || ""
94
+ languages = Array(detected_languages)
95
+
96
+ language_addition =
97
+ languages.map do |lang|
98
+ language_specific_system_prompt(lang)
99
+ end.reject(&:empty?).join("\n")
100
+
101
+ base_prompt + maybe_multi_language_notice(languages) + language_addition
102
+ end
103
+
104
+ def test_analysis_prompt
105
+ test_guidance = config.test_guidance
106
+
107
+ template = <<~TEMPLATE
108
+ Analyze TEST RESULTS:
109
+ <test_results_file>
110
+ {{results}}
111
+ </test_results_file>
112
+
113
+ #{test_guidance}
114
+ TEMPLATE
115
+
116
+ template.strip
117
+ end
118
+
119
+ def test_guidance(language)
120
+ config.languages&.public_send(language.to_s)&.test_guidance
121
+ end
122
+
123
+ def messaging_adapter
124
+ config.adapter&.to_sym || :tmux
125
+ end
126
+
127
+ private
128
+
129
+ def resolve_config_file_path(file)
130
+ Parabot::System.determine_path(file, project_root).tap do |resolved_file|
131
+ logger.warn("file #{resolved_file} not found") unless File.exist?(resolved_file)
132
+ end
133
+ end
134
+
135
+ def setup_config
136
+ Config.setup do |config|
137
+ config.const_name = "Settings"
138
+ config.use_env = true
139
+ config.env_prefix = "PARABOT"
140
+ config.env_separator = "_"
141
+ end
142
+ end
143
+
144
+ def config_files
145
+ [
146
+ Pathname.new(Dir.home) / ".parabot.yml",
147
+ Pathname.new(Dir.home) / ".config" / "parabot" / ".parabot.yml",
148
+ Parabot::System.determine_system_path(".parabot.yml", project_root)
149
+ ].map do |path|
150
+ path.to_s if File.exist?(path)
151
+ end.compact
152
+ end
153
+
154
+ def resolve_log_file_path
155
+ path = Pathname.new(log_file)
156
+ return path.to_s if path.absolute?
157
+
158
+ (Pathname.new(project_root) / path).to_s
159
+ end
160
+
161
+ def ensure_log_directory(file_path)
162
+ dir = File.dirname(file_path)
163
+ return if Dir.exist?(dir)
164
+
165
+ require "fileutils"
166
+ FileUtils.mkdir_p(dir)
167
+ rescue SystemCallError => e
168
+ raise ConfigurationError, "Cannot create log directory #{dir}: #{e.message}"
169
+ end
170
+
171
+ def logger
172
+ require "semantic_logger"
173
+ SemanticLogger[self.class.name]
174
+ end
175
+
176
+ # Multi-language prompt support methods
177
+ def language_specific_system_prompt(language)
178
+ return "" unless language.is_a?(Symbol)
179
+
180
+ lang_config = config.languages&.public_send(language.to_s)
181
+ return "" unless lang_config&.system_prompt
182
+
183
+ "\n\n#{lang_config.system_prompt}"
184
+ end
185
+
186
+ def language_specific_test_guidance(language)
187
+ return "" unless language.is_a?(Symbol)
188
+
189
+ lang_config = config.languages&.public_send(language.to_s)
190
+ lang_config&.test_guidance || ""
191
+ end
192
+
193
+ def maybe_multi_language_notice(languages)
194
+ return "" if Array(languages).size < 2
195
+
196
+ "\n\nMULTI-LANGUAGE PROJECT: #{languages.join(', ')}\nApply appropriate language conventions for each.\n"
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ # Module for dry run logging functionality
5
+ # Provides a clean interface for logging dry run operations
6
+ module DryRunLogging
7
+ private
8
+
9
+ def dry_run_log(message)
10
+ return logger.debug(message) unless dry_run?
11
+
12
+ message ||= "nil"
13
+
14
+ # for every line in the message, adds "DRY RUN: " prefix
15
+ puts message.split("\n").map { |line| "DRY RUN: #{line}" }.join("\n")
16
+ end
17
+
18
+ def dry_run?
19
+ !!@dry_run
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+ class TmuxError < Error; end
8
+ class TestExecutionError < Error; end
9
+ class LanguageDetectionError < Error; end
10
+ class CommandError < Error; end
11
+ class ValidationError < Error; end
12
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ module Parabot
9
+ class LanguageDetector
10
+ def initialize(config = Configuration.current)
11
+ @config = config
12
+ @project_root = config.project_root
13
+ end
14
+
15
+ # Class method for detecting languages with all available language configs loaded
16
+ # Used by init command and other contexts that need complete language detection
17
+ def self.detect_with_all_languages(project_root = Dir.pwd)
18
+ config = create_full_language_config(project_root)
19
+ detector = new(config)
20
+ detector.detect_all_languages(project_root)
21
+ end
22
+
23
+ private_class_method def self.create_full_language_config(project_root)
24
+ # Load all available language configs
25
+ config_dir = File.join(__dir__, "../../config")
26
+ languages = {}
27
+
28
+ Dir.glob(File.join(config_dir, "languages/*.yml")).each do |lang_file|
29
+ lang_name = File.basename(lang_file, ".yml")
30
+ languages[lang_name] = YAML.load_file(lang_file)
31
+ end
32
+
33
+ # Create a minimal config object that supports language detection
34
+ Class.new do
35
+ def initialize(languages, project_root)
36
+ @languages = languages
37
+ @project_root = project_root
38
+ end
39
+
40
+ attr_reader :project_root
41
+
42
+ def all_language_extensions
43
+ @languages.transform_values { |lang_config| lang_config["file_extensions"] }
44
+ end
45
+
46
+ def all_language_project_files
47
+ result = {}
48
+ @languages.each do |lang, lang_config|
49
+ project_files = lang_config["project_files"] || []
50
+ result[lang] = project_files unless project_files.empty?
51
+ end
52
+ result
53
+ end
54
+
55
+ def detect_language_from_extensions(extensions)
56
+ all_language_extensions.each do |lang, lang_extensions|
57
+ next if lang_extensions.nil?
58
+ return lang if (lang_extensions & extensions).any?
59
+ end
60
+ nil
61
+ end
62
+ end.new(languages, project_root)
63
+ end
64
+
65
+ def detect_all_languages(path = project_root)
66
+ detected_languages = Set.new
67
+
68
+ # 1. Check for definitive project files first (always included)
69
+ detected_languages.merge(detect_from_project_files(path))
70
+
71
+ # 2. Get extensions with counts, filter by threshold to avoid fixture noise
72
+ detected_languages.merge(detect_from_extensions_with_threshold(path))
73
+
74
+ # 3. Return sorted array or [:unknown] if empty
75
+ detected_languages.empty? ? [:unknown] : detected_languages.to_a.sort
76
+ end
77
+
78
+ # Find language from file extensions
79
+ def find_language_from_extensions(extensions)
80
+ return nil if extensions.empty?
81
+
82
+ config.all_language_extensions.each do |lang, lang_extensions|
83
+ next if lang_extensions.empty?
84
+ return lang.to_s if (lang_extensions & extensions).any?
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ attr_reader :config, :project_root
93
+
94
+ def detect_from_project_files(path)
95
+ return [] unless config.all_language_project_files
96
+ return [] unless File.directory?(path.to_s)
97
+
98
+ escaped_path = Shellwords.escape(path.to_s)
99
+ all_files = `git -C #{escaped_path} ls-files --cached --others --exclude-standard 2>/dev/null`.split("\n")
100
+
101
+ detected = []
102
+ config.all_language_project_files.each do |lang, project_files|
103
+ project_files.each do |project_file|
104
+ if all_files.include?(project_file)
105
+ detected << lang.to_sym
106
+ break # Found this language, move to next
107
+ end
108
+ end
109
+ end
110
+
111
+ detected
112
+ rescue StandardError
113
+ []
114
+ end
115
+
116
+ def detect_from_extensions_with_threshold(path)
117
+ extension_counts = get_extension_counts(path)
118
+ return [] if extension_counts.empty?
119
+
120
+ # Only extensions with 2+ files (duplicates) indicate significant codebases
121
+ # This automatically filters out single fixture files
122
+ significant_extensions = extension_counts.keys
123
+
124
+ # Map extensions to languages using our own method
125
+ significant_extensions.filter_map do |ext|
126
+ lang = find_language_from_extensions([ext])
127
+ lang&.to_sym
128
+ end
129
+
130
+ rescue StandardError
131
+ []
132
+ end
133
+
134
+ def get_extension_counts(path)
135
+ return {} unless File.directory?(path.to_s)
136
+
137
+ escaped_path = Shellwords.escape(path.to_s)
138
+ # Get extensions with 2+ occurrences (avoid single fixture files)
139
+ output = `git -C #{escaped_path} ls-files --cached --others --exclude-standard | rev | cut -d . -f1 | rev | sort | uniq -cd | sort -gr | head -10 2>/dev/null`
140
+
141
+ # Parse "count extension" format from uniq -cd output
142
+ counts = {}
143
+ output.split("\n").each do |line|
144
+ parts = line.strip.split
145
+ next unless parts.size == 2
146
+
147
+ count, ext = parts[0].to_i, parts[1]
148
+ next if ext.empty? || count <= 0
149
+
150
+ counts[".#{ext}"] = count
151
+ end
152
+
153
+ counts
154
+ rescue StandardError
155
+ {}
156
+ end
157
+ end
158
+ end