#!/usr/bin/env node // Complement the existing i18n-marker inventory with known plain-text display // slots. Parse source only: never execute module registration/catalog code. import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const displayProps = new Map([ ...["PageLayout", "PageHeader", "PageTitle", "AdminPageLayout", "Card", "Dialog", "PageActionBar", "WorkspaceActionBar"].map((name) => [name, new Set(["title", "subtitle"])]), ["FieldLabel", new Set(["label"])], ["MetricCard", new Set(["label"])], ]); const childOwners = new Set(["PageTitle", "TextWithHelp", "h1", "h2", "h3", "h4", "h5", "h6"]); const unknown = Symbol("dynamic"); export function createReader(ts, allowedRoot) { const sources = new Map(); function source(file) { file = resolve(file); if (file !== allowedRoot && !file.startsWith(`${resolve(allowedRoot)}/`)) return null; if (!existsSync(file)) return null; if (!realpathSync(file).startsWith(`${resolve(allowedRoot)}/`)) return null; if (!sources.has(file)) sources.set(file, ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)); return sources.get(file); } function unwrap(node) { while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression; return node; } function imported(sf, identifier) { for (const item of sf.statements) { if (!ts.isImportDeclaration(item) || !item.importClause || !ts.isStringLiteral(item.moduleSpecifier)) continue; const names = item.importClause.namedBindings; if (names && ts.isNamedImports(names)) { const match = names.elements.find((entry) => entry.name.text === identifier); if (match) return { path: item.moduleSpecifier.text, name: match.propertyName?.text ?? match.name.text }; } if (item.importClause.name?.text === identifier) return { path: item.moduleSpecifier.text, name: "default" }; } return null; } function targetFile(sf, specifier) { if (!specifier.startsWith(".")) return null; const base = resolve(dirname(sf.fileName), specifier); return [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts")].find((file) => existsSync(file) && /\.tsx?$/.test(file)) ?? null; } function declaration(sf, name) { for (const statement of sf.statements) { if (ts.isVariableStatement(statement) && statement.declarationList.flags & ts.NodeFlags.Const) { const item = statement.declarationList.declarations.find((node) => ts.isIdentifier(node.name) && node.name.text === name); if (item) return item.initializer; } if (name === "default" && ts.isExportAssignment(statement)) return statement.expression; } return null; } function value(node, sf, seen = new Set()) { node = unwrap(node); if (!node) return unknown; if (ts.isStringLiteralLike(node)) return node.text; if (ts.isIdentifier(node)) { const key = `${sf.fileName}:${node.text}`; if (seen.has(key)) return unknown; const visited = new Set([...seen, key]); const local = declaration(sf, node.text); if (local) return value(local, sf, visited); const external = imported(sf, node.text); const target = external && targetFile(sf, external.path); const loaded = target && source(target); return loaded ? value(declaration(loaded, external.name), loaded, visited) : unknown; } if (ts.isObjectLiteralExpression(node)) { const result = {}; for (const property of node.properties) { if (ts.isSpreadAssignment(property)) { const spread = value(property.expression, sf, seen); if (spread !== unknown && spread && typeof spread === "object") Object.assign(result, spread); else result.__dynamicSpread = true; } else if (ts.isPropertyAssignment(property)) { result[property.name.text ?? property.name.getText(sf)] = value(property.initializer, sf, seen); } else if (ts.isShorthandPropertyAssignment(property)) result[property.name.text] = value(property.name, sf, seen); } return result; } return unknown; } return { source, value, declaration, imported, unwrap }; } function componentName(ts, reader, sf, node) { const written = node.getText(sf); if (/^h[1-6]$/.test(written)) return written; if (ts.isIdentifier(node)) { const imported = reader.imported(sf, written); if (imported?.name === "default") return imported.path.split("/").at(-1).replace(/\.[tj]sx?$/, ""); return imported?.name ?? written; } if (ts.isPropertyAccessExpression(node)) { const owner = node.expression.getText(sf); const namespace = sf.statements.find((item) => ts.isImportDeclaration(item) && item.importClause?.namedBindings && ts.isNamespaceImport(item.importClause.namedBindings) && item.importClause.namedBindings.name.text === owner); if (namespace) return node.name.text; } return written; } function sourceFiles(directory) { if (!existsSync(directory)) return []; return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { if (entry.name.startsWith(".") || entry.name === "node_modules") return []; const file = join(directory, entry.name); if (entry.isDirectory()) return sourceFiles(file); return entry.isFile() && /\.[tj]sx?$/.test(file) ? [file] : []; }); } export function auditRepository(ts, repositoryRoot, coreRoot) { const reader = createReader(ts, repositoryRoot); const coreReader = createReader(ts, coreRoot); const coreFile = coreReader.source(join(coreRoot, "webui/src/i18n/generatedTranslations.ts")); const coreCatalog = coreFile ? coreReader.value(coreReader.declaration(coreFile, "generatedTranslations"), coreFile) : {}; const moduleFile = reader.source(join(repositoryRoot, "webui/src/module.ts")); let moduleCatalog = {}; let registration = repositoryRoot === coreRoot ? "core-default" : "absent"; if (moduleFile) { for (const statement of moduleFile.statements) { if (!ts.isVariableStatement(statement) || !statement.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)) continue; for (const node of statement.declarationList.declarations) { if (!node.initializer || !(/PlatformWebModule/.test(node.type?.getText(moduleFile) ?? "") || /Module$/.test(node.name.getText(moduleFile)))) continue; const evaluated = reader.value(node.initializer, moduleFile); if (evaluated && typeof evaluated === "object" && Object.hasOwn(evaluated, "translations")) { moduleCatalog = evaluated.translations; registration = moduleCatalog !== unknown && moduleCatalog && typeof moduleCatalog === "object" && !moduleCatalog.__dynamicSpread && !moduleCatalog.en?.__dynamicSpread && !moduleCatalog.de?.__dynamicSpread ? "registered" : "dynamic"; } } } } const findings = [], review = [], labels = []; const add = (text, node, sf, slot) => { const position = sf.getLineAndCharacterOfPosition(node.getStart(sf)); const location = { file: sf.fileName, line: position.line + 1, slot }; if (typeof text !== "string") { review.push({ ...location, code: "dynamic-display-slot", message: "Runtime data or computed text: verify with the owning module/locale context." }); return; } text = text.replace(/\s+/g, " ").trim(); if (!text || text.startsWith("i18n:") || !/[\p{L}]/u.test(text)) return; const missing = ["en", "de"].filter((locale) => { const translated = moduleCatalog?.[locale]?.[text] ?? coreCatalog?.[locale]?.[text]; return typeof translated !== "string" || !translated.trim(); }); labels.push({ ...location, text, missing_locales: missing }); if (missing.length) { const item = { ...location, code: "plain-label-missing-translation", text, missing_locales: missing, registration }; if (registration === "dynamic") review.push({ ...item, code: "dynamic-catalog-review" }); else findings.push(item); } }; for (const file of sourceFiles(join(repositoryRoot, "webui/src"))) { if (file.includes("/i18n/")) continue; const sf = reader.source(file); function textChild(node, owner) { if (ts.isJsxText(node)) add(node.text, node, sf, `${owner}.children`); else if (ts.isJsxExpression(node)) { if (node.expression) add(reader.value(node.expression, sf), node, sf, `${owner}.children`); } else if (ts.isJsxElement(node) || ts.isJsxFragment(node)) for (const child of node.children) textChild(child, owner); } function visit(node) { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { const owner = componentName(ts, reader, sf, node.tagName); for (const property of node.attributes.properties) { if (!ts.isJsxAttribute(property) || !displayProps.get(owner)?.has(property.name.text) || !property.initializer) continue; const expression = ts.isJsxExpression(property.initializer) ? property.initializer.expression : property.initializer; add(reader.value(expression, sf), property, sf, `${owner}.${property.name.text}`); } } if (ts.isJsxElement(node)) { const owner = componentName(ts, reader, sf, node.openingElement.tagName); if (childOwners.has(owner)) for (const child of node.children) textChild(child, owner); } ts.forEachChild(node, visit); } visit(sf); } const hasCatalog = sourceFiles(join(repositoryRoot, "webui/src/i18n")).some((file) => /Translations\.ts$/.test(file)); if (hasCatalog && registration === "absent") findings.push({ file: moduleFile?.fileName ?? join(repositoryRoot, "webui/src/module.ts"), line: 1, code: "catalog-not-registered", message: "Module-owned catalog exists but no static module translations registration was found." }); return { repository: repositoryRoot, registration, labels, findings, review }; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2); let workspace = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const repos = []; for (let index = 0; index < args.length; index++) { if (args[index] === "--workspace-root") workspace = resolve(args[++index]); else if (args[index] === "--repo") repos.push(args[++index]); else throw new Error(`Unknown argument: ${args[index]}`); } const core = join(workspace, "govoplan-core"); const require = createRequire(join(core, "webui/package.json")); const ts = require("typescript"); const catalog = JSON.parse(readFileSync(join(workspace, "govoplan/repositories.json"), "utf8")); const selected = catalog.repositories.filter((repo) => !repos.length || repos.includes(repo.name)); if (repos.some((name) => !selected.some((repo) => repo.name === name))) throw new Error("Unknown repository selection"); for (const repo of selected) { if (typeof repo.path !== "string" || !resolve(workspace, repo.path).startsWith(`${workspace}/`)) throw new Error("Repository path escapes the workspace"); } const results = selected.filter((repo) => existsSync(join(workspace, repo.path, "webui/src"))).map((repo) => auditRepository(ts, join(workspace, repo.path), core)); const findings = results.reduce((total, item) => total + item.findings.length, 0); process.stdout.write(JSON.stringify({ schema_version: 1, results, finding_count: findings, limitations: ["Known display slots only; this is not a complete UI or linguistic review.", "Runtime data, computed labels and dynamic registrations require manual review.", "Core defaults plus each owning module are checked; optional sibling catalogs cannot mask missing registration.", "Explicit i18n markers are checked by the existing platform interface inventory."] }) + "\n"); process.exitCode = findings ? 1 : 0; }