chore: consolidate platform split checks
This commit is contained in:
347
webui/scripts/audit-i18n-structural.mjs
Normal file
347
webui/scripts/audit-i18n-structural.mjs
Normal 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.");
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user