Ctrl K
ComunidadGuías…

i18n-drift.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Structural coverage of translated modes (#3168). Offline, read-only, Node only.
 * Adapted from alinaqvi1129's initial checker in PR #3228.
 *
 * node i18n-drift.mjs [--lang tr ...] [--fail-under N] [--json | --summary]
 * Drift is advisory by default (exit 0): coverage is a structural signal, not
 * a verdict on translation quality. --fail-under N turns a language whose
 * heading coverage is below N% into exit 1, so a regression can gate CI without
 * changing the default for everyone else. Invalid arguments or unreadable
 * inputs exit 1 either way.
 *
 * Compare heading levels IN ORDER, never translated words. Coverage measures
 * structural slots, not translation completeness: equal-shaped replacements,
 * changed prose and empty sections cannot be detected. Fenced report examples
 * are excluded; their contract belongs to the evaluation-template parity gate.
 * Without stable section IDs, repeated levels can have multiple alignments.
 * Report all possible missing locations instead of inventing a missing name.
 */
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
import { basename, join, posix, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMainModule } from './lib/is-main-module.mjs';
import { isNestedCheckout } from './lib/mjs-files.mjs';
import { walkTree } from './lib/walk-tree.mjs';

const ROOT = fileURLToPath(new URL('.', import.meta.url));
const LANGUAGE = /^[a-z]{2}(?:-[A-Za-z0-9]{2,8})*$/;

/** Top-level Markdown ATX/setext headings, with one-based source lines. */
export function extractHeadings(text) {
  const headings = [];
  let fence = null;
  let comment = false;
  let paragraph = null;
  for (const [index, raw] of text.split(/\r?\n/).entries()) {
    if (fence) {
      const close = raw.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
      if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
      continue;
    }
    // Comment-looking text in code is literal, including fence info strings.
    const rawOpen = raw.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
    if (!comment && rawOpen && (rawOpen[1][0] !== '`' || !rawOpen[2].includes('`'))) {
      fence = rawOpen[1];
      paragraph = null;
      continue;
    }
    if (!comment && /^(?: {4}|\t)/.test(raw)) { paragraph = null; continue; }
    let line = '';
    let rest = raw;
    while (rest) {
      if (comment) {
        const end = rest.indexOf('-->');
        if (end < 0) break;
        rest = rest.slice(end + 3);
        comment = false;
      } else {
        if (rest.startsWith('\\')) {
          line += rest.slice(0, 2);
          rest = rest.slice(2);
        } else if (rest.startsWith('`')) {
          const ticks = rest.match(/^`+/)[0];
          const close = [...rest.slice(ticks.length).matchAll(/`+/g)].find(match => match[0].length === ticks.length);
          const end = close ? ticks.length + close.index + ticks.length : ticks.length;
          line += rest.slice(0, end);
          rest = rest.slice(end);
        } else if (rest.startsWith('<!--')) {
          rest = rest.slice(4);
          comment = true;
        } else {
          line += rest[0];
          rest = rest.slice(1);
        }
      }
    }
    const open = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
    if (open && (open[1][0] !== '`' || !open[2].includes('`'))) {
      fence = open[1];
      paragraph = null;
      continue;
    }
    const atx = line.match(/^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$/);
    if (atx) {
      headings.push({ level: atx[1].length, text: (atx[2] ?? '').replace(/(?:^|[ \t]+)#+[ \t]*$/, '').trim(), line: index + 1 });
      paragraph = null;
      continue;
    }
    const setext = line.match(/^ {0,3}(=+|-+)[ \t]*$/);
    if (setext && paragraph) {
      headings.push({ level: setext[1][0] === '=' ? 1 : 2, ...paragraph });
      paragraph = null;
      continue;
    }
    // Indented code, list/quote blocks and thematic breaks are not paragraphs.
    if (!line.trim() || /^(?: {4}|\t| {0,3}(?:>|[-+*] |\d+[.)] |[-*_]{3,}\s*$))/.test(line)) {
      paragraph = null;
    } else {
      paragraph = paragraph ? { ...paragraph, text: `${paragraph.text} ${line.trim()}` } : { text: line.trim(), line: index + 1 };
    }
  }
  return headings;
}

/**
 * Longest common subsequence of heading levels. Prefix/suffix tables let us
 * inspect ALL optimal alignments: an unmatched heading is certain only when
 * none can match it. Candidate count is not the number of missing sections.
 */
export function compareStructure(canonicalText, translatedText) {
  const canonical = extractHeadings(canonicalText);
  const translated = extractHeadings(translatedText);
  const n = canonical.length;
  const m = translated.length;
  const table = () => Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
  const prefix = table();
  const suffix = table();
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < m; j++) {
      prefix[i + 1][j + 1] = canonical[i].level === translated[j].level
        ? prefix[i][j] + 1 : Math.max(prefix[i][j + 1], prefix[i + 1][j]);
    }
  }
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      suffix[i][j] = canonical[i].level === translated[j].level
        ? suffix[i + 1][j + 1] + 1 : Math.max(suffix[i + 1][j], suffix[i][j + 1]);
    }
  }
  const covered = suffix[0][0];
  const missing = [];
  const candidates = [];
  for (let i = 0; i < n; i++) {
    let canMatch = false;
    let canSkip = false;
    for (let j = 0; j <= m; j++) {
      if (prefix[i][j] + suffix[i + 1][j] === covered) canSkip = true;
      if (j < m && canonical[i].level === translated[j].level && prefix[i][j] + 1 + suffix[i + 1][j + 1] === covered) canMatch = true;
    }
    if (!canMatch) missing.push(canonical[i]);
    else if (canSkip) candidates.push(canonical[i]);
  }
  return {
    covered, total: n, coverage: n ? covered / n : 1,
    canonicalCount: n, translatedCount: m,
    missingCount: n - covered, extraCount: m - covered, missing, candidates,
  };
}

function modePath(value) {
  return typeof value === 'string' && value.endsWith('.md')
    && !value.includes('\\') && value.split('/').every(part => part && part !== '.' && part !== '..')
    && !value.startsWith('/') && !value.includes(':')
    && !/^(?:README\.md|_profile(?:\.template)?\.md|_custom(?:\.template)?\.md)$/i.test(posix.basename(value));
}

// Do not follow symlinks or nested checkouts while reading mode sources.
function sourcePath(root, relativePath) {
  let path = root;
  for (const part of ['modes', ...relativePath.split('/')]) {
    path = join(path, part);
    if (!existsSync(path)) return null;
    const stat = lstatSync(path);
    if (stat.isSymbolicLink() || (stat.isDirectory() && isNestedCheckout(path))) return null;
  }
  return path;
}

/** README table: translated filename -> source path relative to modes/. */
export function parseReadmeMapping(readmePath) {
  const map = new Map();
  if (!readmePath || !existsSync(readmePath)) return map;
  for (const line of readFileSync(readmePath, 'utf8').split(/\r?\n/)) {
    const row = line.match(/^\s*\|([^|]+)\|([^|]+)\|/);
    if (!row) continue;
    const translated = row[1].trim().replace(/`/g, '');
    const canonical = row[2].match(/\bmodes\/([^`\s()|]+\.md)/)?.[1];
    if (modePath(translated) && modePath(canonical)) map.set(translated, canonical);
  }
  return map;
}

export function discoverLangs(root = ROOT) {
  const dir = sourcePath(root, '');
  if (!dir || !lstatSync(dir).isDirectory()) throw new Error('modes/ directory not found');
  return readdirSync(dir, { withFileTypes: true })
    .filter(entry => LANGUAGE.test(entry.name) && entry.isDirectory() && !isNestedCheckout(join(dir, entry.name)))
    .map(entry => entry.name).sort();
}

function markdownFiles(dir) {
  // The root is vetted here because walkTree deliberately never tests its own
  // root — a language directory that IS a checkout, or a link, is not ours.
  if (!dir || !existsSync(dir) || !lstatSync(dir).isDirectory() || lstatSync(dir).isSymbolicLink() || isNestedCheckout(dir)) return [];
  // Below it, walkTree skips nested checkouts and scratch copies on every
  // descent, and ignores links by default, as the Dirent-based recursion this
  // replaced did.
  return walkTree(dir, { skip: (entry) => entry.name.startsWith('.') })
    .filter((abs) => modePath(basename(abs)))
    .map((abs) => relative(dir, abs).split(sep).join('/'))
    .sort();
}

/** Compare the shipped subset, plus README-promised files that were deleted. */
export function checkLang(lang, root = ROOT) {
  if (!discoverLangs(root).includes(lang)) throw new Error(`language directory not found: modes/${lang}/`);
  const maps = new Map();
  const mapping = (language) => {
    if (!maps.has(language)) maps.set(language, parseReadmeMapping(sourcePath(root, `${language}/README.md`)));
    return maps.get(language);
  };
  const canonicalFor = (language, file, seen = new Set()) => {
    const key = `${language}/${file}`;
    if (seen.has(key)) return null;
    seen.add(key);
    const source = mapping(language).get(file) ?? file;
    const [first, ...tail] = source.split('/');
    // Some languages translate another locale (NL -> FR); follow its declared
    // source to the canonical root rather than measuring two stale mirrors.
    if (tail.length && LANGUAGE.test(first)) return canonicalFor(first, tail.join('/'), seen);
    return modePath(source) ? source : null;
  };
  const names = new Set([...markdownFiles(sourcePath(root, lang)), ...mapping(lang).keys()]);
  const files = [...names].sort().map(translated => {
    const canonical = canonicalFor(lang, translated);
    const canonicalPath = canonical && sourcePath(root, canonical);
    const base = { translated, canonical, result: null, skipped: false };
    if (!canonicalPath || !lstatSync(canonicalPath).isFile()) {
      return { ...base, skipped: true, skipReason: canonical ? `canonical modes/${canonical} not found or excluded` : 'cyclic or invalid canonical mapping' };
    }
    const translatedPath = sourcePath(root, `${lang}/${translated}`);
    const missingFile = !translatedPath || !lstatSync(translatedPath).isFile();
    return {
      ...base, missingFile,
      result: compareStructure(readFileSync(canonicalPath, 'utf8'), missingFile ? '' : readFileSync(translatedPath, 'utf8')),
    };
  });
  return { lang, files };
}

function summarize(files) {
  const checked = files.filter(file => !file.skipped);
  const sum = key => checked.reduce((total, file) => total + file.result[key], 0);
  return {
    filesChecked: checked.length, filesSkipped: files.length - checked.length,
    sectionsCovered: sum('covered'), sectionsTotal: sum('total'),
    missingCount: sum('missingCount'), extraCount: sum('extraCount'),
  };
}

export function toJSON(results) {
  return results.map(result => ({ ...result, summary: summarize(result.files) }));
}

const percent = result => result.total ? `${Math.round(result.coverage * 100)}%` : 'n/a';

/**
 * A language's aggregate coverage as a whole percentage, rounded DOWN.
 *
 * Floor rather than round, because this number is printed next to the threshold
 * that failed: 199 of 250 sections is 79.6%, and a line reading "below
 * --fail-under 80: zh 80%" contradicts itself, while rounding also flattens
 * 1999 of 2500 to the same 80%. Rounding down can only understate coverage,
 * which is the safe direction for a gate.
 *
 * The per-file column in the report table keeps its own rounding: it measures a
 * single file, not the language total this threshold is compared against.
 *
 * @param {{sectionsCovered: number, sectionsTotal: number}} stats - A language summary.
 * @returns {number|null} Whole percent, or null when there is nothing measured.
 */
export function coveragePercent(stats) {
  const total = stats?.sectionsTotal ?? 0;
  if (!total) return null;
  return Math.floor((stats.sectionsCovered / total) * 100);
}

export function formatReport(results, { summary = false } = {}) {
  const lines = ['i18n structural coverage (heading levels/order; not translation quality)'];
  for (const { lang, files, summary: stats } of toJSON(results)) {
    lines.push(`\n${lang}: ${stats.sectionsCovered}/${stats.sectionsTotal} sections; ${stats.filesChecked} files checked, ${stats.filesSkipped} skipped`);
    if (summary) continue;
    lines.push('| Translation | Canonical | Covered / total | Structural difference |', '| --- | --- | --- | --- |');
    const cell = value => value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]/g, ' ');
    for (const file of files) {
      const label = `modes/${lang}/${file.translated}`;
      if (file.skipped) {
        lines.push(`| ${cell(label)} | ${cell(file.canonical ?? '—')} | — | skipped: ${cell(file.skipReason)} |`);
        continue;
      }
      const r = file.result;
      const difference = file.missingFile ? 'missing file' : `${r.missingCount} missing slots; ${r.extraCount} extra/unmatched headings`;
      lines.push(`| ${cell(label)} | modes/${cell(file.canonical)} | ${r.covered}/${r.total} (${percent(r)}) | ${difference} |`);
    }
    for (const file of files.filter(file => !file.skipped && file.result.missingCount)) {
      lines.push(`\n${file.translated} — canonical locations to review:`);
      for (const h of file.result.missing) lines.push(`  missing structural slot: modes/${file.canonical}:${h.line} ${'#'.repeat(h.level)} ${h.text}`);
      if (file.result.candidates.length) {
        lines.push(`  ${file.result.missingCount - file.result.missing.length} further slot(s) missing; exact identity is ambiguous among:`);
        for (const h of file.result.candidates) lines.push(`    modes/${file.canonical}:${h.line} ${'#'.repeat(h.level)} ${h.text}`);
      }
    }
  }
  lines.push('\n100% means compatible heading structure only. Same-shaped omissions, prose changes and translation quality are not measured.');
  return lines.join('\n');
}

function main(args) {
  const langs = [];
  let json = false;
  let summary = false;
  let help = false;
  let failUnder = null;
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--lang' || args[i].startsWith('--lang=')) {
      const lang = args[i] === '--lang' ? args[++i] : args[i].slice('--lang='.length);
      if (!lang || !LANGUAGE.test(lang)) throw new Error('--lang requires a language code, e.g. tr or zh-TW');
      langs.push(lang);
    } else if (args[i] === '--json') json = true;
    else if (args[i] === '--summary') summary = true;
    else if (args[i] === '--fail-under' || args[i].startsWith('--fail-under=')) {
      const raw = args[i] === '--fail-under' ? args[++i] : args[i].slice('--fail-under='.length);
      const value = Number(raw);
      // Whole percentages only: "80.5" reads like a threshold but invites a
      // boundary argument no one needs, and Number('') is 0 — which would
      // silently pass everything. Both are rejected by name instead.
      if (raw === undefined || raw === '' || !Number.isInteger(value) || value < 0 || value > 100) {
        throw new Error('--fail-under requires a whole percentage between 0 and 100, e.g. --fail-under 80');
      }
      failUnder = value;
    } else if (args[i] === '--help' || args[i] === '-h') help = true;
    else throw new Error(`unknown argument: ${args[i]}`);
  }
  if (json && summary) throw new Error('choose --json or --summary');
  if (help) {
    console.log('Usage: node i18n-drift.mjs [--lang tr ...] [--fail-under N] [--json | --summary]\nRead-only, offline. Drift exits 0 by default; --fail-under exits 1 for any selected language below N% coverage. Invalid arguments or unreadable inputs exit 1.');
    return;
  }
  const selected = [...new Set(langs.length ? langs : discoverLangs())];
  if (!selected.length) throw new Error('no language directories found in modes/');
  const results = selected.map(lang => checkLang(lang));
  console.log(json ? JSON.stringify(toJSON(results), null, 2) : formatReport(results, { summary }));

  // Checked after the report, so the run still prints what fell short. A
  // language with nothing measurable (every file skipped) has no percentage to
  // judge and is left alone rather than counted as 100% or 0%.
  if (failUnder !== null) {
    const below = toJSON(results)
      .map(({ lang, summary: stats }) => ({ lang, coverage: coveragePercent(stats) }))
      .filter(entry => entry.coverage !== null && entry.coverage < failUnder);
    if (below.length) {
      console.error(`i18n-drift: ${below.length} language(s) below --fail-under ${failUnder}: ${below.map(entry => `${entry.lang} ${entry.coverage}%`).join(', ')}`);
      process.exitCode = 1;
    }
  }
}

if (isMainModule(import.meta.url)) {
  try { main(process.argv.slice(2)); }
  catch (error) { console.error(`i18n-drift: ${error.message}`); process.exitCode = 1; }
}