Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
213 lines
12 KiB
JavaScript
Executable File
213 lines
12 KiB
JavaScript
Executable File
#!/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;
|
|
}
|