yanagi 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.
data/exe/yanagi ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/yanagi"
5
+ require_relative "../lib/yanagi/cli"
6
+
7
+ Yanagi::CLI.start(ARGV)
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "rules"
5
+ require_relative "normalize"
6
+ require_relative "lexicon"
7
+ require_relative "cyrillic"
8
+
9
+ module Yanagi
10
+ module Audit
11
+ JP_MARKERS = %w[
12
+ ші чі джі ґ
13
+ дза дзу дзе дзо
14
+ шя шю шьо
15
+ чя чю чьо
16
+ джя джю джьо
17
+ кя кю кьо
18
+ ря рю рьо
19
+ ня ню ньо
20
+ хя хю хьо
21
+ мя мю мьо
22
+ бя бю бьо
23
+ пя пю пьо
24
+ ґя ґю ґьо
25
+ ].freeze
26
+
27
+ POLIVANOV_MARKERS = %w[
28
+ сі ті дзі зі
29
+ ся сю сьо
30
+ тя тю тьо
31
+ дзя дзю дзьо зя зю зьо
32
+ ].freeze
33
+
34
+ Finding = Struct.new(
35
+ :file,
36
+ :line,
37
+ :col,
38
+ :token,
39
+ :tier,
40
+ :message,
41
+ :canonical,
42
+ :approved,
43
+ keyword_init: true
44
+ ) do
45
+ def to_h
46
+ {
47
+ "file" => file,
48
+ "line" => line,
49
+ "col" => col,
50
+ "token" => token,
51
+ "tier" => tier,
52
+ "message" => message,
53
+ "canonical" => canonical,
54
+ "approved" => approved || false
55
+ }
56
+ end
57
+ end
58
+
59
+ def self.allowlist
60
+ @allowlist ||= begin
61
+ data = Rules.native_ua_allowlist
62
+ data.is_a?(Array) ? Set.new(data.map(&:to_s).map(&:downcase)) : Set.new
63
+ end
64
+ end
65
+
66
+ def self.reload_allowlist!
67
+ @allowlist = nil
68
+ end
69
+
70
+ def self.scan_text(text, file_path: nil, tier2: true, tier3: false)
71
+ findings = []
72
+ allow = allowlist
73
+ lines = text.lines
74
+
75
+ lines.each_with_index do |line_text, line_idx|
76
+ line_text.scan(/(?<![\w\p{Cyrillic}])([\p{Cyrillic}'’\`-]+)(?![\w\p{Cyrillic}])/) do
77
+ match_data = Regexp.last_match
78
+ raw_tok = match_data[1]
79
+ col = match_data.begin(0) + 1
80
+
81
+ clean = raw_tok.downcase.gsub(/^[-'\`"]+|[-'\`"]+$/, "")
82
+ next if clean.length < 2
83
+
84
+ # 1. Skip if allowlisted native Ukrainian word
85
+ next if allow.include?(clean)
86
+ stem, _ = Lexicon.strip_inflection(clean)
87
+ next if stem.length >= 3 && allow.include?(stem)
88
+
89
+ # 2. Tier 1 Check (AUTOFIX-eligible)
90
+ # Does this un-allowlisted token match a lexicon entry via forbidden substitution?
91
+ t1 = check_tier1(raw_tok, clean, file_path, line_idx + 1, col)
92
+ if t1
93
+ findings << t1
94
+ next
95
+ end
96
+
97
+ # 3. Skip if known in lexicon
98
+ lex_match = Lexicon.find_by_stem(clean)
99
+ next if lex_match
100
+
101
+ # 4. Tier 2 Check (JP markers present, absent from allowlist & lexicon)
102
+ if tier2 && has_jp_markers?(clean)
103
+ findings << Finding.new(
104
+ file: file_path,
105
+ line: line_idx + 1,
106
+ col: col,
107
+ token: raw_tok,
108
+ tier: 2,
109
+ message: "Contains Japanese transliteration markers; unanchored in lexicon",
110
+ canonical: nil,
111
+ approved: false
112
+ )
113
+ next
114
+ end
115
+
116
+ # 5. Tier 3 Check (Polivanov markers present, off by default)
117
+ if tier3 && has_polivanov_markers?(clean)
118
+ findings << Finding.new(
119
+ file: file_path,
120
+ line: line_idx + 1,
121
+ col: col,
122
+ token: raw_tok,
123
+ tier: 3,
124
+ message: "Contains Polivanov digraph marker; expected mostly noise",
125
+ canonical: nil,
126
+ approved: false
127
+ )
128
+ end
129
+ end
130
+ end
131
+
132
+ findings
133
+ end
134
+
135
+ def self.scan(paths, tier2: true, tier3: false)
136
+ paths = [paths] unless paths.is_a?(Array)
137
+ all_findings = []
138
+
139
+ paths.each do |p|
140
+ if File.directory?(p)
141
+ Dir.glob(File.join(p, "**", "*.{org,md,txt}")).each do |f|
142
+ next if f.include?("glossary.org") || f.include?("transliteration.md")
143
+ content = File.read(f, encoding: "UTF-8")
144
+ all_findings.concat(scan_text(content, file_path: f, tier2: tier2, tier3: tier3))
145
+ end
146
+ elsif File.file?(p)
147
+ content = File.read(p, encoding: "UTF-8")
148
+ all_findings.concat(scan_text(content, file_path: p, tier2: tier2, tier3: tier3))
149
+ end
150
+ end
151
+
152
+ all_findings
153
+ end
154
+
155
+ def self.apply(findings_file)
156
+ data = YAML.safe_load_file(findings_file, permitted_classes: [Symbol, Date], symbolize_names: true) || []
157
+ data = data[:findings] if data.is_a?(Hash) && data.key?(:findings)
158
+ data = [data] unless data.is_a?(Array)
159
+
160
+ by_file = {}
161
+ data.each do |f|
162
+ next unless f[:approved] == true && f[:canonical] && !f[:canonical].empty? && f[:file]
163
+ by_file[f[:file]] ||= []
164
+ by_file[f[:file]] << f
165
+ end
166
+
167
+ applied_count = 0
168
+
169
+ by_file.each do |file_path, file_findings|
170
+ next unless File.exist?(file_path)
171
+ content = File.read(file_path, encoding: "UTF-8")
172
+
173
+ file_findings.sort_by { |f| [-f[:line].to_i, -f[:col].to_i] }.each do |f|
174
+ tok = f[:token]
175
+ canon = f[:canonical]
176
+ replacement = if tok == tok.upcase
177
+ canon.upcase
178
+ elsif tok == tok.capitalize
179
+ canon.capitalize
180
+ else
181
+ canon
182
+ end
183
+
184
+ if content.include?(tok)
185
+ content = content.sub(tok, replacement)
186
+ applied_count += 1
187
+ end
188
+ end
189
+
190
+ File.write(file_path, content, encoding: "UTF-8")
191
+ end
192
+
193
+ applied_count
194
+ end
195
+
196
+ def self.check_tier1(raw_tok, clean_tok, file_path, line, col)
197
+ mora_map = Rules.mora_map
198
+ return nil unless mora_map
199
+ return nil if clean_tok.length < Lexicon::MIN_STEM_LENGTH
200
+
201
+ mora_map.each do |_kana, data|
202
+ forbidden_list = data[:forbidden] || []
203
+ canonical_cyr = data[:cyrillic]&.to_s
204
+ next unless canonical_cyr && !forbidden_list.empty?
205
+
206
+ forbidden_list.each do |forb|
207
+ next unless clean_tok.include?(forb)
208
+
209
+ candidate = clean_tok.gsub(forb, canonical_cyr)
210
+ lex_match = Lexicon.find_by_stem(candidate)
211
+
212
+ # Japanese mora rules apply only to Japanese-origin terms. Chinese works
213
+ # and place names in the glossary carry their own transliteration.
214
+ next if lex_match && lex_match[:entry] && lex_match[:entry][:origin].to_s == "zh"
215
+
216
+ if lex_match && lex_match[:stem].length >= Lexicon::MIN_STEM_LENGTH
217
+ canonical_word = if raw_tok == raw_tok.capitalize
218
+ candidate.capitalize
219
+ elsif raw_tok == raw_tok.upcase
220
+ candidate.upcase
221
+ else
222
+ candidate
223
+ end
224
+
225
+ return Finding.new(
226
+ file: file_path,
227
+ line: line,
228
+ col: col,
229
+ token: raw_tok,
230
+ tier: 1,
231
+ message: "Forbidden transliteration '#{forb}' for '#{canonical_cyr}' matching lexicon '#{lex_match[:key]}'",
232
+ canonical: canonical_word,
233
+ approved: true
234
+ )
235
+ end
236
+ end
237
+ end
238
+
239
+ nil
240
+ end
241
+
242
+ def self.has_jp_markers?(text)
243
+ JP_MARKERS.any? { |m| text.include?(m) }
244
+ end
245
+
246
+ def self.has_polivanov_markers?(text)
247
+ POLIVANOV_MARKERS.any? { |m| text.include?(m) }
248
+ end
249
+
250
+ def self.build_allowlist_from_corpus(corpus_dir, out_path: nil)
251
+ allow = Set.new
252
+
253
+ seed = %w[
254
+ інші наші ваші перші більші менші кращі довші вищі нижчі тиші чаші аркуші гроші душі пізніші давніші старші молодші зовнішні свіжіші
255
+ вночі очі плечі ключі речі ночі двічі тричі уночі поночі тисячі зустрічі чіпляти
256
+ бджіл бджілка джунглі джерело джерела джерел
257
+ ґанок ґрунт ґудзик ґава ґрати дзиґа ґвалт ґречний ґедзь
258
+ дзвонити дзвін дзвінок дзвіночок дзеркало дзеркальний дзьоб кукурудза дзенькіт дзвеніти задзвеніти
259
+ сірник сірники зовсім вісім досі сіль сільський сусід сусідка сусіди сіно січень сірий сідати сісти засідання весілля постійний постійно
260
+ партії кімнаті статті миті житті святі почутті тяглості тіло тінь тікати тітка тільки потім тієї тією
261
+ зір зірка зірки зібрати зіграти зійти поїздці нозі дорозі книзі підлозі зілля
262
+ сьогодні сьомий всього всьому третього цього якому того цього
263
+ ]
264
+ seed.each { |w| allow << w.downcase }
265
+
266
+ if Dir.exist?(corpus_dir)
267
+ books_dir = File.join(corpus_dir, "books")
268
+ shared_dir = File.join(corpus_dir, "shared")
269
+ files = []
270
+ files.concat(Dir.glob(File.join(books_dir, "**", "*.{org,md,txt}"))) if Dir.exist?(books_dir)
271
+ files.concat(Dir.glob(File.join(shared_dir, "**", "*.{org,md,txt}"))) if Dir.exist?(shared_dir)
272
+ files.reject! { |f| f.include?("glossary.org") || f.include?("transliteration.md") }
273
+
274
+ files.each do |f|
275
+ content = File.read(f, encoding: "UTF-8")
276
+ content.scan(/[\p{Cyrillic}'’\`-]+/) do |tok|
277
+ clean = tok.downcase.gsub(/^[-'\`"]+|[-'\`"]+$/, "")
278
+ next if clean.length < 2
279
+
280
+ # Do not allowlist Tier-1 forbidden forms or explicit Polivanov markers
281
+ next if check_tier1(clean, clean, nil, 0, 0)
282
+ next if clean.include?("дзі") || clean.include?("тсу") || clean.include?("сьо")
283
+ next if Lexicon.find_by_stem(clean)
284
+
285
+ allow << clean
286
+ end
287
+ end
288
+ end
289
+
290
+ out_file = out_path || Rules.path_for("native_ua_allowlist.yml")
291
+ File.write(out_file, YAML.dump(allow.to_a.sort))
292
+ reload_allowlist!
293
+ Rules.reload!
294
+ allow
295
+ end
296
+ end
297
+
298
+ def self.audit(paths, tier2: true, tier3: false)
299
+ Audit.scan(paths, tier2: tier2, tier3: tier3)
300
+ end
301
+ end
data/lib/yanagi/cli.rb ADDED
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "yaml"
6
+ require_relative "../yanagi"
7
+
8
+ module Yanagi
9
+ class CLI
10
+ def self.start(args = ARGV)
11
+ # Output is Ukrainian/Japanese text; emit UTF-8 regardless of the caller's locale.
12
+ $stdout.set_encoding(Encoding::UTF_8)
13
+ $stderr.set_encoding(Encoding::UTF_8)
14
+ new(args).run
15
+ end
16
+
17
+ def initialize(args)
18
+ @args = args.dup
19
+ end
20
+
21
+ def run
22
+ cmd = @args.shift
23
+ case cmd
24
+ when "romaji"
25
+ cmd_romaji
26
+ when "cyrillic"
27
+ cmd_cyrillic
28
+ when "audit"
29
+ cmd_audit
30
+ when "apply"
31
+ cmd_apply
32
+ when "doc-sync", "doc_sync"
33
+ cmd_doc_sync
34
+ when "lexicon"
35
+ cmd_lexicon
36
+ when "verify-gold", "verify_gold"
37
+ cmd_verify_gold
38
+ when "-v", "--version", "version"
39
+ puts "yanagi #{Yanagi::VERSION}"
40
+ when "-h", "--help", "help", nil
41
+ print_help
42
+ else
43
+ warn "Unknown command: #{cmd}"
44
+ print_help
45
+ exit 1
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def cmd_romaji
52
+ input = @args.join(" ").strip
53
+ if input.empty?
54
+ warn "Usage: yanagi romaji <kana>"
55
+ exit 1
56
+ end
57
+ puts Yanagi.romaji(input)
58
+ end
59
+
60
+ def cmd_cyrillic
61
+ format = "human"
62
+ kanji = nil
63
+ reading = nil
64
+
65
+ parser = OptionParser.new do |opts|
66
+ opts.on("--format FORMAT", %w[human json], "Output format (human or json)") { |v| format = v }
67
+ opts.on("--kanji KANJI", "Kanji writing") { |v| kanji = v }
68
+ opts.on("--reading READING", "Kana reading") { |v| reading = v }
69
+ end
70
+ remaining = parser.parse!(@args)
71
+
72
+ input = reading || remaining.join(" ").strip
73
+ if input.empty? && kanji.nil?
74
+ warn "Usage: yanagi cyrillic <kana> [--format json|human] [--kanji <kanji>]"
75
+ exit 1
76
+ end
77
+
78
+ res = Yanagi.cyrillic(input, kanji: kanji, reading: reading)
79
+ if format == "json"
80
+ puts JSON.pretty_generate({
81
+ text: res.text,
82
+ source: res.source,
83
+ confidence: res.confidence,
84
+ notes: res.notes
85
+ })
86
+ else
87
+ puts res.text
88
+ end
89
+ end
90
+
91
+ def cmd_audit
92
+ tier2 = true
93
+ tier3 = false
94
+ format = "human"
95
+ out_path = nil
96
+
97
+ parser = OptionParser.new do |opts|
98
+ opts.on("--[no-]tier2", "Enable/disable Tier 2 reporting (default: true)") { |v| tier2 = v }
99
+ opts.on("--tier3", "Enable Tier 3 Polivanov reporting (default: false)") { |v| tier3 = v }
100
+ opts.on("--format FORMAT", %w[human json yaml], "Output format") { |v| format = v }
101
+ opts.on("--out FILE", "Output file for findings") { |v| out_path = v }
102
+ end
103
+ paths = parser.parse!(@args)
104
+
105
+ if paths.empty?
106
+ paths = ["."]
107
+ end
108
+
109
+ findings = Yanagi.audit(paths, tier2: tier2, tier3: tier3)
110
+
111
+ if out_path
112
+ data = { "findings" => findings.map(&:to_h) }
113
+ content = format == "json" ? JSON.pretty_generate(data) : YAML.dump(data)
114
+ File.write(out_path, content)
115
+ puts "Wrote #{findings.length} findings to #{out_path}"
116
+ elsif format == "json"
117
+ puts JSON.pretty_generate(findings.map(&:to_h))
118
+ elsif format == "yaml"
119
+ puts YAML.dump(findings.map(&:to_h))
120
+ else
121
+ tier1_count = findings.count { |f| f.tier == 1 }
122
+ tier2_count = findings.count { |f| f.tier == 2 }
123
+ tier3_count = findings.count { |f| f.tier == 3 }
124
+
125
+ puts "Yanagi Audit Results:"
126
+ puts "--------------------"
127
+ puts "Tier 1 (AUTOFIX): #{tier1_count}"
128
+ puts "Tier 2 (REPORT): #{tier2_count}"
129
+ puts "Tier 3 (NOISE): #{tier3_count}"
130
+ puts "Total: #{findings.length}"
131
+ puts ""
132
+
133
+ findings.each do |f|
134
+ loc = f.file ? "#{f.file}:#{f.line}:#{f.col}" : "line #{f.line}:#{f.col}"
135
+ puts "[Tier #{f.tier}] #{loc} - '#{f.token}' #{f.canonical ? "-> '#{f.canonical}'" : ""}"
136
+ puts " #{f.message}"
137
+ end
138
+ end
139
+
140
+ # Exit non-zero if Tier 1 findings exist
141
+ exit 1 if findings.any? { |f| f.tier == 1 }
142
+ end
143
+
144
+ def cmd_apply
145
+ findings_file = @args.first
146
+ unless findings_file && File.exist?(findings_file)
147
+ warn "Usage: yanagi apply <findings.yml>"
148
+ exit 1
149
+ end
150
+
151
+ applied = Yanagi::Audit.apply(findings_file)
152
+ puts "Applied #{applied} approved corrections."
153
+ end
154
+
155
+ def cmd_doc_sync
156
+ path = @args.first
157
+ sync = Yanagi::DocSync.new(path: path)
158
+
159
+ diffs = sync.diff
160
+ if diffs.empty?
161
+ puts "Transliteration policy doc (#{sync.path}) is in sync with rules."
162
+ exit 0
163
+ else
164
+ warn "Discrepancies found in policy doc (#{sync.path}):"
165
+ diffs.each do |d|
166
+ warn " - #{d.inspect}"
167
+ end
168
+ exit 1
169
+ end
170
+ end
171
+
172
+ def cmd_lexicon
173
+ subcmd = @args.shift
174
+ case subcmd
175
+ when "build"
176
+ glossary_path = nil
177
+ out_path = nil
178
+
179
+ parser = OptionParser.new do |opts|
180
+ opts.on("--glossary FILE", "Path to glossary.org") { |v| glossary_path = v }
181
+ opts.on("--out FILE", "Output path for lexicon.yml") { |v| out_path = v }
182
+ end
183
+ parser.parse!(@args)
184
+
185
+ glossary_path ||= File.expand_path("~/projects/meijin/books/meijin/glossary.org")
186
+ unless File.exist?(glossary_path)
187
+ warn "Glossary file not found: #{glossary_path}"
188
+ exit 1
189
+ end
190
+
191
+ lex = Yanagi::Lexicon.build_from_glossary(glossary_path, out_path: out_path)
192
+ puts "Built lexicon with #{lex.length} entries."
193
+
194
+ when "merge"
195
+ out_path = nil
196
+ parser = OptionParser.new do |opts|
197
+ opts.on("--out FILE", "Output path for lexicon.yml") { |v| out_path = v }
198
+ end
199
+ remaining = parser.parse!(@args)
200
+ proposal_file = remaining.first
201
+
202
+ unless proposal_file && File.exist?(proposal_file)
203
+ warn "Usage: yanagi lexicon merge <proposal.yml> [--out <file>]"
204
+ exit 1
205
+ end
206
+
207
+ lex = Yanagi::Lexicon.merge_proposal(proposal_file, out_path: out_path)
208
+ puts "Successfully merged proposals. Lexicon now contains #{lex.length} entries."
209
+
210
+ else
211
+ warn "Usage: yanagi lexicon [build|merge] [options]"
212
+ exit 1
213
+ end
214
+ end
215
+
216
+ # Forbidden Cyrillic sequences: Polivanov leftovers and yoon/long-vowel
217
+ # violations. Derived from the rules data so the doc, the audit and this
218
+ # check cannot drift apart.
219
+ def gold_forbidden_patterns
220
+ pats = []
221
+ (Rules.mora_map || {}).each_value do |data|
222
+ canonical = data[:cyrillic].to_s
223
+ Array(data[:forbidden]).each do |forb|
224
+ f = forb.to_s
225
+ next if f.empty?
226
+
227
+ # A forbidden sequence is only a violation when it is not already part
228
+ # of the canonical rendering: «за» is wrong on its own but correct
229
+ # inside «дза», and «ху» is wrong except inside a longer correct form.
230
+ prefix = canonical.end_with?(f) ? canonical[0...-f.length] : nil
231
+ pats << if prefix && !prefix.empty?
232
+ Regexp.new("(?<!#{Regexp.escape(prefix)})#{Regexp.escape(f)}", Regexp::IGNORECASE)
233
+ else
234
+ Regexp.new(Regexp.escape(f), Regexp::IGNORECASE)
235
+ end
236
+ end
237
+ end
238
+ pats << /оо/ # long vowels are never doubled (see transliteration.md 3.3)
239
+ pats.uniq { |r| r.source }
240
+ end
241
+
242
+ def cmd_verify_gold
243
+ glossary_path = @args.first || File.expand_path("~/projects/meijin/books/meijin/glossary.org")
244
+ unless File.exist?(glossary_path)
245
+ warn "Glossary file not found: #{glossary_path}"
246
+ exit 1
247
+ end
248
+
249
+ content = File.read(glossary_path, encoding: "UTF-8")
250
+ rows = []
251
+ content.each_line do |line|
252
+ next unless line.start_with?("|")
253
+ parts = line.split("|").map(&:strip).reject(&:empty?)
254
+ next if parts.empty? || parts[0].start_with?("-") || parts[0] =~ /Японське/i
255
+
256
+ col0 = parts[0]
257
+ next unless col0 =~ /^(.+?)\s*\((.+?)\)$/
258
+
259
+ rows << {
260
+ kanji: Regexp.last_match(1).strip,
261
+ reading: Regexp.last_match(2).strip,
262
+ ukrainian: parts[1],
263
+ note: parts[2]
264
+ }
265
+ end
266
+
267
+ puts "Verifying #{rows.length} gold glossary terms against Yanagi rules..."
268
+
269
+ exceptions = Rules.exceptions || []
270
+ pending = exceptions.select { |e| e[:status].to_s == "pending" }
271
+ accepted_terms = exceptions.select { |e| e[:status].to_s == "accepted" }.map { |e| e[:term].to_s }
272
+
273
+ patterns = gold_forbidden_patterns
274
+ passed = 0
275
+ failures = []
276
+
277
+ rows.each do |row|
278
+ reading = row[:reading]
279
+
280
+ # Non-Japanese entries carry their own language's transliteration.
281
+ entry = Rules.lexicon[row[:kanji].to_sym] || Rules.lexicon[row[:kanji]]
282
+ if entry && entry[:origin].to_s == "zh"
283
+ passed += 1
284
+ next
285
+ end
286
+
287
+ if accepted_terms.any? { |t| reading.include?(t) }
288
+ passed += 1
289
+ next
290
+ end
291
+
292
+ hit = patterns.find { |pat| reading =~ pat }
293
+ if hit
294
+ failures << { kanji: row[:kanji], reading: reading, reason: "Forbidden sequence #{hit.source}" }
295
+ else
296
+ passed += 1
297
+ end
298
+ end
299
+
300
+ pass_rate = rows.empty? ? 100.0 : (passed.to_f / rows.length * 100).round(1)
301
+ puts "Gold Verification Summary:"
302
+ puts "-------------------------"
303
+ puts "Total Terms: #{rows.length}"
304
+ puts "Passed: #{passed} (#{pass_rate}%)"
305
+ puts "Failures: #{failures.length}"
306
+ puts "Pending defect: #{pending.length}"
307
+
308
+ unless failures.empty?
309
+ warn "\nFailures:"
310
+ failures.each { |f| warn " - #{f[:kanji]} (#{f[:reading]}): #{f[:reason]}" }
311
+ end
312
+
313
+ # A pending exception is an uncorrected data defect: fail so it stays visible.
314
+ unless pending.empty?
315
+ warn "\nPending defects in data/exceptions.yml (correct the source data or reclassify):"
316
+ pending.each { |e| warn " - #{e[:term]} (#{e[:kanji]}): #{e[:reason]}" }
317
+ end
318
+
319
+ if failures.empty? && pending.empty?
320
+ puts "All gold pairs verified successfully!"
321
+ exit 0
322
+ end
323
+
324
+ exit 1
325
+ end
326
+
327
+ def print_help
328
+ puts <<~HELP
329
+ Yanagi (柳) - Deterministic Japanese -> Ukrainian transliteration & policy tool
330
+
331
+ Usage:
332
+ yanagi romaji <kana>
333
+ yanagi cyrillic <kana> [--format json|human] [--kanji <kanji>]
334
+ yanagi audit <paths...> [--tier2] [--tier3] [--format json|human|yaml] [--out <file>]
335
+ yanagi apply <findings.yml>
336
+ yanagi doc-sync [path/to/transliteration.md]
337
+ yanagi lexicon build --glossary <glossary.org> [--out <file>]
338
+ yanagi lexicon merge <proposal.yml> [--out <file>]
339
+ yanagi verify-gold [path/to/glossary.org]
340
+ yanagi version
341
+ yanagi help
342
+ HELP
343
+ end
344
+ end
345
+ end