copy_tuner_client 1.2.0 → 1.2.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.
@@ -0,0 +1,63 @@
1
+ # prefix 移行後の検証
2
+
3
+ SKILL.md 手順 8 の詳細。対象 prefix を `local_first_key_regexp` に足した後、その範囲がローカルだけで解決する
4
+ ことを確かめる。完全分離(マッチキーは CopyTuner を見ない)なので、ローカルへの書き忘れは未訳として現れる。
5
+
6
+ ## 1. regexp が意図どおりマッチするか
7
+
8
+ gem の判定そのものを使って、今回の prefix のキーがマッチし、隣接キーが誤マッチしないことを確認する。
9
+
10
+ ```bash
11
+ bin/rails runner 'p [
12
+ CopyTunerClient.configuration.local_first_key?("views.foo.bar"), # => true を期待
13
+ CopyTunerClient.configuration.local_first_key?("reviews.foo") # => false を期待(部分マッチ事故の確認)
14
+ ]'
15
+ ```
16
+
17
+ ## 2. テスト
18
+
19
+ プロジェクトの品質コマンドに従う。多くの Rails プロジェクトでは:
20
+
21
+ ```bash
22
+ docker compose up -d db # 未起動なら
23
+ bundle exec rspec
24
+ ```
25
+
26
+ `translation missing` が出ないこと。完全分離した prefix のキーがローカル YAML に揃っていないと、ここで
27
+ 未訳が顕在化する(それが狙い)。落ちたら、抜き出したサブツリーに漏れがないか・配置ファイルがロードされて
28
+ いるかを確認する。
29
+
30
+ ## 3. 未翻訳キーの検出
31
+
32
+ 対象 prefix が関わる主要画面・フローを動かし、`translation missing` が出ないか確認する。
33
+ ログから機械的に拾うなら:
34
+
35
+ ```bash
36
+ grep -rni 'translation missing' log/ tmp/ 2>/dev/null || echo 'none'
37
+ ```
38
+
39
+ より厳密にやるなら、開発・テスト環境で `config.i18n.raise_on_missing_translations = true` を一時的に
40
+ 有効化してテストを流し、未定義キーで例外が出るかを確認する手もある(任意・恒久設定にするかはプロジェクト判断)。
41
+
42
+ ## 4. i18n の主要参照が解決するか
43
+
44
+ 移行した prefix に応じて、影響しやすい参照パターンを主要画面または rails runner で確認する:
45
+
46
+ - `views.*` を移したら … ビューの `t('...')` / `t('.lazy_key')`
47
+ - `activerecord.attributes` を移したら … `Model.human_attribute_name(:attr)`(CSV ヘッダ・フォームラベル)
48
+ - `activerecord.models` を移したら … `Model.model_name.human`
49
+ - `activerecord.enums` を移したら … enum の表示名(`human_enum_name` 等のヘルパ経由)
50
+
51
+ 例:
52
+
53
+ ```bash
54
+ bin/rails runner 'puts [User.model_name.human, User.human_attribute_name(:name)].inspect'
55
+ ```
56
+
57
+ `translation missing` を含まず、期待する日本語が返ること。
58
+
59
+ ## 5. 移行していない prefix が壊れていないこと
60
+
61
+ 未移行 prefix は `local_first_key_regexp` にマッチしないので、引き続き CopyTuner から引かれる。今回の移行で
62
+ 未移行 prefix の表示が変わっていないことも、主要画面で軽く確認しておく(regexp のアンカー漏れで意図せず
63
+ 広くマッチしていないかの確認も兼ねる)。
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ # copy_tuner → config/locales prefix-unit migration script (SKILL.md step 6).
4
+ #
5
+ # Run via `bin/rails runner` so that the Rails i18n load path and CopyTuner
6
+ # configuration are available:
7
+ #
8
+ # bin/rails runner .claude/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb \
9
+ # -- --prefix date --export tmp/copy_tuner_all.yml --out config/locales/0010_date.yml
10
+ #
11
+ # What it does, in one pass:
12
+ # (1) Place : extract the target prefix subtree from the 0000_original_*.yml
13
+ # originals (load-order deep_merge), deep_merge the export subtree on top
14
+ # (export wins for string blurbs), then re-apply the originals'
15
+ # non-representable values (arrays/symbols/numbers/booleans such as the
16
+ # `date.order` symbol array) last so a corrupted export value cannot
17
+ # overwrite them, and write it to --out.
18
+ # (2) Static guards : YAML round-trip, --out is on the i18n load path, every
19
+ # leaf key matches the prefix regexp.
20
+ # (3) Leak check : simulate the original-deletion in memory and resolve every
21
+ # target-prefix key through a fresh I18n::Backend::Simple. A key that goes
22
+ # missing after deletion is a migration leak.
23
+ # (4) Gate : only when there are zero leaks, prune the prefix subtree from the
24
+ # originals on disk. Any leak aborts with the originals untouched.
25
+ #
26
+ # 対象 locale はプロジェクトの I18n.available_locales を実行時に参照して決める(default_locale を先頭に並べる)。
27
+ # `--locales ja,en` で明示上書きできる(fixture を使った検証用。指定時は指定順を尊重する)。
28
+
29
+ require 'optparse'
30
+ require 'yaml'
31
+
32
+ # NOTE: `bin/rails runner` は Kernel#abort が投げる SystemExit を握りつぶし終了コードが 0 になる
33
+ # (実機確認済み)。中断を呼び出し側へ確実に伝えるため、abort ではなく warn + exit(1) を使う。
34
+ def die(message)
35
+ warn message
36
+ exit(1)
37
+ end
38
+
39
+ # ---- 引数 ----
40
+ options = {}
41
+ parser =
42
+ OptionParser.new do |opts|
43
+ opts.banner = 'Usage: bin/rails runner migrate_prefix.rb -- --prefix PREFIX --export PATH --out PATH [--regexp PATTERN] [--originals-glob GLOB]'
44
+ opts.on('--prefix PREFIX', 'ドット区切りの対象 prefix(例: date / activerecord.attributes)') { |v| options[:prefix] = v }
45
+ opts.on('--export PATH', '全件 export YAML(手順3で出力)') { |v| options[:export] = v }
46
+ opts.on('--out PATH', '移行分の出力先(例: config/locales/0010_date.yml)') { |v| options[:out] = v }
47
+ opts.on('--regexp PATTERN', 'leaf キー検証用の正規表現(省略時は prefix から \A<prefix>\. を生成)') { |v| options[:regexp] = v }
48
+ opts.on('--originals-glob GLOB', 'オリジナルファイルの glob(既定: config/locales/0000_original_*.yml)') { |v| options[:originals_glob] = v }
49
+ opts.on('--locales LIST', 'カンマ区切りの対象 locale(既定: I18n.available_locales。検証用の上書き)') { |v| options[:locales] = v }
50
+ end
51
+ # `bin/rails runner script -- ...` の `--` 以降だけを渡したいが、runner が剥がさない環境もあるため両対応。
52
+ argv = ARGV.include?('--') ? ARGV[(ARGV.index('--') + 1)..] : ARGV
53
+ parser.parse!(argv)
54
+
55
+ die(parser.banner) unless options[:prefix] && options[:export] && options[:out]
56
+
57
+ PREFIX = options[:prefix]
58
+ prefix_keys = PREFIX.split('.')
59
+
60
+ # 対象 locale 群。--locales 指定時はその順を尊重、未指定なら default_locale を先頭にした available_locales。
61
+ LOCALES =
62
+ if options[:locales]
63
+ options[:locales].split(',').map(&:strip).reject(&:empty?)
64
+ else
65
+ ([I18n.default_locale.to_s] + I18n.available_locales.map(&:to_s)).uniq
66
+ end
67
+ # 黙って 'ja' に倒すと将来 locale 追加時にサイレント脱落を招くため、空なら明示的に中断する。
68
+ die('対象 locale が空。I18n.available_locales が取れているか(Rails 環境のロード)、--locales の指定を確認すること。') if LOCALES.empty?
69
+ # 既定の regexp は prefix に \A アンカーを付けたもの(手順7の local_first_key_regexp と整合させる)。
70
+ regexp = options[:regexp] ? Regexp.new(options[:regexp]) : /\A#{Regexp.escape(PREFIX)}\./o
71
+ originals_glob = options.fetch(:originals_glob, 'config/locales/0000_original_*.yml')
72
+
73
+ # ---- ヘルパ ----
74
+
75
+ # ネスト Hash を非破壊 deep merge(右辺優先)。配列・スカラはそのまま右辺で置換。
76
+ def deep_merge(base, override)
77
+ base.merge(override) do |_key, b, o|
78
+ b.is_a?(Hash) && o.is_a?(Hash) ? deep_merge(b, o) : o
79
+ end
80
+ end
81
+
82
+ def deep_merge!(base, override)
83
+ base.replace(deep_merge(base, override))
84
+ end
85
+
86
+ # YAML をロードして指定 locale ルート(例 `ja:`)配下の Hash を返す。シンボル(date.order の :year 等)を許可。
87
+ def load_locale_tree(path, locale)
88
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
89
+ raw[locale] || {}
90
+ end
91
+
92
+ # prefix(["date"] や ["activerecord","attributes"])のサブツリーを掘り出す。無ければ nil。
93
+ def dig_prefix(tree, keys)
94
+ keys.reduce(tree) do |node, key|
95
+ return nil unless node.is_a?(Hash) && node.key?(key)
96
+
97
+ node[key]
98
+ end
99
+ end
100
+
101
+ # prefix サブツリーを {"date" => {...}} のように prefix で包んだ Hash にして返す(無ければ {})。
102
+ def extract_prefix(tree, keys)
103
+ sub = dig_prefix(tree, keys)
104
+ return {} if sub.nil?
105
+
106
+ keys.reverse.reduce(sub) { |acc, key| { key => acc } }
107
+ end
108
+
109
+ # prefix サブツリーを取り除いた新しい Hash を返す。空になった親も刈る(非破壊)。
110
+ def prune_prefix(tree, keys)
111
+ head, *rest = keys
112
+ return tree unless tree.is_a?(Hash) && tree.key?(head)
113
+
114
+ dup = tree.dup
115
+ if rest.empty?
116
+ dup.delete(head)
117
+ else
118
+ pruned_child = prune_prefix(dup[head], rest)
119
+ if pruned_child.is_a?(Hash) && pruned_child.empty?
120
+ dup.delete(head) # 空親を刈る
121
+ else
122
+ dup[head] = pruned_child
123
+ end
124
+ end
125
+ dup
126
+ end
127
+
128
+ # ネスト Hash の全 leaf を「ドット区切りキー => 値」で返す。配列・シンボル・nil 値は leaf として扱い、
129
+ # その中までは展開しない(date.order の [:year,...] は date.order 1 個)。
130
+ def leaf_entries(tree, prefix = [])
131
+ tree.each_with_object({}) do |(key, value), acc|
132
+ path = prefix + [key]
133
+ if value.is_a?(Hash) && !value.empty?
134
+ acc.merge!(leaf_entries(value, path))
135
+ else
136
+ acc[path.join('.')] = value
137
+ end
138
+ end
139
+ end
140
+
141
+ # ネスト Hash から「非表現値(非 String・非 Hash の leaf)だけ」を残したネスト Hash を返す。
142
+ # 配列・シンボル・数値・真偽値・nil が対象。String leaf は捨てる(export 勝ちに委ねる)。
143
+ # copy_tuner は flat な文字列 blurb しか持てず、これらは export に出てこない/壊れて出る可能性がある。
144
+ # orig 由来のこの結果を最後に deep_merge することで、壊れた export 値が非表現値を上書きするのを防ぐ。
145
+ def select_non_blurb(tree)
146
+ tree.each_with_object({}) do |(key, value), acc|
147
+ if value.is_a?(Hash) && !value.empty?
148
+ child = select_non_blurb(value)
149
+ acc[key] = child unless child.empty? # 非表現値が残った枝だけ保持
150
+ elsif !value.is_a?(String) && !value.is_a?(Hash)
151
+ acc[key] = value # Array / Symbol / Integer / Float / true / false / nil
152
+ end
153
+ end
154
+ end
155
+
156
+ # locale ルートを持つ raw Hash(`{ "ja" => {...}, "en" => {...} }`)から、対象 prefix を全 locale で刈った
157
+ # 新しい Hash を返す(非破壊)。移行漏れ検証のシミュレーションと実削除の両方で使う。
158
+ def prune_prefix_all_locales(raw, locales, keys)
159
+ locales.reduce(raw) { |acc, locale| acc.merge(locale => prune_prefix(acc[locale] || {}, keys)) }
160
+ end
161
+
162
+ # ---- (1) 配置 ----
163
+ out_path = options[:out]
164
+ # Dir.glob は Ruby 3.0+ で昇順ソート済み(= Rails の i18n ロード順と同じ)を返す。
165
+ original_files = Dir.glob(originals_glob)
166
+ die("オリジナルが見つからない: #{originals_glob}") if original_files.empty?
167
+
168
+ # locale ごとに merged サブツリーを構築する。{ "ja" => {...}, "en" => {...} } の形。
169
+ # あるロケールに当該 prefix のキーが一つも無い(orig も export も空)場合は warn してスキップし、
170
+ # 書き出し・検証から自然に除外する(複数ロケールでは「en は別 prefix しか持たない」等が正常に起こりうる)。
171
+ merged_by_locale = {}
172
+ LOCALES.each do |locale|
173
+ orig_sub = {}
174
+ original_files.each { |f| deep_merge!(orig_sub, extract_prefix(load_locale_tree(f, locale), prefix_keys)) }
175
+
176
+ exp_sub = extract_prefix(load_locale_tree(options[:export], locale), prefix_keys)
177
+
178
+ if orig_sub.empty? && exp_sub.empty?
179
+ warn("locale #{locale}: prefix '#{PREFIX}' のキーが無いためスキップ")
180
+ next
181
+ end
182
+
183
+ # orig がベース・export で上書き(String leaf は export 勝ち)し、最後に非表現値(非 String leaf)を
184
+ # orig 値で再適用して必ず勝たせる。export 側に壊れた非表現値(文字列化等)が出ても置換されない。
185
+ merged_by_locale[locale] = deep_merge(deep_merge(orig_sub, exp_sub), select_non_blurb(orig_sub))
186
+ end
187
+
188
+ # 全 locale でスキップ=当該 prefix がどこにも存在しない(prefix の typo)。
189
+ if merged_by_locale.empty?
190
+ die("prefix '#{PREFIX}' のキーが export にもオリジナルにも見つからない。prefix を確認すること。")
191
+ end
192
+
193
+ # ---- (2) 静的ガード(書き出し前にできる検証を先に。中途半端な --out を残さない)----
194
+ # --out が次回起動時に Rails の i18n glob でロードされる場所か(採番ミス・配置ミスの検出)。
195
+ # NOTE: I18n.load_path は runner 起動時に確定済みで、いま書き出す --out は含まれない。よって
196
+ # 「現在の load_path に在るか」ではなく「Rails 標準 glob(config/locales/**/*.yml)に合致するか」を確認する。
197
+ abs_out = File.expand_path(out_path)
198
+ locales_root = File.expand_path('config/locales')
199
+ unless abs_out.start_with?("#{locales_root}/") && abs_out.end_with?('.yml', '.yaml')
200
+ die("#{out_path} が config/locales 配下の .yml ではない。Rails の i18n ロード対象になる場所へ出力すること。")
201
+ end
202
+
203
+ # 全 leaf キーが regexp にマッチするか(regexp と prefix の不一致の早期検出)。leaf キーは locale を
204
+ # 含まない(merged は locale ルート配下)ので、全 locale を同じ regexp で検証できる。
205
+ merged_by_locale.each do |locale, merged|
206
+ non_matching = leaf_entries(merged).keys.grep_v(regexp)
207
+ next if non_matching.empty?
208
+
209
+ die("locale #{locale}: regexp #{regexp.inspect} にマッチしない leaf キーがある(regexp/prefix 不一致):\n #{non_matching.join("\n ")}")
210
+ end
211
+
212
+ # 書き出し(ここまでの検証を通過してから)。merged_by_locale は { "ja" => {...}, "en" => {...} } 形なので
213
+ # そのまま全 locale ルートを持つ YAML になる(スキップした locale は含まれない)。
214
+ File.write(out_path, merged_by_locale.to_yaml)
215
+ total_leaves = merged_by_locale.sum { |_locale, merged| leaf_entries(merged).size }
216
+ puts "配置: #{out_path} (locale #{merged_by_locale.keys.join(',')} / leaf 合計 #{total_leaves} キー)"
217
+
218
+ # YAML ラウンドトリップ(to_yaml → 再読込で各 locale の merged と一致するか)。
219
+ merged_by_locale.each do |locale, merged|
220
+ die("YAML ラウンドトリップ不一致。書き出し結果が壊れている: #{out_path} (locale #{locale})") unless load_locale_tree(out_path, locale) == merged
221
+ end
222
+
223
+ # ---- (3) 移行漏れ検証(削除をメモリ上でシミュレート)----
224
+ # 削除後に Rails がロードするであろう全 locales ツリーを実ロード順で再現する。
225
+ #
226
+ # NOTE: いま書き出した --out は起動時確定の I18n.load_path に含まれない(実機確認済み)。そのため
227
+ # load_path 由来の既存ファイル群(gem の locale 含む)を実順で読みつつ、--out を**末尾に明示追加**して
228
+ # 後勝ちさせ、「削除後+移行分ロード済み」の状態を再現する。
229
+ abs_out = File.expand_path(out_path)
230
+ sim_paths = I18n.load_path.dup
231
+ sim_paths << out_path unless sim_paths.map { |p| File.expand_path(p) }.include?(abs_out)
232
+ original_abs = original_files.map { |f| File.expand_path(f) }
233
+
234
+ sim_tree = {}
235
+ sim_paths.each do |path|
236
+ next unless File.exist?(path)
237
+ next unless path.end_with?('.yml', '.yaml') # .rb ロードパスはこのスキルの範囲外(必要なら別途)
238
+
239
+ content = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
240
+ next unless content.is_a?(Hash)
241
+
242
+ # オリジナルからは対象 prefix を全 locale で刈った状態にする(実削除後と等価)。
243
+ content = prune_prefix_all_locales(content, LOCALES, prefix_keys) if original_abs.include?(File.expand_path(path))
244
+ deep_merge!(sim_tree, content)
245
+ end
246
+
247
+ sim = I18n::Backend::Simple.new
248
+ sim_tree.each { |loc, data| sim.store_translations(loc, data) }
249
+
250
+ # NOTE: missing 判定は `default:` センチネルで行う。`throw: true` は missing 時に Ruby の throw
251
+ # (catch/throw フロー制御)で MissingTranslation を投げるため rescue では捕まらず UncaughtThrowError に
252
+ # なる(実機確認済み)。default にユニークなオブジェクトを渡せば、missing のときだけそれが返る。
253
+ # 空文字 "" は「存在」扱い(センチネルと equal? でないため missing 扱いしない)。
254
+ missing = Object.new
255
+
256
+ # 母集合は locale ごとに独立に取る(= その locale の merged の全 leaf キー)。locale 横断の和集合に
257
+ # すると、en にしか無いキーを ja で lookup して誤検知する。各 locale はその locale に実在するキーだけ検証する。
258
+ leaks = []
259
+ merged_by_locale.each do |locale, merged|
260
+ leaf_entries(merged).each_key do |key|
261
+ value = sim.translate(locale.to_sym, key, default: missing)
262
+ leaks << [locale, key] if value.equal?(missing)
263
+ end
264
+ end
265
+
266
+ # ---- (4) ゲート ----
267
+ unless leaks.empty?
268
+ warn "移行漏れ検出: 削除すると次の #{leaks.size} キーが未訳になる。オリジナルは変更していない。"
269
+ leaks.sort.each { |locale, key| warn " - #{locale}: #{key}" }
270
+ die('中断。--out の内容・採番・regexp を確認すること。')
271
+ end
272
+
273
+ original_files.each do |f|
274
+ raw = YAML.safe_load_file(f, permitted_classes: [Symbol], aliases: true) || {}
275
+ File.write(f, prune_prefix_all_locales(raw, LOCALES, prefix_keys).to_yaml)
276
+ end
277
+
278
+ puts "削除: prefix '#{PREFIX}' をオリジナル #{original_files.size} ファイルから刈り取った。"
279
+ puts '完了。手順7(local_first_key_regexp 追加)が未済なら次に実施すること。'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copy_tuner_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - SonicGarden
@@ -228,6 +228,15 @@ files:
228
228
  - lib/copy_tuner_client/version.rb
229
229
  - lib/tasks/copy_tuner_client_tasks.rake
230
230
  - package.json
231
+ - skills/copy-tuner-to-locales-cleanup/SKILL.md
232
+ - skills/copy-tuner-to-locales-cleanup/references/example-touchpoints.md
233
+ - skills/copy-tuner-to-locales-cleanup/references/verification-final.md
234
+ - skills/copy-tuner-to-locales-migrate-prefix/SKILL.md
235
+ - skills/copy-tuner-to-locales-migrate-prefix/references/example-touchpoints.md
236
+ - skills/copy-tuner-to-locales-migrate-prefix/references/export-and-split.md
237
+ - skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md
238
+ - skills/copy-tuner-to-locales-migrate-prefix/references/verification-per-prefix.md
239
+ - skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb
231
240
  - skills/copy-tuner/SKILL.md
232
241
  - spec/copy_tuner_client/cache_spec.rb
233
242
  - spec/copy_tuner_client/client_spec.rb