better_translate 1.0.0.1 → 1.1.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +28 -0
  3. data/.rubocop_todo.yml +291 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +12 -7
  6. data/CONTRIBUTING.md +432 -0
  7. data/README.md +240 -1
  8. data/Rakefile +14 -1
  9. data/SECURITY.md +160 -0
  10. data/Steepfile +0 -1
  11. data/brakeman.yml +37 -0
  12. data/codecov.yml +34 -0
  13. data/lib/better_translate/analyzer/code_scanner.rb +149 -0
  14. data/lib/better_translate/analyzer/key_scanner.rb +109 -0
  15. data/lib/better_translate/analyzer/orphan_detector.rb +91 -0
  16. data/lib/better_translate/analyzer/reporter.rb +155 -0
  17. data/lib/better_translate/cli.rb +81 -2
  18. data/lib/better_translate/configuration.rb +76 -3
  19. data/lib/better_translate/errors.rb +9 -0
  20. data/lib/better_translate/json_handler.rb +227 -0
  21. data/lib/better_translate/translator.rb +205 -23
  22. data/lib/better_translate/version.rb +1 -1
  23. data/lib/better_translate/yaml_handler.rb +59 -0
  24. data/lib/better_translate.rb +7 -0
  25. data/lib/generators/better_translate/install/install_generator.rb +2 -2
  26. data/lib/generators/better_translate/install/templates/initializer.rb.tt +22 -34
  27. data/lib/generators/better_translate/translate/translate_generator.rb +65 -46
  28. data/lib/tasks/better_translate.rake +62 -45
  29. data/sig/better_translate/analyzer/code_scanner.rbs +59 -0
  30. data/sig/better_translate/analyzer/key_scanner.rbs +40 -0
  31. data/sig/better_translate/analyzer/orphan_detector.rbs +43 -0
  32. data/sig/better_translate/analyzer/reporter.rbs +70 -0
  33. data/sig/better_translate/cli.rbs +2 -0
  34. data/sig/better_translate/configuration.rbs +6 -0
  35. data/sig/better_translate/errors.rbs +4 -0
  36. data/sig/better_translate/json_handler.rbs +65 -0
  37. data/sig/better_translate/progress_tracker.rbs +1 -1
  38. data/sig/better_translate/translator.rbs +12 -1
  39. data/sig/better_translate/yaml_handler.rbs +6 -0
  40. data/sig/better_translate.rbs +4 -0
  41. data/sig/csv.rbs +16 -0
  42. metadata +32 -3
  43. data/regenerate_vcr.rb +0 -47
data/SECURITY.md ADDED
@@ -0,0 +1,160 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ We release patches for security vulnerabilities. Currently supported versions:
6
+
7
+ | Version | Supported |
8
+ | ------- | ------------------ |
9
+ | 1.1.x | :white_check_mark: |
10
+ | 1.0.x | :white_check_mark: |
11
+ | < 1.0 | :x: |
12
+
13
+ ## Security Measures
14
+
15
+ BetterTranslate implements multiple security measures to protect your data and application:
16
+
17
+ ### 🔒 Static Security Analysis
18
+ - **Brakeman**: Automated security scanner running on every commit
19
+ - Checks for 76+ security vulnerabilities including:
20
+ - SQL Injection
21
+ - Cross-Site Scripting (XSS)
22
+ - Command Injection
23
+ - File Access vulnerabilities
24
+ - Unsafe Deserialization
25
+ - Mass Assignment issues
26
+
27
+ ### 🛡️ Dependency Security
28
+ - **Bundler Audit**: Regular checks for vulnerable dependencies
29
+ - Automated dependency updates via Dependabot (if configured)
30
+ - Minimal runtime dependencies (only Faraday)
31
+
32
+ ### 🔐 API Key Protection
33
+ - API keys are never logged or stored in code
34
+ - VCR cassettes automatically anonymize API keys
35
+ - `.env` files are git-ignored by default
36
+ - Comprehensive validation prevents key exposure
37
+
38
+ ### ✅ Code Quality
39
+ - **RuboCop**: Style and security linting
40
+ - **Steep**: Static type checking
41
+ - 93%+ test coverage with comprehensive test suite
42
+ - Type-safe configuration with validation
43
+
44
+ ## Reporting a Vulnerability
45
+
46
+ We take security seriously. If you discover a security vulnerability, please follow these steps:
47
+
48
+ ### 🚨 **DO NOT** disclose the vulnerability publicly
49
+
50
+ Please report security vulnerabilities privately to protect users.
51
+
52
+ ### 📧 How to Report
53
+
54
+ **Email**: alessio.bussolari@pandev.it
55
+
56
+ **Subject**: `[SECURITY] BetterTranslate Vulnerability Report`
57
+
58
+ **Include in your report**:
59
+ 1. **Description** of the vulnerability
60
+ 2. **Steps to reproduce** the issue
61
+ 3. **Potential impact** and attack scenarios
62
+ 4. **Suggested fix** (if you have one)
63
+ 5. **Your contact information** for follow-up
64
+
65
+ ### ⏱️ Response Timeline
66
+
67
+ - **Initial Response**: Within 48 hours
68
+ - **Status Update**: Within 7 days
69
+ - **Fix Timeline**: Depending on severity
70
+ - Critical: 24-48 hours
71
+ - High: 7 days
72
+ - Medium: 30 days
73
+ - Low: 90 days
74
+
75
+ ### 🎁 Recognition
76
+
77
+ We appreciate security researchers who responsibly disclose vulnerabilities:
78
+
79
+ - Your name will be credited in our CHANGELOG (unless you prefer to remain anonymous)
80
+ - We may offer a "Hall of Fame" mention in this file
81
+ - Significant findings may be eligible for acknowledgment in release notes
82
+
83
+ ## Security Best Practices
84
+
85
+ When using BetterTranslate:
86
+
87
+ ### ✅ Recommended Practices
88
+
89
+ 1. **API Keys**:
90
+ - Store API keys in environment variables
91
+ - Use `.env` files (never commit them)
92
+ - Rotate keys regularly
93
+ - Use separate keys for dev/staging/production
94
+
95
+ 2. **Configuration**:
96
+ - Validate all configuration before use
97
+ - Use `config.validate!` explicitly
98
+ - Review exclusion lists for sensitive data
99
+ - Enable dry_run mode for testing
100
+
101
+ 3. **File Permissions**:
102
+ - Restrict access to locale files
103
+ - Review backup files (`.bak`) security
104
+ - Use appropriate file permissions (644 for files, 755 for directories)
105
+
106
+ 4. **Dependencies**:
107
+ - Run `bundle audit` regularly
108
+ - Keep gems updated
109
+ - Review CHANGELOG for security updates
110
+
111
+ ### ❌ Avoid These Mistakes
112
+
113
+ 1. **DO NOT** hardcode API keys in source code
114
+ 2. **DO NOT** commit `.env` files to version control
115
+ 3. **DO NOT** expose translation API keys in client-side code
116
+ 4. **DO NOT** disable SSL verification in production
117
+ 5. **DO NOT** ignore Brakeman or RuboCop security warnings
118
+
119
+ ## Security Scanning
120
+
121
+ ### Run Security Checks Locally
122
+
123
+ ```bash
124
+ # Run Brakeman security scanner
125
+ bundle exec rake brakeman
126
+
127
+ # Check for vulnerable dependencies
128
+ bundle exec bundler-audit check --update
129
+
130
+ # Run full test suite with security checks
131
+ bundle exec rake # includes spec, rubocop, steep, brakeman
132
+ ```
133
+
134
+ ### Continuous Integration
135
+
136
+ Our CI pipeline automatically runs:
137
+ - Brakeman security scanner
138
+ - RuboCop with security cops
139
+ - Steep type checking
140
+ - Comprehensive test suite (541 tests)
141
+ - Code coverage analysis (93%+)
142
+
143
+ ## Additional Resources
144
+
145
+ - [OWASP Top 10](https://owasp.org/www-project-top-ten/)
146
+ - [Ruby Security](https://ruby-lang.org/en/security/)
147
+ - [Brakeman Documentation](https://brakemanscanner.org/docs/)
148
+ - [Bundler Audit](https://github.com/rubysec/bundler-audit)
149
+
150
+ ## Security Hall of Fame
151
+
152
+ Thank you to these security researchers who helped improve BetterTranslate:
153
+
154
+ <!-- Future contributors will be listed here -->
155
+ _No vulnerabilities reported yet._
156
+
157
+ ---
158
+
159
+ **Last Updated**: 2025-10-23
160
+ **Contact**: alessio.bussolari@pandev.it
data/Steepfile CHANGED
@@ -19,7 +19,6 @@ target :lib do
19
19
  library "pathname"
20
20
  library "monitor"
21
21
  library "logger"
22
- library "set"
23
22
  library "json"
24
23
  library "yaml"
25
24
  library "securerandom"
data/brakeman.yml ADDED
@@ -0,0 +1,37 @@
1
+ # Brakeman configuration file
2
+ # https://brakemanscanner.org/docs/options/
3
+
4
+ # Set application path (defaults to current directory)
5
+ :app_path: "."
6
+
7
+ # Set Rails version (detected automatically)
8
+ # :rails_version: "8.1.0"
9
+
10
+ # Enable additional security checks
11
+ :force_scan: true
12
+
13
+ # Show all files processed
14
+ :report_progress: true
15
+
16
+ # Skip certain checks (none skipped by default)
17
+ :skip_checks: []
18
+
19
+ # Only run specific checks (empty means run all)
20
+ :run_checks: []
21
+
22
+ # Set confidence levels to report (1=High, 2=Medium, 3=Weak)
23
+ :min_confidence: 2
24
+
25
+ # Ignore specific warnings
26
+ :ignore_file: ".brakeman.ignore"
27
+
28
+ # Additional paths to scan
29
+ :additional_checks_path: []
30
+
31
+ # Paths to exclude from scanning
32
+ :skip_files:
33
+ - "spec/"
34
+ - "test/"
35
+
36
+ # Exit with error code if warnings found
37
+ :exit_on_warn: true
data/codecov.yml ADDED
@@ -0,0 +1,34 @@
1
+ # Codecov configuration
2
+ # Documentation: https://docs.codecov.com/docs/codecov-yaml
3
+
4
+ codecov:
5
+ require_ci_to_pass: yes
6
+
7
+ coverage:
8
+ precision: 2
9
+ round: down
10
+ range: "70...100"
11
+
12
+ status:
13
+ project:
14
+ default:
15
+ target: 90%
16
+ threshold: 1%
17
+ if_ci_failed: error
18
+
19
+ patch:
20
+ default:
21
+ target: 90%
22
+ threshold: 1%
23
+
24
+ comment:
25
+ layout: "reach,diff,flags,tree,footer"
26
+ behavior: default
27
+ require_changes: false
28
+ require_base: false
29
+ require_head: true
30
+
31
+ ignore:
32
+ - "spec/**/*"
33
+ - "vendor/**/*"
34
+ - "gemfiles/**/*"
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterTranslate
4
+ module Analyzer
5
+ # Scans code files to find i18n key references
6
+ #
7
+ # Supports multiple patterns:
8
+ # - t('key') / t("key")
9
+ # - I18n.t(:key) / I18n.t('key')
10
+ # - <%= t('key') %> in ERB
11
+ #
12
+ # @example Basic usage
13
+ # scanner = CodeScanner.new("app/")
14
+ # keys = scanner.scan
15
+ # #=> #<Set: {"users.greeting", "products.list"}>
16
+ #
17
+ class CodeScanner
18
+ # I18n patterns to match
19
+ #
20
+ # Matches:
21
+ # - t('users.greeting')
22
+ # - t("users.greeting")
23
+ # - I18n.t(:users.greeting)
24
+ # - I18n.t('users.greeting')
25
+ # - I18n.translate('users.greeting')
26
+ I18N_PATTERNS = [
27
+ /\bt\(['"]([a-z0-9_.]+)['"]/i, # t('key') or t("key")
28
+ /\bI18n\.t\(:([a-z0-9_.]+)/i, # I18n.t(:key)
29
+ /\bI18n\.t\(['"]([a-z0-9_.]+)['"]/i, # I18n.t('key')
30
+ /\bI18n\.translate\(['"]([a-z0-9_.]+)['"]/i # I18n.translate('key')
31
+ ].freeze
32
+
33
+ # File extensions to scan
34
+ SCANNABLE_EXTENSIONS = %w[.rb .erb .html.erb .haml .slim].freeze
35
+
36
+ # @return [String] Path to scan (file or directory)
37
+ attr_reader :path
38
+
39
+ # @return [Set] Found i18n keys
40
+ attr_reader :keys
41
+
42
+ # @return [Array<String>] List of scanned files
43
+ attr_reader :files_scanned
44
+
45
+ # Initialize scanner with path
46
+ #
47
+ # @param path [String] File or directory path to scan
48
+ #
49
+ def initialize(path)
50
+ @path = path
51
+ @keys = Set.new
52
+ @files_scanned = []
53
+ end
54
+
55
+ # Scan path and extract i18n keys
56
+ #
57
+ # @return [Set] Set of found i18n keys
58
+ # @raise [FileError] if path does not exist
59
+ #
60
+ # @example
61
+ # scanner = CodeScanner.new("app/")
62
+ # keys = scanner.scan
63
+ # #=> #<Set: {"users.greeting"}>
64
+ #
65
+ def scan
66
+ validate_path!
67
+
68
+ files = collect_files
69
+ files.each do |file|
70
+ scan_file(file)
71
+ @files_scanned << file
72
+ end
73
+
74
+ @keys
75
+ end
76
+
77
+ # Get count of unique keys found
78
+ #
79
+ # @return [Integer] Number of unique keys
80
+ #
81
+ def key_count
82
+ @keys.size
83
+ end
84
+
85
+ private
86
+
87
+ # Validate that path exists
88
+ #
89
+ # @raise [FileError] if path does not exist
90
+ #
91
+ def validate_path!
92
+ return if File.exist?(path)
93
+
94
+ raise FileError.new(
95
+ "Path does not exist: #{path}",
96
+ context: { path: path }
97
+ )
98
+ end
99
+
100
+ # Collect all scannable files from path
101
+ #
102
+ # @return [Array<String>] List of file paths
103
+ #
104
+ def collect_files
105
+ if File.file?(path)
106
+ return [path] if scannable_file?(path)
107
+
108
+ return []
109
+ end
110
+
111
+ Dir.glob(File.join(path, "**", "*")).select do |file|
112
+ File.file?(file) && scannable_file?(file)
113
+ end
114
+ end
115
+
116
+ # Check if file should be scanned
117
+ #
118
+ # @param file [String] File path
119
+ # @return [Boolean]
120
+ #
121
+ def scannable_file?(file)
122
+ SCANNABLE_EXTENSIONS.any? { |ext| file.end_with?(ext) }
123
+ end
124
+
125
+ # Scan single file and extract keys
126
+ #
127
+ # @param file [String] File path
128
+ #
129
+ def scan_file(file)
130
+ content = File.read(file)
131
+
132
+ # Remove commented lines to avoid false positives
133
+ lines = content.split("\n")
134
+ active_lines = lines.reject { |line| line.strip.start_with?("#", "//", "<!--") }
135
+ active_content = active_lines.join("\n")
136
+
137
+ I18N_PATTERNS.each do |pattern|
138
+ active_content.scan(pattern) do |match|
139
+ key = match.is_a?(Array) ? match.first : match
140
+ @keys.add(key) if key
141
+ end
142
+ end
143
+ rescue StandardError => e
144
+ # Skip files that can't be read
145
+ warn "Warning: Could not scan #{file}: #{e.message}" if ENV["VERBOSE"]
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module BetterTranslate
6
+ module Analyzer
7
+ # Scans YAML translation files and extracts all keys in flatten format
8
+ #
9
+ # @example Basic usage
10
+ # scanner = KeyScanner.new("config/locales/en.yml")
11
+ # keys = scanner.scan
12
+ # #=> { "users.greeting" => "Hello", "users.welcome" => "Welcome %{name}" }
13
+ #
14
+ class KeyScanner
15
+ # @return [String] Path to the YAML file
16
+ attr_reader :file_path
17
+
18
+ # @return [Hash] Flatten keys extracted from YAML
19
+ attr_reader :keys
20
+
21
+ # Initialize scanner with YAML file path
22
+ #
23
+ # @param file_path [String] Path to YAML file
24
+ #
25
+ def initialize(file_path)
26
+ @file_path = file_path
27
+ @keys = {}
28
+ end
29
+
30
+ # Scan YAML file and extract all flatten keys
31
+ #
32
+ # @return [Hash] Flatten keys with their values
33
+ # @raise [FileError] if file does not exist
34
+ # @raise [YamlError] if YAML is invalid
35
+ #
36
+ # @example
37
+ # scanner = KeyScanner.new("en.yml")
38
+ # keys = scanner.scan
39
+ # #=> { "users.greeting" => "Hello" }
40
+ #
41
+ def scan
42
+ validate_file!
43
+
44
+ begin
45
+ content = YAML.load_file(file_path)
46
+
47
+ # Skip root language key (en, it, fr, etc.) and start from its content
48
+ if content.is_a?(Hash) && content.size == 1
49
+ root_key = content.keys.first.to_s
50
+ content = content[root_key] || {} if root_key.match?(/^[a-z]{2}(-[A-Z]{2})?$/)
51
+ end
52
+
53
+ flatten_keys(content)
54
+ rescue Psych::SyntaxError => e
55
+ raise YamlError.new(
56
+ "Invalid YAML syntax in #{file_path}",
57
+ context: { file: file_path, error: e.message }
58
+ )
59
+ end
60
+
61
+ @keys
62
+ end
63
+
64
+ # Get total count of keys
65
+ #
66
+ # @return [Integer] Number of keys
67
+ #
68
+ def key_count
69
+ @keys.size
70
+ end
71
+
72
+ private
73
+
74
+ # Validate that file exists
75
+ #
76
+ # @raise [FileError] if file does not exist
77
+ #
78
+ def validate_file!
79
+ return if File.exist?(file_path)
80
+
81
+ raise FileError.new(
82
+ "Translation file does not exist: #{file_path}",
83
+ context: { file: file_path }
84
+ )
85
+ end
86
+
87
+ # Flatten nested hash into dot-notation keys
88
+ #
89
+ # @param hash [Hash] Nested hash to flatten
90
+ # @param prefix [String] Prefix for current level
91
+ #
92
+ # @example
93
+ # flatten_keys({ "users" => { "greeting" => "Hello" } })
94
+ # #=> { "users.greeting" => "Hello" }
95
+ #
96
+ def flatten_keys(hash, prefix = nil)
97
+ hash.each do |key, value|
98
+ current_key = prefix ? "#{prefix}.#{key}" : key.to_s
99
+
100
+ if value.is_a?(Hash)
101
+ flatten_keys(value, current_key)
102
+ else
103
+ @keys[current_key] = value
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterTranslate
4
+ module Analyzer
5
+ # Detects orphan i18n keys (keys defined but never used in code)
6
+ #
7
+ # @example Basic usage
8
+ # all_keys = { "users.greeting" => "Hello", "orphan" => "Unused" }
9
+ # used_keys = Set.new(["users.greeting"])
10
+ #
11
+ # detector = OrphanDetector.new(all_keys, used_keys)
12
+ # orphans = detector.detect
13
+ # #=> ["orphan"]
14
+ #
15
+ class OrphanDetector
16
+ # @return [Hash] All translation keys with their values
17
+ attr_reader :all_keys
18
+
19
+ # @return [Set] Keys that are used in code
20
+ attr_reader :used_keys
21
+
22
+ # @return [Array<String>] Orphan keys (not used in code)
23
+ attr_reader :orphans
24
+
25
+ # Initialize detector
26
+ #
27
+ # @param all_keys [Hash] All translation keys from YAML files
28
+ # @param used_keys [Set] Keys found in code
29
+ #
30
+ def initialize(all_keys, used_keys)
31
+ @all_keys = all_keys
32
+ @used_keys = used_keys
33
+ @orphans = []
34
+ end
35
+
36
+ # Detect orphan keys
37
+ #
38
+ # Compares all keys with used keys and identifies those that are never referenced.
39
+ #
40
+ # @return [Array<String>] List of orphan key names
41
+ #
42
+ # @example
43
+ # detector.detect
44
+ # #=> ["orphan_key", "another.orphan"]
45
+ #
46
+ def detect
47
+ @orphans = all_keys.keys.reject { |key| used_keys.include?(key) }
48
+ end
49
+
50
+ # Get count of orphan keys
51
+ #
52
+ # @return [Integer] Number of orphan keys
53
+ #
54
+ def orphan_count
55
+ @orphans.size
56
+ end
57
+
58
+ # Get details of orphan keys with their values
59
+ #
60
+ # @return [Hash] Hash of orphan keys and their translation values
61
+ #
62
+ # @example
63
+ # detector.orphan_details
64
+ # #=> { "orphan_key" => "This is never used" }
65
+ #
66
+ def orphan_details
67
+ # @type var result: Hash[String, untyped]
68
+ result = {}
69
+ @orphans.each do |key|
70
+ result[key] = all_keys[key]
71
+ end
72
+ result
73
+ end
74
+
75
+ # Calculate usage percentage
76
+ #
77
+ # @return [Float] Percentage of keys that are used (0.0 to 100.0)
78
+ #
79
+ # @example
80
+ # detector.usage_percentage
81
+ # #=> 75.0 # 6 out of 8 keys are used
82
+ #
83
+ def usage_percentage
84
+ return 0.0 if all_keys.empty?
85
+
86
+ used_count = all_keys.size - @orphans.size
87
+ (used_count.to_f / all_keys.size * 100).round(1).to_f
88
+ end
89
+ end
90
+ end
91
+ end