Files
govoplan/tools/devkit/audit-display-labels.mjs
zemion 2ffdb23f69
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m30s
feat(devkit): add resumable workspace automation and UI review tooling
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.
2026-09-09 02:03:17 +02:00

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;
}