locallingo 0.1.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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ module Validators
5
+ # Reports keys present in the source locale but absent from a target locale.
6
+ class Missing
7
+ def initialize(cli_name: "lingo")
8
+ @cli_name = cli_name
9
+ end
10
+
11
+ # +source+ and +target+ are flat key=>value hashes for one target +locale+.
12
+ def call(source:, target:, locale:)
13
+ (source.keys - target.keys).map do |key|
14
+ {
15
+ type: :missing,
16
+ locale:,
17
+ key:,
18
+ suggestion: "Run: #{@cli_name} translate --locale #{locale}"
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../state_store"
4
+
5
+ module Locallingo
6
+ module Validators
7
+ # Reports target keys whose recorded source hash no longer matches the
8
+ # current source value — i.e. the English text changed after translation.
9
+ class Outdated
10
+ def initialize(cli_name: "lingo")
11
+ @cli_name = cli_name
12
+ end
13
+
14
+ # +source+ is the flat source hash; +locale_state+ is the target locale's
15
+ # loaded state (key => { "source_hash" => ... }).
16
+ def call(source:, locale_state:, locale:)
17
+ outdated_keys(source, locale_state).map do |key|
18
+ {
19
+ type: :outdated,
20
+ locale:,
21
+ key:,
22
+ suggestion: "Source changed. Run: #{@cli_name} translate --locale #{locale} --force-key #{key}"
23
+ }
24
+ end
25
+ end
26
+
27
+ # Keys whose stored source_hash differs from the current source hash.
28
+ def outdated_keys(source, locale_state)
29
+ source.filter_map do |key, value|
30
+ stored = locale_state.dig(key, "source_hash")
31
+ key if stored && stored != StateStore.hash(value)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ VERSION = "0.1.0"
5
+ end
data/lib/locallingo.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "locallingo/version"
4
+ require_relative "locallingo/configuration"
5
+ require_relative "locallingo/json_extraction"
6
+ require_relative "locallingo/key_flattener"
7
+ require_relative "locallingo/state_store"
8
+ require_relative "locallingo/providers/ruby_llm"
9
+ require_relative "locallingo/validators/missing"
10
+ require_relative "locallingo/validators/outdated"
11
+ require_relative "locallingo/validators/duplicate_values"
12
+ require_relative "locallingo/validators/manual_edits"
13
+ require_relative "locallingo/quality/static_rules"
14
+ require_relative "locallingo/quality/british_spellings"
15
+ require_relative "locallingo/quality/terminology"
16
+ require_relative "locallingo/manager"
17
+ require_relative "locallingo/quality_checker"
18
+ require_relative "locallingo/reporter"
19
+ require_relative "locallingo/cli"
20
+
21
+ # Locallingo — AI-assisted i18n translation, drift detection and quality linting.
22
+ #
23
+ # The public entry points are the `lingo` CLI (exe/lingo → Locallingo::CLI) and,
24
+ # programmatically, Locallingo::Manager / Locallingo::QualityChecker, both driven
25
+ # by a Locallingo::Configuration loaded from `.locallingo.yml`.
26
+ module Locallingo
27
+ class Error < StandardError; end
28
+
29
+ # Raised when an LLM call is attempted but the configured provider has no
30
+ # credentials available.
31
+ class MissingCredentialsError < Error; end
32
+
33
+ # Load the configuration for +root_path+ (defaults to the current directory),
34
+ # optionally scoped to a package path from the `packages:` list.
35
+ def self.configuration(root_path: Dir.pwd, package: nil)
36
+ Configuration.load(root_path:, package:)
37
+ end
38
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Locallingo
6
+ # Enforces fully-qualified i18n keys instead of relative keys.
7
+ #
8
+ # Relative keys (e.g. `t(".notice")`) depend on Rails' lazy lookup, which is
9
+ # implicit and fragile: moving a translation between files, renaming an
10
+ # action, or reusing a string in a different context all silently break the
11
+ # lookup. Fully qualified keys are explicit and grep-able.
12
+ #
13
+ # The autocorrector mirrors Rails' lazy-lookup scope, deriving the prefix
14
+ # from the file path (mapped through `ScopedDirectories`) plus the enclosing
15
+ # method name. When the path does not map to a known convention it flags the
16
+ # offense but leaves qualification to the developer rather than guessing.
17
+ #
18
+ # The set of scoped directories is configurable so the cop fits apps with
19
+ # different layouts:
20
+ #
21
+ # Locallingo/RelativeI18nKey:
22
+ # ScopedDirectories: [controllers, mailers, views, components]
23
+ #
24
+ # @example
25
+ # # bad
26
+ # t(".title")
27
+ # t(".nested.key")
28
+ #
29
+ # # good
30
+ # t("users.index.title")
31
+ # t("users.show.nested.key")
32
+ class RelativeI18nKey < Base
33
+ extend AutoCorrector
34
+
35
+ MSG = "Use fully-qualified i18n key instead of relative key `%<key>s`."
36
+
37
+ DEFAULT_SCOPED_DIRECTORIES = %w[
38
+ controllers mailers views components models services jobs notifiers
39
+ ].freeze
40
+
41
+ # @!method t_with_string_arg?(node)
42
+ def_node_matcher :t_with_string_arg?, <<~PATTERN
43
+ (send nil? :t (str $_) ...)
44
+ PATTERN
45
+
46
+ def on_send(node)
47
+ t_with_string_arg?(node) do |key|
48
+ next unless key.start_with?(".")
49
+
50
+ add_offense(node, message: format(MSG, key:)) do |corrector|
51
+ qualified = qualify(node, key)
52
+ next unless qualified
53
+
54
+ corrector.replace(node.first_argument, "\"#{qualified}\"")
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def scoped_directories
62
+ Array(cop_config["ScopedDirectories"]).then do |configured|
63
+ configured.empty? ? DEFAULT_SCOPED_DIRECTORIES : configured
64
+ end
65
+ end
66
+
67
+ def qualify(node, relative_key)
68
+ scope = lazy_lookup_scope(node)
69
+ return nil unless scope
70
+
71
+ "#{scope}#{relative_key}"
72
+ end
73
+
74
+ # Mirrors Rails' lazy-lookup scope. Returns nil if the path does not map
75
+ # to a known convention (we'd rather flag and require manual
76
+ # qualification than guess wrong).
77
+ def lazy_lookup_scope(node)
78
+ path = processed_source.file_path
79
+ return nil if path.nil?
80
+
81
+ rel = path.sub(%r{\A.*?app/}, "")
82
+ dir = rel.split("/").first
83
+ return nil unless scoped_directories.include?(dir)
84
+
85
+ file_scope = rel.sub(%r{\A[^/]+/}, "").delete_suffix(".rb")
86
+ file_scope = file_scope.delete_suffix("_controller").delete_suffix("_mailer")
87
+ base = file_scope.tr("/", ".")
88
+
89
+ method_name = enclosing_method(node)
90
+ method_name ? "#{base}.#{method_name}" : base
91
+ end
92
+
93
+ def enclosing_method(node)
94
+ node.each_ancestor(:def).first&.method_name&.to_s
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Locallingo
6
+ # Enforces `I18n.l(value, format: :name)` instead of `.strftime(...)` in
7
+ # view files.
8
+ #
9
+ # Hardcoded strftime patterns bypass locale-aware formatting. Use I18n.l
10
+ # with named formats defined in config/locales/time.*.yml and date.*.yml.
11
+ #
12
+ # `.strftime` inside a `value:` pair is exempt: HTML datetime-local input
13
+ # values must follow the HTML spec, not locale display formatting.
14
+ #
15
+ # Scope with the standard RuboCop `Include`/`Exclude` in your config:
16
+ #
17
+ # Locallingo/StrftimeInView:
18
+ # Include: [app/views/**/*.rb]
19
+ # Exclude: [app/views/**/*_mailer/**/*.rb]
20
+ #
21
+ # @example
22
+ # # bad
23
+ # @user.created_at.strftime("%B %d, %Y")
24
+ #
25
+ # # good
26
+ # I18n.l(@user.created_at, format: :long)
27
+ class StrftimeInView < Base
28
+ MSG = "Use `I18n.l(value, format: :name)` instead of `.strftime(...)`. " \
29
+ "Define formats in config/locales/{time,date}.*.yml."
30
+
31
+ RESTRICT_ON_SEND = %i[strftime].freeze
32
+
33
+ def on_send(node)
34
+ return if in_html_input_context?(node)
35
+
36
+ add_offense(node.loc.selector)
37
+ end
38
+
39
+ private
40
+
41
+ # Allow strftime for HTML datetime-local input values, where the format
42
+ # is dictated by the HTML spec, not user display.
43
+ def in_html_input_context?(node)
44
+ node.each_ancestor(:pair).any? do |pair|
45
+ pair.key.value == :value if pair.key.respond_to?(:value)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: locallingo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mikael Henriksson
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rubocop
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.75'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '2'
42
+ type: :development
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.75'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '2'
52
+ description: Locallingo extends i18n-tasks with AI translation (via RubyLLM — OpenAI,
53
+ Anthropic, and more), source-hash drift detection so only missing/changed keys are
54
+ re-translated, and a static + AI translation-quality linter. Everything app-specific
55
+ — target locales, provider/model, prompt context and glossary, per-language style
56
+ guides, which validators and quality rules run, and post-translate hooks — is config-driven
57
+ via `.locallingo.yml`, with a default block plus optional per-package overrides.
58
+ Ships the `lingo` CLI.
59
+ email: mikael@zoolutions.llc
60
+ executables:
61
+ - lingo
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - config/default.yml
69
+ - config/locallingo.default.yml
70
+ - exe/lingo
71
+ - lib/locallingo.rb
72
+ - lib/locallingo/cli.rb
73
+ - lib/locallingo/configuration.rb
74
+ - lib/locallingo/json_extraction.rb
75
+ - lib/locallingo/key_flattener.rb
76
+ - lib/locallingo/manager.rb
77
+ - lib/locallingo/providers/ruby_llm.rb
78
+ - lib/locallingo/quality/british_spellings.rb
79
+ - lib/locallingo/quality/static_rules.rb
80
+ - lib/locallingo/quality/terminology.rb
81
+ - lib/locallingo/quality_checker.rb
82
+ - lib/locallingo/reporter.rb
83
+ - lib/locallingo/rubocop.rb
84
+ - lib/locallingo/state_store.rb
85
+ - lib/locallingo/validators/duplicate_values.rb
86
+ - lib/locallingo/validators/manual_edits.rb
87
+ - lib/locallingo/validators/missing.rb
88
+ - lib/locallingo/validators/outdated.rb
89
+ - lib/locallingo/version.rb
90
+ - lib/rubocop/cop/locallingo/relative_i18n_key.rb
91
+ - lib/rubocop/cop/locallingo/strftime_in_view.rb
92
+ homepage: https://github.com/mhenrixon/locallingo
93
+ licenses:
94
+ - MIT
95
+ metadata:
96
+ source_code_uri: https://github.com/mhenrixon/locallingo
97
+ changelog_uri: https://github.com/mhenrixon/locallingo/blob/main/CHANGELOG.md
98
+ bug_tracker_uri: https://github.com/mhenrixon/locallingo/issues
99
+ rubygems_mfa_required: 'true'
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '3.2'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 4.0.9
115
+ specification_version: 4
116
+ summary: AI-assisted i18n translation, drift detection, and quality linting on top
117
+ of i18n-tasks
118
+ test_files: []