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.
- checksums.yaml +7 -0
- data/.rubocop.yml +55 -0
- data/CLAUDE.md +171 -0
- data/DISTRIBUTION.md +287 -0
- data/INSTALL.md +242 -0
- data/LICENSE +21 -0
- data/README.md +371 -0
- data/Rakefile +50 -0
- data/config/base.yml +12 -0
- data/config/commands.yml +28 -0
- data/config/core_prompts/system_prompt.yml +66 -0
- data/config/core_prompts/test_guidance.yml +24 -0
- data/config/languages/elixir.yml +62 -0
- data/config/languages/javascript.yml +64 -0
- data/config/languages/kotlin.yml +64 -0
- data/config/languages/ruby.yml +66 -0
- data/config/languages/shell.yml +63 -0
- data/config/languages/typescript.yml +63 -0
- data/exe/parabot +22 -0
- data/lib/parabot/cli/argument_parser.rb +105 -0
- data/lib/parabot/cli/command_router.rb +114 -0
- data/lib/parabot/cli.rb +71 -0
- data/lib/parabot/commands/base.rb +108 -0
- data/lib/parabot/commands/custom_commands.rb +63 -0
- data/lib/parabot/commands/doctor.rb +196 -0
- data/lib/parabot/commands/init.rb +171 -0
- data/lib/parabot/commands/message.rb +25 -0
- data/lib/parabot/commands/start.rb +35 -0
- data/lib/parabot/commands/test.rb +43 -0
- data/lib/parabot/commands/version.rb +17 -0
- data/lib/parabot/commands.rb +15 -0
- data/lib/parabot/configuration.rb +199 -0
- data/lib/parabot/dry_run_logging.rb +22 -0
- data/lib/parabot/errors.rb +12 -0
- data/lib/parabot/language_detector.rb +158 -0
- data/lib/parabot/language_inference.rb +82 -0
- data/lib/parabot/logging_setup.rb +73 -0
- data/lib/parabot/messaging/adapter.rb +53 -0
- data/lib/parabot/messaging/adapter_factory.rb +33 -0
- data/lib/parabot/messaging/dry_run_adapter.rb +55 -0
- data/lib/parabot/messaging/tmux_adapter.rb +82 -0
- data/lib/parabot/system.rb +41 -0
- data/lib/parabot/test_runner.rb +179 -0
- data/lib/parabot/tmux_manager.rb +245 -0
- data/lib/parabot/version.rb +5 -0
- data/lib/parabot/yaml_text_assembler.rb +155 -0
- data/lib/parabot.rb +30 -0
- data/parabot.gemspec +44 -0
- data/scripts/build-distribution +122 -0
- data/scripts/install +152 -0
- 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
|