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.
This commit is contained in:
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/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;
|
||||
}
|
||||
Reference in New Issue
Block a user