Module: Yanagi::Audit

Defined in:
lib/yanagi/audit.rb

Defined Under Namespace

Classes: Finding

Constant Summary collapse

JP_MARKERS =
%w[
  ші чі джі ґ
  дза дзу дзе дзо
  шя шю шьо
  чя чю чьо
  джя джю джьо
  кя кю кьо
  ря рю рьо
  ня ню ньо
  хя хю хьо
  мя мю мьо
  бя бю бьо
  пя пю пьо
  ґя ґю ґьо
].freeze
POLIVANOV_MARKERS =
%w[
  сі ті дзі зі
  ся сю сьо
  тя тю тьо
  дзя дзю дзьо зя зю зьо
].freeze

Class Method Summary collapse

Class Method Details

.allowlistObject



59
60
61
62
63
64
# File 'lib/yanagi/audit.rb', line 59

def self.allowlist
  @allowlist ||= begin
    data = Rules.native_ua_allowlist
    data.is_a?(Array) ? Set.new(data.map(&:to_s).map(&:downcase)) : Set.new
  end
end

.apply(findings_file) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/yanagi/audit.rb', line 155

def self.apply(findings_file)
  data = YAML.safe_load_file(findings_file, permitted_classes: [Symbol, Date], symbolize_names: true) || []
  data = data[:findings] if data.is_a?(Hash) && data.key?(:findings)
  data = [data] unless data.is_a?(Array)

  by_file = {}
  data.each do |f|
    next unless f[:approved] == true && f[:canonical] && !f[:canonical].empty? && f[:file]
    by_file[f[:file]] ||= []
    by_file[f[:file]] << f
  end

  applied_count = 0

  by_file.each do |file_path, file_findings|
    next unless File.exist?(file_path)
    content = File.read(file_path, encoding: "UTF-8")

    file_findings.sort_by { |f| [-f[:line].to_i, -f[:col].to_i] }.each do |f|
      tok = f[:token]
      canon = f[:canonical]
      replacement = if tok == tok.upcase
                      canon.upcase
                    elsif tok == tok.capitalize
                      canon.capitalize
                    else
                      canon
                    end

      if content.include?(tok)
        content = content.sub(tok, replacement)
        applied_count += 1
      end
    end

    File.write(file_path, content, encoding: "UTF-8")
  end

  applied_count
end

.build_allowlist_from_corpus(corpus_dir, out_path: nil) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/yanagi/audit.rb', line 250

def self.build_allowlist_from_corpus(corpus_dir, out_path: nil)
  allow = Set.new

  seed = %w[
    інші наші ваші перші більші менші кращі довші вищі нижчі тиші чаші аркуші гроші душі пізніші давніші старші молодші зовнішні свіжіші
    вночі очі плечі ключі речі ночі двічі тричі уночі поночі тисячі зустрічі чіпляти
    бджіл бджілка джунглі джерело джерела джерел
    ґанок ґрунт ґудзик ґава ґрати дзиґа ґвалт ґречний ґедзь
    дзвонити дзвін дзвінок дзвіночок дзеркало дзеркальний дзьоб кукурудза дзенькіт дзвеніти задзвеніти
    сірник сірники зовсім вісім досі сіль сільський сусід сусідка сусіди сіно січень сірий сідати сісти засідання весілля постійний постійно
    партії кімнаті статті миті житті святі почутті тяглості тіло тінь тікати тітка тільки потім тієї тією
    зір зірка зірки зібрати зіграти зійти поїздці нозі дорозі книзі підлозі зілля
    сьогодні сьомий всього всьому третього цього якому того цього
  ]
  seed.each { |w| allow << w.downcase }

  if Dir.exist?(corpus_dir)
    books_dir = File.join(corpus_dir, "books")
    shared_dir = File.join(corpus_dir, "shared")
    files = []
    files.concat(Dir.glob(File.join(books_dir, "**", "*.{org,md,txt}"))) if Dir.exist?(books_dir)
    files.concat(Dir.glob(File.join(shared_dir, "**", "*.{org,md,txt}"))) if Dir.exist?(shared_dir)
    files.reject! { |f| f.include?("glossary.org") || f.include?("transliteration.md") }

    files.each do |f|
      content = File.read(f, encoding: "UTF-8")
      content.scan(/[\p{Cyrillic}'’\`-]+/) do |tok|
        clean = tok.downcase.gsub(/^[-'\`"]+|[-'\`"]+$/, "")
        next if clean.length < 2

        # Do not allowlist Tier-1 forbidden forms or explicit Polivanov markers
        next if check_tier1(clean, clean, nil, 0, 0)
        next if clean.include?("дзі") || clean.include?("тсу") || clean.include?("сьо")
        next if Lexicon.find_by_stem(clean)

        allow << clean
      end
    end
  end

  out_file = out_path || Rules.path_for("native_ua_allowlist.yml")
  File.write(out_file, YAML.dump(allow.to_a.sort))
  reload_allowlist!
  Rules.reload!
  allow
end

.check_tier1(raw_tok, clean_tok, file_path, line, col) ⇒ Object



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/yanagi/audit.rb', line 196

def self.check_tier1(raw_tok, clean_tok, file_path, line, col)
  mora_map = Rules.mora_map
  return nil unless mora_map
  return nil if clean_tok.length < Lexicon::MIN_STEM_LENGTH

  mora_map.each do |_kana, data|
    forbidden_list = data[:forbidden] || []
    canonical_cyr = data[:cyrillic]&.to_s
    next unless canonical_cyr && !forbidden_list.empty?

    forbidden_list.each do |forb|
      next unless clean_tok.include?(forb)

      candidate = clean_tok.gsub(forb, canonical_cyr)
      lex_match = Lexicon.find_by_stem(candidate)

      # Japanese mora rules apply only to Japanese-origin terms. Chinese works
      # and place names in the glossary carry their own transliteration.
      next if lex_match && lex_match[:entry] && lex_match[:entry][:origin].to_s == "zh"

      if lex_match && lex_match[:stem].length >= Lexicon::MIN_STEM_LENGTH
        canonical_word = if raw_tok == raw_tok.capitalize
                           candidate.capitalize
                         elsif raw_tok == raw_tok.upcase
                           candidate.upcase
                         else
                           candidate
                         end

        return Finding.new(
          file: file_path,
          line: line,
          col: col,
          token: raw_tok,
          tier: 1,
          message: "Forbidden transliteration '#{forb}' for '#{canonical_cyr}' matching lexicon '#{lex_match[:key]}'",
          canonical: canonical_word,
          approved: true
        )
      end
    end
  end

  nil
end

.has_jp_markers?(text) ⇒ Boolean

Returns:

  • (Boolean)


242
243
244
# File 'lib/yanagi/audit.rb', line 242

def self.has_jp_markers?(text)
  JP_MARKERS.any? { |m| text.include?(m) }
end

.has_polivanov_markers?(text) ⇒ Boolean

Returns:

  • (Boolean)


246
247
248
# File 'lib/yanagi/audit.rb', line 246

def self.has_polivanov_markers?(text)
  POLIVANOV_MARKERS.any? { |m| text.include?(m) }
end

.reload_allowlist!Object



66
67
68
# File 'lib/yanagi/audit.rb', line 66

def self.reload_allowlist!
  @allowlist = nil
end

.scan(paths, tier2: true, tier3: false) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/yanagi/audit.rb', line 135

def self.scan(paths, tier2: true, tier3: false)
  paths = [paths] unless paths.is_a?(Array)
  all_findings = []

  paths.each do |p|
    if File.directory?(p)
      Dir.glob(File.join(p, "**", "*.{org,md,txt}")).each do |f|
        next if f.include?("glossary.org") || f.include?("transliteration.md")
        content = File.read(f, encoding: "UTF-8")
        all_findings.concat(scan_text(content, file_path: f, tier2: tier2, tier3: tier3))
      end
    elsif File.file?(p)
      content = File.read(p, encoding: "UTF-8")
      all_findings.concat(scan_text(content, file_path: p, tier2: tier2, tier3: tier3))
    end
  end

  all_findings
end

.scan_text(text, file_path: nil, tier2: true, tier3: false) ⇒ Object



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/yanagi/audit.rb', line 70

def self.scan_text(text, file_path: nil, tier2: true, tier3: false)
  findings = []
  allow = allowlist
  lines = text.lines

  lines.each_with_index do |line_text, line_idx|
    line_text.scan(/(?<![\w\p{Cyrillic}])([\p{Cyrillic}'’\`-]+)(?![\w\p{Cyrillic}])/) do
      match_data = Regexp.last_match
      raw_tok = match_data[1]
      col = match_data.begin(0) + 1

      clean = raw_tok.downcase.gsub(/^[-'\`"]+|[-'\`"]+$/, "")
      next if clean.length < 2

      # 1. Skip if allowlisted native Ukrainian word
      next if allow.include?(clean)
      stem, _ = Lexicon.strip_inflection(clean)
      next if stem.length >= 3 && allow.include?(stem)

      # 2. Tier 1 Check (AUTOFIX-eligible)
      # Does this un-allowlisted token match a lexicon entry via forbidden substitution?
      t1 = check_tier1(raw_tok, clean, file_path, line_idx + 1, col)
      if t1
        findings << t1
        next
      end

      # 3. Skip if known in lexicon
      lex_match = Lexicon.find_by_stem(clean)
      next if lex_match

      # 4. Tier 2 Check (JP markers present, absent from allowlist & lexicon)
      if tier2 && has_jp_markers?(clean)
        findings << Finding.new(
          file: file_path,
          line: line_idx + 1,
          col: col,
          token: raw_tok,
          tier: 2,
          message: "Contains Japanese transliteration markers; unanchored in lexicon",
          canonical: nil,
          approved: false
        )
        next
      end

      # 5. Tier 3 Check (Polivanov markers present, off by default)
      if tier3 && has_polivanov_markers?(clean)
        findings << Finding.new(
          file: file_path,
          line: line_idx + 1,
          col: col,
          token: raw_tok,
          tier: 3,
          message: "Contains Polivanov digraph marker; expected mostly noise",
          canonical: nil,
          approved: false
        )
      end
    end
  end

  findings
end