#!/usr/bin/env node /** * Lints client/messages/fa.json against the banned-orthography-variant rules in * client/messages/STYLE.md — enforces the phase-12 sweep so it cannot silently regress. * Exits non-zero (and prints every offending key) on any match. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FA_MESSAGES_PATH = join(__dirname, '..', 'messages', 'fa.json'); // Each rule scans the *raw value* of every leaf string in fa.json (flattened key path → value). // `pattern` is a plain substring (not regex) so ZWNJ/hamza characters stay literal and unambiguous. const RULES = [ { name: 'brand name must use ZWNJ (بالین‌یار), never a plain space', pattern: 'بالین یار', }, { name: 'تأیید must use hamza — تایید (hamza-less) is banned', pattern: 'تایید', }, { name: 'جستجو is the one standard form — جست‌وجو is banned', pattern: 'جست‌وجو', }, { name: 'جستجو is the one standard form — جست و جو (spaced) is banned', pattern: 'جست و جو', }, { name: 'the indefinite «ی» misattachment bug — «بازی» (game) instead of «ی» on the right word', // Word-boundary trap: catches "... بازی " (space after) so it doesn't also flag words that // legitimately contain "بازی" as a substring of something else — in this catalog there are none, // but the trailing-space anchor keeps the check honest if one is ever added on purpose elsewhere. pattern: 'بازی ', }, { // Leading space distinguishes the archaic passive auxiliary ("X می‌گردد" = "is X-ed") from the // unrelated, entirely legitimate verb «برمی‌گردد» (returns/comes back), which fuses «بر» directly // onto «می‌گردد» with no space and must never be flagged. name: 'archaic passive می‌گردد is banned — use می‌شود', pattern: ' می‌گردد', }, ]; function flattenStrings(value, path, out) { if (typeof value === 'string') { out.push([path, value]); return; } if (Array.isArray(value)) { value.forEach((item, index) => flattenStrings(item, `${path}[${index}]`, out)); return; } if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value)) { flattenStrings(child, path ? `${path}.${key}` : key, out); } } } function main() { const raw = readFileSync(FA_MESSAGES_PATH, 'utf8'); const messages = JSON.parse(raw); const leaves = []; flattenStrings(messages, '', leaves); const failures = []; for (const rule of RULES) { for (const [path, value] of leaves) { if (value.includes(rule.pattern)) { failures.push({ rule: rule.name, path, value }); } } } if (failures.length === 0) { console.log(`check-copy: ${leaves.length} strings checked, 0 banned variants found.`); return; } console.error(`check-copy: found ${failures.length} banned copy variant(s):\n`); for (const failure of failures) { console.error(` [${failure.rule}]`); console.error(` fa.json → ${failure.path}: "${failure.value}"\n`); } process.exitCode = 1; } main();