#!/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('