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.
- checksums.yaml +4 -4
- data/.rubocop.yml +28 -0
- data/.rubocop_todo.yml +291 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +12 -7
- data/CONTRIBUTING.md +432 -0
- data/README.md +240 -1
- data/Rakefile +14 -1
- data/SECURITY.md +160 -0
- data/Steepfile +0 -1
- data/brakeman.yml +37 -0
- data/codecov.yml +34 -0
- data/lib/better_translate/analyzer/code_scanner.rb +149 -0
- data/lib/better_translate/analyzer/key_scanner.rb +109 -0
- data/lib/better_translate/analyzer/orphan_detector.rb +91 -0
- data/lib/better_translate/analyzer/reporter.rb +155 -0
- data/lib/better_translate/cli.rb +81 -2
- data/lib/better_translate/configuration.rb +76 -3
- data/lib/better_translate/errors.rb +9 -0
- data/lib/better_translate/json_handler.rb +227 -0
- data/lib/better_translate/translator.rb +205 -23
- data/lib/better_translate/version.rb +1 -1
- data/lib/better_translate/yaml_handler.rb +59 -0
- data/lib/better_translate.rb +7 -0
- data/lib/generators/better_translate/install/install_generator.rb +2 -2
- data/lib/generators/better_translate/install/templates/initializer.rb.tt +22 -34
- data/lib/generators/better_translate/translate/translate_generator.rb +65 -46
- data/lib/tasks/better_translate.rake +62 -45
- data/sig/better_translate/analyzer/code_scanner.rbs +59 -0
- data/sig/better_translate/analyzer/key_scanner.rbs +40 -0
- data/sig/better_translate/analyzer/orphan_detector.rbs +43 -0
- data/sig/better_translate/analyzer/reporter.rbs +70 -0
- data/sig/better_translate/cli.rbs +2 -0
- data/sig/better_translate/configuration.rbs +6 -0
- data/sig/better_translate/errors.rbs +4 -0
- data/sig/better_translate/json_handler.rbs +65 -0
- data/sig/better_translate/progress_tracker.rbs +1 -1
- data/sig/better_translate/translator.rbs +12 -1
- data/sig/better_translate/yaml_handler.rbs +6 -0
- data/sig/better_translate.rbs +4 -0
- data/sig/csv.rbs +16 -0
- metadata +32 -3
- 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
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
|