chore: consolidate platform split checks
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 12:51:19 +02:00
parent 150b720f12
commit 635d25c74c
216 changed files with 23336 additions and 4077 deletions

View File

@@ -0,0 +1,347 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import ts from "typescript";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const webuiDir = path.resolve(scriptDir, "..");
const workspaceRoot = path.resolve(webuiDir, "..", "..");
const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan-"))
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
.filter((sourceRoot) => fs.existsSync(sourceRoot));
const generatedCatalogs = sourceRoots
.map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts"))
.filter((file) => fs.existsSync(file));
function scanStructuralSourcePositions(roots) {
const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts")));
const findings = [];
for (const file of files) {
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
function report(node, label) {
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
findings.push(`${file}:${line + 1}:${character + 1} ${label} contains an i18n marker`);
}
function visit(node) {
if (isStringLiteralLike(node) && node.text.includes("i18n:") && isStructuralStringPosition(node, file) && !isAllowedStructuralStringPosition(node, file)) {
report(node, "structural string");
}
if (ts.isJsxAttribute(node)) {
const name = nodeName(node.name);
if ((isStructuralName(name) || isStructuralJsxValue(node)) && hasI18nLiteral(node.initializer)) report(node, `JSX ${name}`);
}
if (ts.isPropertyAssignment(node)) {
const name = nodeName(node.name);
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`);
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
}
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
if (ts.isVariableDeclaration(node)) {
const name = nodeName(node.name);
if (isStructuralVariableName(name) && name !== "LEGACY_STORAGE_KEYS" && hasI18nLiteral(node.initializer)) report(node, `var ${name}`);
}
if (ts.isCallExpression(node) && isStructuralCall(node) && hasI18nLiteral(node.arguments[0])) {
report(node.arguments[0], `call ${node.expression.getText(source)}`);
}
if (ts.isCallExpression(node)) {
node.arguments.forEach((argument, index) => {
if (isStructuralCallArgument(node, index) && hasI18nLiteral(argument)) {
report(argument, `call ${node.expression.getText(source)} argument ${index + 1}`);
}
});
}
if (ts.isNewExpression(node) && node.expression.getText(source) === "CustomEvent" && hasI18nLiteral(node.arguments?.[0])) {
report(node.arguments[0], "new CustomEvent");
}
if (ts.isArrayLiteralExpression(node) && hasI18nLiteral(node) && isLookupArray(node)) {
report(node, "lookup array");
}
ts.forEachChild(node, visit);
}
visit(source);
}
return findings;
}
function scanGeneratedCatalogs(files) {
const findings = [];
for (const file of files) {
const translations = loadGeneratedTranslations(file);
for (const [language, dictionary] of Object.entries(translations)) {
for (const [key, value] of Object.entries(dictionary)) {
if (looksStructuralCatalogValue(String(value), key)) {
findings.push(`${file} ${language} ${key} has structural value ${JSON.stringify(value)}`);
}
}
}
}
return findings;
}
function rgFiles(root) {
try {
const output = execFileSync("rg", ["--files", root], { encoding: "utf8" }).trim();
return output ? output.split("\n") : [];
} catch {
return [];
}
}
function nodeName(name) {
if (!name) return "";
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
return name.getText();
}
function isStructuralName(name) {
if (!name) return false;
if (name === "aria-label" || name === "alt" || name === "placeholder" || name === "title") return false;
if (name.startsWith("data-")) return true;
return structuralNames.has(name) ||
name.endsWith("ClassName") ||
/(?:^|_)(?:id|ids|key|keys|slug|code|path|url|endpoint|route|scope|permission|capability|module|event|storage)(?:_|$)/i.test(name) ||
/(?:Id|IDs|Key|Keys|Slug|Code|Path|Url|URL|Endpoint|Route|Scope|Permission|Capability|Module|Event|Storage)$/.test(name);
}
function hasI18nLiteral(node) {
let found = false;
function visit(child) {
if (
(ts.isStringLiteral(child) || ts.isNoSubstitutionTemplateLiteral(child) || ts.isTemplateHead(child) || ts.isTemplateMiddle(child) || ts.isTemplateTail(child)) &&
child.text.includes("i18n:")
) {
found = true;
}
if (!found) ts.forEachChild(child, visit);
}
if (node) visit(node);
return found;
}
function isStringLiteralLike(node) {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
}
function isStructuralStringPosition(node, file) {
const parent = node.parent;
if (!parent) return false;
if (ts.isPropertyAssignment(parent) && parent.name === node) return true;
if (ts.isElementAccessExpression(parent) && parent.argumentExpression === node) return true;
if (ts.isLiteralTypeNode(parent)) return true;
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) return true;
if (ts.isImportTypeNode(parent)) return true;
if (ts.isCallExpression(parent) && isStructuralCall(parent) && parent.arguments[0] === node) return true;
if (ts.isNewExpression(parent) && parent.expression.getText() === "CustomEvent" && parent.arguments?.[0] === node) return true;
return false;
}
function isAllowedStructuralStringPosition(node, file) {
if (file.endsWith("/utils/fieldHelp.ts") && ts.isPropertyAssignment(node.parent) && node.parent.name === node) return true;
return hasAncestorVariable(node, "LEGACY_STORAGE_KEYS");
}
function isStructuralVariableName(name) {
return /(?:^|_)(?:CLASS|CLASSNAME|STYLE|ID|KEY|SLUG|CODE|PATH|URL|ENDPOINT|ROUTE|SCOPE|PERMISSION|CAPABILITY|EVENT|STORAGE|ALGORITHM)(?:_|$)/i.test(name) ||
/(?:ClassName|Style|Id|ID|Key|Slug|Code|Path|Url|URL|Endpoint|Route|Scope|Permission|Capability|Event|Storage|Algorithm)$/.test(name);
}
function isStructuralCall(node) {
const expression = node.expression.getText();
return /(?:^|\.)(?:getItem|setItem|removeItem|addEventListener|removeEventListener|dispatchEvent)$/.test(expression);
}
function isStructuralCallArgument(node, index) {
const expressionName = callExpressionName(node);
if (isStructuralCall(node) && index === 0) return true;
if ((expressionName === "metadataText" || expressionName === "metadataFlag" || expressionName === "getText" || expressionName === "getBool") && index === 1) return true;
if (expressionName === "createAttachmentBasePath" && index === 0) return true;
if (expressionName === "updateNested" && index === 1) return true;
if ((expressionName === "hasScope" || expressionName === "hasAnyScope") && index === 1) return true;
if ((expressionName === "usePlatformModuleInstalled" || expressionName === "usePlatformUiCapability") && index === 0) return true;
return false;
}
function callExpressionName(node) {
const expression = node.expression;
if (ts.isIdentifier(expression)) return expression.text;
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
return expression.getText();
}
function isLookupArray(node) {
let parent = node.parent;
while (parent && (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
parent = parent.parent;
}
return Boolean(
parent &&
ts.isPropertyAccessExpression(parent) &&
parent.expression === node &&
["includes", "indexOf"].includes(parent.name.text) &&
ts.isCallExpression(parent.parent)
);
}
function isAlgorithmNameProperty(node) {
if (nodeName(node.name) !== "name") return false;
const objectLiteral = node.parent;
if (!ts.isObjectLiteralExpression(objectLiteral)) return false;
if (objectLiteral.properties.some((property) => ts.isPropertyAssignment(property) && ["hash", "namedCurve"].includes(nodeName(property.name)))) return true;
let parent = objectLiteral.parent;
while (parent && (ts.isConditionalExpression(parent) || ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
parent = parent.parent;
}
if (ts.isVariableDeclaration(parent)) return /Algorithm$/i.test(nodeName(parent.name));
if (ts.isPropertyAssignment(parent)) return /Algorithm$/i.test(nodeName(parent.name));
return false;
}
function isListOptionValueProperty(node) {
if (nodeName(node.name) !== "value") return false;
const objectLiteral = node.parent;
if (!ts.isObjectLiteralExpression(objectLiteral)) return false;
return objectLiteral.properties.some((property) => ts.isPropertyAssignment(property) && nodeName(property.name) === "label");
}
function isStructuralJsxValue(node) {
if (nodeName(node.name) !== "value") return false;
const parent = node.parent;
if (!ts.isJsxOpeningElement(parent) && !ts.isJsxSelfClosingElement(parent)) return false;
return parent.tagName.getText() === "option";
}
function hasAncestorVariable(node, variableName) {
let current = node.parent;
while (current) {
if (ts.isVariableDeclaration(current) && nodeName(current.name) === variableName) return true;
current = current.parent;
}
return false;
}
const structuralNames = new Set([
"action",
"actionId",
"algorithm",
"allOf",
"anyOf",
"apiKey",
"authMethod",
"capability",
"capabilityId",
"class",
"className",
"code",
"columnType",
"data-testid",
"endpoint",
"event",
"eventName",
"eventType",
"field",
"for",
"form",
"hash",
"href",
"htmlFor",
"iconName",
"id",
"kind",
"key",
"method",
"moduleId",
"nameAttribute",
"namedCurve",
"ownerModule",
"owner_module",
"path",
"permission",
"permissions",
"rel",
"requiredScopes",
"role",
"route",
"scope",
"scopeId",
"scopeType",
"slug",
"status",
"storageKey",
"style",
"target",
"to",
"tone",
"type",
"url",
"variant"
]);
function loadGeneratedTranslations(file) {
const source = fs.readFileSync(file, "utf8");
const output = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText;
const sandbox = { exports: {}, module: { exports: {} }, require: () => ({}) };
vm.runInNewContext(output, sandbox, { filename: file });
return sandbox.exports.generatedTranslations ?? sandbox.module.exports.generatedTranslations ?? {};
}
const cssUnits = /^(?:-?\d+(?:\.\d+)?(?:px|r?em|vh|vw|vmin|vmax|%|s|ms)?|0)(?:\s+(?:-?\d+(?:\.\d+)?(?:px|r?em|vh|vw|vmin|vmax|%|s|ms)?|0)){0,3}$/i;
const cssFunction = /^(?:minmax|repeat|calc|var|clamp|rgba?|hsla?)\(/i;
const color = /^(?:#[0-9a-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\))$/i;
const cssIdentifier = /^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/;
const emailAddress = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const emailAddressList = /^[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+(?:\s*[;,]\s*[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+)*$/;
const urlLike = /^(?:https?|webcal):\/\//i;
const packageSpecifier = /^@[a-z0-9-]+\/[a-z0-9-]+$/i;
const shellCommand = /^(?:systemctl|pg_dump|pg_restore|npm|pnpm|uv|pip|python)\b/;
const wildcardDomain = /^\*\.[a-z0-9.-]+$/i;
const technicalDottedIdentifier = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9][a-z0-9-]*)+$/;
const environmentReference = /^(?:env|secret):[A-Z0-9_]+$/;
const codeEllipsis = /^[a-z][a-z0-9_-]*-\.\.\.$/;
const globOrPathChoice = /^[\w./*-]+(?:\s+or\s+[\w./*-]+)+$/i;
const multiLineStructuralList = /^(?:[A-Za-z0-9_.:/-]+[*?]?(?:\n|$))+$/;
const cryptographicIdentifier = /^(?:ECDSA|Ed25519|RSASSA-PKCS1-v1_5|RSA-PSS|AES-GCM|SHA-\d+)$/;
const classStateTokens = new Set(["active", "subtle", "success", "neutral", "danger", "disabled", "compact", "hidden", "block", "inline"]);
const allowedStructuralLookingValues = new Set(["active tenant", "campaign-local settings", "govoplan-files"]);
const allowedStructuralLookingKeys = /(?:active_tenant|python_package_placeholder|manifest_signature_key|remote_entry|remote_manifest)/;
function looksStructuralCatalogValue(value, key) {
const trimmed = value.trim();
if (!trimmed || allowedStructuralLookingValues.has(trimmed) || allowedStructuralLookingKeys.test(key)) return false;
if (emailAddress.test(trimmed) || emailAddressList.test(trimmed) || cryptographicIdentifier.test(trimmed)) return true;
if (environmentReference.test(trimmed) || codeEllipsis.test(trimmed)) return true;
if (globOrPathChoice.test(trimmed) && /[/*.]/.test(trimmed)) return true;
if (urlLike.test(trimmed) || packageSpecifier.test(trimmed) || shellCommand.test(trimmed) || wildcardDomain.test(trimmed)) return true;
if (technicalDottedIdentifier.test(trimmed)) return true;
if (trimmed.includes("\n") && multiLineStructuralList.test(trimmed)) return true;
if (cssUnits.test(trimmed) || cssFunction.test(trimmed) || color.test(trimmed)) return true;
if (/^[.#][A-Za-z_][\w-]*(?:\s*[.#][A-Za-z_][\w-]*)*$/.test(trimmed)) return true;
const tokens = trimmed.split(/\s+/);
if (!tokens.every((token) => cssIdentifier.test(token))) return false;
if (!tokens.some((token) => token.includes("-") || token.startsWith("is-") || token.startsWith("has-") || classStateTokens.has(token))) return false;
if (value !== trimmed) return true;
if (tokens.length > 1 && tokens.every((token) => token === token.toLowerCase()) && tokens.some((token) => token.includes("-") || token.startsWith("is-") || token.startsWith("has-"))) return true;
if (tokens.length === 1 && (/^(?:is-|has-|with-)/.test(trimmed) || (trimmed.includes("-") && trimmed === trimmed.toLowerCase()))) return true;
return false;
}
const sourceFindings = scanStructuralSourcePositions(sourceRoots);
const catalogFindings = scanGeneratedCatalogs(generatedCatalogs);
const findings = [...sourceFindings, ...catalogFindings];
if (findings.length) {
console.error(findings.join("\n"));
process.exit(1);
}
console.log("No structural i18n markers found in source positions or generated catalogs.");

View File

@@ -4,8 +4,11 @@ const packageByModule = {
access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui",
campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui",
docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui",
mail: "@govoplan/mail-webui"
mail: "@govoplan/mail-webui",
ops: "@govoplan/ops-webui"
};
const cases = [
@@ -13,12 +16,14 @@ const cases = [
{ name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] },
{ name: "access-with-admin", modules: ["access", "admin"] },
{ name: "dashboard-only", modules: ["dashboard"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
{ name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "full-product", modules: ["access", "admin", "campaigns", "files", "mail"] }
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "dashboard", "campaigns", "files", "mail", "docs", "ops"] }
];
const npmExec = process.env.npm_execpath;
@@ -27,13 +32,20 @@ const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
for (const testCase of cases) {
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
const env = {
...process.env,
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
};
delete env.npm_config_tmp;
delete env.NPM_CONFIG_TMP;
if (process.env.GOVOPLAN_NPM_USERCONFIG) {
env.NPM_CONFIG_USERCONFIG = process.env.GOVOPLAN_NPM_USERCONFIG;
delete env.npm_config_userconfig;
}
console.log(`\n== WebUI module permutation: ${testCase.name} ==`);
const result = spawnSync(command, baseArgs, {
cwd: new URL("..", import.meta.url),
env: {
...process.env,
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
},
env,
stdio: "inherit"
});
if (result.status !== 0) {