You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/translations-check.ts

579 lines
20 KiB

/**
* translations-check — validate the texts/idlangref discipline across UIX.
*
* Runs five independent checks and exits non-zero if any of them fails:
*
* 1. **Morfo `texts:` shape**: every value is an absolute idlangref
* (`'#?…|fallback'`). Component-scoped refs (`#?components.{kebab}.…`) must
* match the morfo's own `kebab`.
*
* 2. **Compiled ref coverage**: every `v.translationRef` / `v.commonRef` /
* `v.langRef` in a morfo's aria/data contract — as NORMALIZED by the real
* compiler (`compileMorfo`, the same path the runtime resolves) — points to
* a catalog leaf. This is the check that catches the silent-fallback class:
* `v.translationRef('prevMonth')` with a catalog key `prev-month` compiles,
* runs, and ships the English fallback on every locale (measured 2026-08-10
* in chronos: five labels). `texts:` is a declaration, never a lookup table,
* so only the compiled key can be validated.
*
* 3. **Component catalog coverage**: every component-scoped idlangref in
* source points into `src/uix/langs/components/{kebab}.ts`. The dotted path
* must resolve to a leaf `LangRecord` (object whose values are strings).
*
* 4. **Common catalog coverage**: every `#?common.…` idlangref points into
* `src/uix/langs/common.ts`.
*
* 5. **Orphan detection**: every leaf in the component catalogs is referenced
* by a `morfo.texts` entry, a compiled morfo ref, or a source file. Orphan
* entries are surfaced as warnings (not errors) so consumers can remove
* them safely.
*
* Also enforces:
* - No raw `translations:` field survives on any morfo.
* - No source file (.ts / .svelte under src/uix) declares an idlangref to a
* nonexistent path.
*
* The catalogs and morfos are IMPORTED (they are pure data modules) and morfos
* are compiled with the real `compileMorfo` — never re-parsed with regex. The
* previous implementation regex-evaluated the catalog sources and its comment
* stripper corrupted any string containing `//` (a `https://` URL crashed the
* whole run), while the class it was credited with catching — translationRef
* call sites — was never scanned at all: a `v.translationRef('key')` contains
* no `#?` literal until the compiler normalizes it. Validating the compiled
* contract closes both holes at once.
*
* `.ts` sources are scanned via the TypeScript AST (string literals only, so a
* `//` inside a string can never eat the rest of the line and comments never
* produce false references). `.svelte` sources get their `<script>` blocks
* AST-scanned the same way; the remaining template only has HTML comments
* stripped — line comments are left alone there on purpose, because stripping
* them is exactly what turned URLs into silent false negatives.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import { compileMorfo } from '../src/uix/morfo/compile';
import type { SourcePlan } from '../src/uix/morfo/compile';
import type { Morfo } from '../src/uix/morfo/types';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..');
const UIX = join(REPO, 'src', 'uix');
const MORFO_DIR = join(UIX, 'morfo', 'components');
const LANGS_COMMON = join(UIX, 'langs', 'common.ts');
const LANGS_COMPONENTS = join(UIX, 'langs', 'components');
type LangLeaf = Record<string, string>;
type LangBranch = { [key: string]: LangLeaf | LangBranch };
interface Finding {
severity: 'error' | 'warn';
source: string;
message: string;
}
const findings: Finding[] = [];
function err(source: string, message: string) {
findings.push({ severity: 'error', source, message });
}
function warn(source: string, message: string) {
findings.push({ severity: 'warn', source, message });
}
// ── Module loading ────────────────────────────────────────────────────────
async function importModule(file: string): Promise<Record<string, unknown> | null> {
try {
return (await import(pathToFileURL(file).href)) as Record<string, unknown>;
} catch (e) {
err(file, `Failed to import: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}
function kebabToCamel(kebab: string): string {
return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
async function loadCommonCatalog(): Promise<LangBranch> {
const mod = await importModule(LANGS_COMMON);
const branch = mod?.commonLangs as LangBranch | undefined;
if (!branch) {
err(LANGS_COMMON, 'No commonLangs export found.');
return {};
}
return branch;
}
async function loadComponentCatalogs(): Promise<Map<string, LangBranch>> {
const map = new Map<string, LangBranch>();
const files = readdirSync(LANGS_COMPONENTS).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
for (const file of files) {
const kebab = file.replace(/\.ts$/, '');
const exportName = `${kebabToCamel(kebab)}Langs`;
const mod = await importModule(join(LANGS_COMPONENTS, file));
if (!mod) continue;
const data = mod[exportName] as LangBranch | undefined;
if (!data) {
err(join(LANGS_COMPONENTS, file), `No export const ${exportName} found.`);
continue;
}
map.set(kebab, data);
}
return map;
}
interface LoadedMorfo {
file: string;
morfo: Morfo;
}
function looksLikeMorfo(x: unknown): x is Morfo {
return (
!!x &&
typeof x === 'object' &&
typeof (x as Morfo).kebab === 'string' &&
Array.isArray((x as Morfo).parts)
);
}
async function loadMorfos(): Promise<LoadedMorfo[]> {
const out: LoadedMorfo[] = [];
// Dedupe by identity: a module that re-exports another component's morfo
// must not double-count it (compileMorfo caches by identity for the same
// reason).
const seen = new Set<unknown>();
const files = readdirSync(MORFO_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
for (const file of files) {
const path = join(MORFO_DIR, file);
const mod = await importModule(path);
if (!mod) continue;
for (const exported of Object.values(mod)) {
if (!looksLikeMorfo(exported) || seen.has(exported)) continue;
seen.add(exported);
out.push({ file: path, morfo: exported });
}
}
return out;
}
// ── Path resolution inside a catalog branch ───────────────────────────────
function resolvePath(node: LangBranch, path: string[]): unknown {
let cur: unknown = node;
for (const seg of path) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}
function isLeafRecord(v: unknown): v is LangLeaf {
return (
typeof v === 'object' &&
v !== null &&
!Array.isArray(v) &&
Object.values(v as Record<string, unknown>).every((x) => typeof x === 'string')
);
}
function collectLeafPaths(node: unknown, prefix: string[] = []): string[][] {
if (isLeafRecord(node)) return [prefix];
if (typeof node !== 'object' || node === null) return [];
return Object.entries(node).flatMap(([k, v]) => collectLeafPaths(v, [...prefix, k]));
}
// ── Morfo texts + compiled refs ───────────────────────────────────────────
interface MorfoTextEntry {
file: string;
kebab: string;
textKey: string;
rawRef: string;
}
function morfoTextEntries(loaded: LoadedMorfo[]): MorfoTextEntry[] {
const out: MorfoTextEntry[] = [];
for (const { file, morfo } of loaded) {
// Hard ban: no legacy `translations:` field.
if ('translations' in (morfo as object)) {
err(file, 'Legacy `translations:` field present. Replace with `texts:` idlangrefs.');
}
const texts = (morfo as { texts?: Record<string, string> }).texts ?? {};
for (const [textKey, rawRef] of Object.entries(texts)) {
out.push({ file, kebab: morfo.kebab, textKey, rawRef });
}
}
return out;
}
interface CompiledRefEntry {
file: string;
kebab: string;
part: string;
attr: string;
/** Normalized idlangref as the runtime will resolve it: `#?path|fallback`. */
ref: string;
}
function visitSource(source: SourcePlan, onRef: (ref: string) => void): void {
if (source.kind === 'mapRef') {
visitSource(source.source, onRef);
return;
}
if (source.kind === 'translationRef') onRef(source.key);
}
function collectCompiledRefs(loaded: LoadedMorfo[]): CompiledRefEntry[] {
const out: CompiledRefEntry[] = [];
for (const { file, morfo } of loaded) {
let compiled;
try {
compiled = compileMorfo(morfo);
} catch (e) {
err(file, `compileMorfo failed: ${e instanceof Error ? e.message : String(e)}`);
continue;
}
for (const [partKebab, part] of compiled.parts.byKebab) {
for (const plan of part.dynamicAttrs) {
visitSource(plan.source, (ref) => {
out.push({ file, kebab: compiled.kebab, part: partKebab, attr: plan.attr, ref });
});
}
}
}
return out;
}
// ── Source idlangref scanning ─────────────────────────────────────────────
function listSourceFiles(root: string): string[] {
if (!statSync(root).isDirectory()) return [];
const out: string[] = [];
for (const entry of readdirSync(root)) {
const path = join(root, entry);
const stat = statSync(path);
if (stat.isDirectory()) {
out.push(...listSourceFiles(path));
} else if (
(entry.endsWith('.ts') || entry.endsWith('.svelte')) &&
!entry.endsWith('.test.ts') &&
!entry.endsWith('.test.svelte')
) {
out.push(path);
}
}
return out;
}
// Only match refs with no `{` `}` `:` whitespace — those are real idlangrefs,
// not doc placeholders like `#?components.{kebab}.{key}`.
const IDLANGREF_RE = /#\?([A-Za-z][\w.-]*)(?:\|([^'"`\n]*))?/g;
/**
* A text chunk extracted from a source file. `truncated` marks template-literal
* heads / middle spans: their text ends where a `${…}` substitution begins, so
* a ref match that reaches the chunk's end is INCOMPLETE — a dynamic ref like
* `` `#?components.x.${key}|…` `` must not be validated as the literal path
* `components.x.`; it contributes a dynamic PREFIX instead.
*/
interface SourceChunk {
text: string;
truncated: boolean;
}
/** Every string-literal text in a TS source, via the real parser. */
function stringChunksOf(fileName: string, source: string): SourceChunk[] {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false);
const out: SourceChunk[] = [];
const visit = (node: ts.Node): void => {
if (ts.isStringLiteralLike(node)) {
out.push({ text: node.text, truncated: false });
} else if (ts.isTemplateExpression(node)) {
out.push({ text: node.head.text, truncated: true });
for (let i = 0; i < node.templateSpans.length; i++) {
// Every span's literal but the last is followed by another `${…}`.
const last = i === node.templateSpans.length - 1;
out.push({ text: node.templateSpans[i].literal.text, truncated: !last });
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return out;
}
interface SourceScan {
/** ref path → files that reference it. */
occurrences: Map<string, Set<string>>;
/**
* Path prefixes of refs built dynamically (template substitution right
* after the prefix). A catalog leaf under one of these cannot be proven
* orphan — the concrete key only exists at runtime.
*/
dynamicPrefixes: Set<string>;
}
function scanChunk(chunk: SourceChunk, file: string, scan: SourceScan): void {
IDLANGREF_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = IDLANGREF_RE.exec(chunk.text))) {
const reachesEnd = m.index + m[0].length === chunk.text.length;
if (chunk.truncated && reachesEnd) {
scan.dynamicPrefixes.add(m[1].replace(/\.$/, ''));
continue;
}
if (!scan.occurrences.has(m[1])) scan.occurrences.set(m[1], new Set());
scan.occurrences.get(m[1])!.add(file);
}
}
function refChunksOfFile(path: string): SourceChunk[] {
const source = readFileSync(path, 'utf8');
if (!path.endsWith('.svelte')) return stringChunksOf(path, source);
const chunks: SourceChunk[] = [];
const scriptRe = /<script[^>]*>([\s\S]*?)<\/script>/g;
let m: RegExpExecArray | null;
while ((m = scriptRe.exec(source))) {
chunks.push(...stringChunksOf(path, m[1]));
}
// Template remainder: strip HTML comments only. `//` stays — it lives
// inside URLs in quoted strings, and eating the rest of the line is how
// real refs went unscanned.
chunks.push({
text: source.replace(scriptRe, '').replace(/<!--[\s\S]*?-->/g, ''),
truncated: false
});
return chunks;
}
function scanIdlangrefsInSource(): SourceScan {
const scan: SourceScan = { occurrences: new Map(), dynamicPrefixes: new Set() };
for (const file of listSourceFiles(UIX)) {
for (const chunk of refChunksOfFile(file)) {
scanChunk(chunk, file, scan);
}
}
return scan;
}
// ── Checks ────────────────────────────────────────────────────────────────
function checkMorfoTextsShape(
entries: MorfoTextEntry[],
components: Map<string, LangBranch>,
common: LangBranch
): void {
for (const { file, kebab, textKey, rawRef } of entries) {
if (!rawRef.startsWith('#?')) {
err(
file,
`texts.${textKey}: must be an absolute idlangref starting with '#?'. Got ${JSON.stringify(rawRef)}.`
);
continue;
}
const body = rawRef.slice(2);
const pipe = body.indexOf('|');
const refPath = pipe === -1 ? body : body.slice(0, pipe);
const fallback = pipe === -1 ? undefined : body.slice(pipe + 1);
if (!fallback) {
warn(file, `texts.${textKey}: idlangref ${rawRef} has no fallback after '|'.`);
}
const segments = refPath.split('.');
if (segments[0] === 'components') {
if (segments[1] !== kebab) {
err(
file,
`texts.${textKey}: ref points to ${segments[1]} but the morfo kebab is ${kebab}.`
);
continue;
}
const branch = components.get(kebab);
if (!branch) {
err(file, `texts.${textKey}: no catalog found at src/uix/langs/components/${kebab}.ts.`);
continue;
}
const leaf = resolvePath(branch, segments.slice(2));
if (!isLeafRecord(leaf)) {
err(
file,
`texts.${textKey}: ref ${refPath} does not resolve to a LangRecord leaf in components/${kebab}.ts.`
);
}
} else if (segments[0] === 'common') {
const leaf = resolvePath(common, segments.slice(1));
if (!isLeafRecord(leaf)) {
err(
file,
`texts.${textKey}: ref ${refPath} does not resolve to a LangRecord leaf in langs/common.ts.`
);
}
} else {
// app-scoped refs like `#?app.foo.bar`: cannot validate inside UIX.
warn(
file,
`texts.${textKey}: ref ${refPath} is outside common/components; not validated by UIX.`
);
}
}
}
function checkCompiledRefs(
entries: CompiledRefEntry[],
components: Map<string, LangBranch>,
common: LangBranch
): void {
for (const { file, kebab, part, attr, ref } of entries) {
const body = ref.startsWith('#?') ? ref.slice(2) : ref;
const pipe = body.indexOf('|');
const refPath = pipe === -1 ? body : body.slice(0, pipe);
const segments = refPath.split('.');
const where = `${kebab}.${part} ${attr}`;
if (segments[0] === 'components') {
if (segments[1] !== kebab) {
err(
file,
`${where}: compiled ref ${refPath} points into components.${segments[1]} but the morfo kebab is ${kebab}.`
);
continue;
}
const branch = components.get(kebab);
if (!branch) {
err(
file,
`${where}: no catalog at src/uix/langs/components/${kebab}.ts for compiled ref ${refPath}.`
);
continue;
}
const leaf = resolvePath(branch, segments.slice(2));
if (!isLeafRecord(leaf)) {
err(
file,
`${where}: compiled ref ${refPath} does not resolve to a leaf in components/${kebab}.ts — ` +
'translationRef takes the CATALOG key verbatim; an alias dies to the English fallback.'
);
}
} else if (segments[0] === 'common') {
const leaf = resolvePath(common, segments.slice(1));
if (!isLeafRecord(leaf)) {
err(
file,
`${where}: compiled ref ${refPath} does not resolve to a leaf in langs/common.ts — ` +
'commonRef takes the CATALOG group verbatim; an alias dies to the English fallback.'
);
}
} else {
warn(
file,
`${where}: compiled ref ${refPath} is outside common/components; not validated by UIX.`
);
}
}
}
function checkComponentCatalogOrphans(
components: Map<string, LangBranch>,
morfoEntries: MorfoTextEntry[],
compiledRefs: CompiledRefEntry[],
scan: SourceScan
): void {
const referenced = new Set<string>();
for (const e of morfoEntries) {
const refPath = e.rawRef.slice(2).split('|')[0];
referenced.add(refPath);
}
for (const e of compiledRefs) {
const body = e.ref.startsWith('#?') ? e.ref.slice(2) : e.ref;
referenced.add(body.split('|')[0]);
}
for (const path of scan.occurrences.keys()) referenced.add(path);
const dynamicPrefixes = [...scan.dynamicPrefixes];
for (const [kebab, branch] of components) {
const leaves = collectLeafPaths(branch);
for (const leafPath of leaves) {
const full = `components.${kebab}.${leafPath.join('.')}`;
if (referenced.has(full)) continue;
// A ref built at runtime (`#?components.x.${key}`) can reach any leaf
// under its prefix — those cannot be proven orphan.
if (dynamicPrefixes.some((p) => full === p || full.startsWith(`${p}.`))) continue;
warn(
join(LANGS_COMPONENTS, `${kebab}.ts`),
`Orphan: ${full} is not referenced by any morfo.texts entry, compiled ref or source file.`
);
}
}
}
function checkSourceIdlangrefs(
occurrences: Map<string, Set<string>>,
components: Map<string, LangBranch>,
common: LangBranch
): void {
for (const [path, files] of occurrences) {
const segments = path.split('.');
if (segments[0] === 'components') {
const kebab = segments[1];
const branch = components.get(kebab);
if (!branch) continue; // not a UIX-owned component
const leaf = resolvePath(branch, segments.slice(2));
if (!isLeafRecord(leaf)) {
for (const file of files) {
err(file, `idlangref ${path} does not resolve to a leaf in components/${kebab}.ts.`);
}
}
} else if (segments[0] === 'common') {
const leaf = resolvePath(common, segments.slice(1));
if (!isLeafRecord(leaf)) {
for (const file of files) {
err(file, `idlangref ${path} does not resolve to a leaf in langs/common.ts.`);
}
}
}
}
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
const common = await loadCommonCatalog();
const components = await loadComponentCatalogs();
const morfos = await loadMorfos();
const morfoEntries = morfoTextEntries(morfos);
const compiledRefs = collectCompiledRefs(morfos);
const scan = scanIdlangrefsInSource();
checkMorfoTextsShape(morfoEntries, components, common);
checkCompiledRefs(compiledRefs, components, common);
checkSourceIdlangrefs(scan.occurrences, components, common);
checkComponentCatalogOrphans(components, morfoEntries, compiledRefs, scan);
const errors = findings.filter((f) => f.severity === 'error');
const warnings = findings.filter((f) => f.severity === 'warn');
for (const f of findings) {
const rel = f.source.replace(REPO + '\\', '').replace(REPO + '/', '');
const tag = f.severity === 'error' ? 'ERROR' : 'WARN';
console.log(`${tag} ${rel} ${f.message}`);
}
console.log();
console.log(
`Summary: ${morfos.length} morfos · ${morfoEntries.length} texts refs · ` +
`${compiledRefs.length} compiled refs · ${components.size} component catalogs · ` +
`${errors.length} errors · ${warnings.length} warnings.`
);
if (errors.length > 0) process.exit(1);
}
main().catch((e) => {
console.error(e);
process.exit(2);
});

Powered by TurnKey Linux.