Files
govoplan/tools/inventory/extract-webui-structure.mjs
T
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

758 lines
24 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
const [metaRootArgument, ...argumentsRest] = process.argv.slice(2);
if (!metaRootArgument) {
throw new Error("Usage: extract-webui-structure.mjs META_ROOT [--workspace-root ROOT]");
}
let explicitWorkspaceRoot;
for (let index = 0; index < argumentsRest.length; index++) {
if (argumentsRest[index] !== "--workspace-root" || explicitWorkspaceRoot !== undefined || !argumentsRest[index + 1]) {
throw new Error("Expected one --workspace-root ROOT option");
}
explicitWorkspaceRoot = path.resolve(argumentsRest[++index]);
}
const metaRoot = path.resolve(metaRootArgument);
const repositoryCatalog = JSON.parse(
fs.readFileSync(path.join(metaRoot, "repositories.json"), "utf8")
);
const siblingWorkspaceRoot = path.dirname(metaRoot);
// Discovery is retained only for old direct callers. An explicit root wins
// even when another configured checkout contains more optional modules.
const workspaceRoot = explicitWorkspaceRoot ?? (fs.existsSync(
path.join(siblingWorkspaceRoot, "govoplan-core", "webui")
) ? siblingWorkspaceRoot : path.resolve(repositoryCatalog.default_parent));
if (!fs.statSync(workspaceRoot).isDirectory()) throw new Error("Inventory workspace root must be an existing directory");
const actualWorkspaceRoot = fs.realpathSync(workspaceRoot);
function withinWorkspace(candidate) {
const relative = path.relative(actualWorkspaceRoot, fs.realpathSync(candidate));
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
if (!Array.isArray(repositoryCatalog.repositories)) throw new Error("Repository catalog requires a repositories array");
const typescriptPath = path.join(
workspaceRoot,
"govoplan-core",
"webui",
"node_modules",
"typescript",
"lib",
"typescript.js"
);
const ts = await import(pathToFileURL(typescriptPath).href);
const fieldComponents = new Set([
"input",
"select",
"textarea",
"Checkbox",
"DateTimeField",
"EmailAddressInput",
"ReferenceSelect",
"SearchableSelect",
"ToggleSwitch"
]);
const fieldComponentPattern =
/(?:Input|Field|Select|Picker|Toggle|Checkbox|Radio|Editor)$/;
const labelAttributes = new Set([
"aria-label",
"description",
"help",
"helperText",
"helpText",
"label",
"placeholder",
"title"
]);
const helpAttributes = new Set([
"data-help-context-id",
"description",
"help",
"helpContextId",
"helperText",
"helpText"
]);
const exactHelpAttributes = new Set([
"data-help-context-id",
"helpContextId"
]);
const helpRiskAttributes = new Set([
"data-help-risk",
"helpRisk"
]);
const reviewedHelpRiskAttributes = new Set([
"data-help-risk-reviewed",
"helpRiskReviewed"
]);
const supportedHelpRisks = new Set([
"authority",
"credential",
"disclosure",
"encryption",
"external-effect",
"irreversible",
"policy",
"retention"
]);
const actionComponentPattern = /(?:Action|Button|Link)$/;
const contributionTypes = new Map([
["AdminSectionsUiCapability", "admin_section"],
["DashboardWidgetsUiCapability", "widget"],
["OrganizationFunctionActionsUiCapability", "action"],
["SearchContextsUiCapability", "search_object"],
["SettingsSectionsUiCapability", "setting"],
["WizardDirectoriesUiCapability", "workflow_directory"]
]);
const result = {
workspaceRoot: actualWorkspaceRoot,
fields: [],
actions: [],
labels: [],
visibleText: [],
translationCatalog: {},
translationUsages: [],
dynamicTranslationUsages: [],
routes: [],
navigation: [],
frontendApiReferences: [],
uiCapabilities: [],
contributions: []
};
for (const repository of repositoryCatalog.repositories) {
if (!repository || typeof repository.path !== "string" || !repository.path || path.isAbsolute(repository.path) || repository.path.split(/[\\/]/).includes("..")) {
throw new Error("Inventory repository paths must remain inside the selected workspace");
}
const sourceRoot = path.join(actualWorkspaceRoot, repository.path, "webui", "src");
if (!fs.existsSync(sourceRoot)) continue;
if (!withinWorkspace(sourceRoot)) throw new Error("Inventory source root escapes the selected workspace");
for (const sourcePath of sourceFiles(sourceRoot)) {
inspectSource(repository.name, sourceRoot, sourcePath);
}
}
process.stdout.write(`${JSON.stringify(result)}\n`);
function sourceFiles(root) {
const files = [];
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name.startsWith(".") ||
entry.name === "dist"
) {
continue;
}
const candidate = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(candidate);
else if (entry.isFile() && /\.(?:ts|tsx)$/.test(entry.name)) files.push(candidate);
}
}
return files.sort();
}
function inspectSource(repository, sourceRoot, sourcePath) {
const sourceText = fs.readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(
sourcePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourcePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
);
const relativeFile = path.relative(path.resolve(sourceRoot, "..", ".."), sourcePath);
const identityCounters = new Map();
function location(node) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
return {
repository,
file: relativeFile,
line: position.line + 1,
column: position.character + 1
};
}
function visit(node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
inspectJsxOpening(node, location);
}
if (ts.isJsxText(node)) {
const value = node.getText(sourceFile).replace(/\s+/g, " ").trim();
if (value) {
result.visibleText.push({
...location(node),
value,
translationKey: value.startsWith("i18n:") ? value : null
});
}
}
if (ts.isStringLiteralLike(node)) {
inspectString(node.text, node, location);
} else if (ts.isTemplateExpression(node)) {
inspectString(templateText(node), node, location);
}
if (ts.isPropertyAssignment(node)) {
inspectProperty(node, location);
inspectTranslationProperty(node, location);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
function inspectJsxOpening(node, locate) {
const component = node.tagName.getText(sourceFile);
const attributes = new Map();
for (const attribute of node.attributes.properties) {
if (!ts.isJsxAttribute(attribute)) continue;
attributes.set(
attribute.name.getText(sourceFile),
jsxAttributeValue(attribute)
);
}
for (const [attribute, value] of attributes) {
if (!labelAttributes.has(attribute) || value === null) continue;
result.labels.push({
...locate(node),
component,
attribute,
value,
translationKey: value.startsWith("i18n:") ? value : null
});
}
const isField =
fieldComponents.has(component) ||
(fieldComponentPattern.test(component) && component !== "FormField");
inspectAction(node, component, attributes, locate);
if (!isField) return;
const parentFormField = nearestFormField(node);
const parentAttributes = parentFormField
? jsxAttributes(parentFormField)
: new Map();
const scopedAncestorAttributes = nearestScopedHelpAttributes(node);
const label =
attributes.get("label") ??
attributes.get("aria-label") ??
parentAttributes.get("label") ??
null;
const help = firstAttribute(attributes, helpAttributes) ??
firstAttribute(parentAttributes, helpAttributes) ??
firstAttribute(scopedAncestorAttributes, helpAttributes);
const hasHelp = hasAnyAttribute(attributes, helpAttributes) ||
hasAnyAttribute(parentAttributes, helpAttributes) ||
hasAnyAttribute(scopedAncestorAttributes, helpAttributes);
const hasExactHelp = hasAnyAttribute(attributes, exactHelpAttributes) ||
hasAnyAttribute(parentAttributes, exactHelpAttributes) ||
hasAnyAttribute(scopedAncestorAttributes, exactHelpAttributes);
const helpContextId = firstAttribute(attributes, exactHelpAttributes) ??
firstAttribute(parentAttributes, exactHelpAttributes) ??
firstAttribute(scopedAncestorAttributes, exactHelpAttributes);
const explicitId = firstAttribute(
attributes,
new Set(["interfaceId", "data-interface-id", "id", "name", "field"])
);
const context = nearestNamedContext(node);
const risk = helpRiskFor({
component,
context,
file: relativeFile,
label,
explicitId,
name: attributes.get("name") ?? attributes.get("id") ?? attributes.get("field") ?? null,
explicitRisk: firstAttribute(attributes, helpRiskAttributes) ??
firstAttribute(parentAttributes, helpRiskAttributes) ??
firstAttribute(scopedAncestorAttributes, helpRiskAttributes)
});
const riskReviewed = firstAttribute(attributes, reviewedHelpRiskAttributes) ??
firstAttribute(parentAttributes, reviewedHelpRiskAttributes) ??
firstAttribute(scopedAncestorAttributes, reviewedHelpRiskAttributes);
const stableId = sourceIdentity(
"field",
node,
component,
explicitId ?? label ?? attributes.get("placeholder") ?? "field"
);
result.fields.push({
...locate(node),
id: stableId,
explicitId,
idSource: explicitId === null ? "source_anchor" : "explicit",
context,
component,
name:
attributes.get("name") ??
attributes.get("id") ??
attributes.get("field") ??
null,
label,
placeholder: attributes.get("placeholder") ?? null,
help: help ?? null,
helpId: hasHelp ? `${stableId}.help` : null,
helpDynamic: hasHelp && help === null,
helpCandidate: !hasHelp,
helpExact: hasExactHelp,
helpContextId,
helpContextDynamic: hasExactHelp && helpContextId === null,
helpRisk: risk.value,
helpRiskSource: risk.source,
helpRiskReviewed: riskReviewed,
highRiskHelpMissing: risk.value !== null && !hasExactHelp && riskReviewed !== "standard"
});
}
function inspectAction(node, component, attributes, locate) {
const lowerComponent = component.toLowerCase();
const inputType = attributes.get("type")?.toLowerCase();
const isAction = lowerComponent === "button" ||
lowerComponent === "a" ||
actionComponentPattern.test(component) ||
(lowerComponent === "input" && ["button", "reset", "submit"].includes(inputType));
if (!isAction) return;
const label = attributes.get("aria-label") ??
attributes.get("title") ??
attributes.get("label") ??
staticJsxChildText(node) ??
null;
const explicitId = firstAttribute(
attributes,
new Set(["interfaceId", "data-interface-id", "id", "name"])
);
const context = nearestNamedContext(node);
const hasHelp = hasAnyAttribute(attributes, helpAttributes);
const hasExactHelp = hasAnyAttribute(attributes, exactHelpAttributes);
const helpContextId = firstAttribute(attributes, exactHelpAttributes);
const risk = helpRiskFor({
component,
context,
file: relativeFile,
label,
explicitId,
name: attributes.get("name") ?? attributes.get("id") ?? null,
explicitRisk: firstAttribute(attributes, helpRiskAttributes)
});
const riskReviewed = firstAttribute(attributes, reviewedHelpRiskAttributes);
result.actions.push({
...locate(node),
id: sourceIdentity(
"action",
node,
component,
explicitId ?? label ?? "action"
),
explicitId,
idSource: explicitId === null ? "source_anchor" : "explicit",
context,
component,
label,
helpExact: hasExactHelp,
helpContextId,
helpContextDynamic: hasExactHelp && helpContextId === null,
helpDynamic: hasHelp && firstAttribute(attributes, helpAttributes) === null,
helpRisk: risk.value,
helpRiskSource: risk.source,
helpRiskReviewed: riskReviewed,
highRiskHelpMissing: risk.value !== null && !hasExactHelp && riskReviewed !== "standard"
});
}
function nearestNamedContext(node) {
let current = node.parent;
while (current) {
if (ts.isFunctionDeclaration(current) && current.name) {
return current.name.text;
}
if (ts.isMethodDeclaration(current) && current.name) {
return current.name.getText(sourceFile);
}
if (
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
ts.isVariableDeclaration(current.parent) &&
ts.isIdentifier(current.parent.name)
) {
return current.parent.name.text;
}
if (
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
ts.isPropertyAssignment(current.parent)
) {
return propertyNameText(current.parent.name) ?? "anonymous";
}
current = current.parent;
}
return path.basename(relativeFile).replace(/\.[^.]+$/, "");
}
function sourceIdentity(kind, node, component, semantic) {
const context = nearestNamedContext(node);
const normalizedSemantic = slug(String(semantic));
const counterKey = `${kind}:${context}:${component}:${normalizedSemantic}`;
const occurrence = (identityCounters.get(counterKey) ?? 0) + 1;
identityCounters.set(counterKey, occurrence);
const anchor = [relativeFile, context, component, normalizedSemantic, occurrence].join(":");
const digest = createHash("sha256").update(anchor).digest("hex").slice(0, 12);
return `${repository}.${kind}.${slug(context)}.${normalizedSemantic}.${digest}`;
}
function staticJsxChildText(node) {
if (!ts.isJsxOpeningElement(node) || !ts.isJsxElement(node.parent)) return null;
const values = [];
for (const child of node.parent.children) {
if (ts.isJsxText(child)) {
const value = child.getText(sourceFile).replace(/\s+/g, " ").trim();
if (value) values.push(value);
} else if (
ts.isJsxExpression(child) &&
child.expression &&
ts.isStringLiteralLike(child.expression)
) {
values.push(child.expression.text);
}
}
return values.length > 0 ? values.join(" ") : null;
}
function nearestFormField(node) {
let current = node.parent;
while (current) {
if (
ts.isJsxElement(current) &&
current.openingElement.tagName.getText(sourceFile) === "FormField"
) {
return current.openingElement;
}
if (
ts.isJsxOpeningElement(current) ||
ts.isJsxSelfClosingElement(current)
) {
const name = current.tagName.getText(sourceFile);
if (name !== "FormField") return null;
}
current = current.parent;
}
return null;
}
function nearestScopedHelpAttributes(node) {
let current = node.parent;
while (current) {
if (ts.isJsxElement(current)) {
const attributes = jsxAttributes(current.openingElement);
if (attributes.get("data-help-scope") === "field") return attributes;
}
if (
ts.isFunctionDeclaration(current) ||
ts.isMethodDeclaration(current) ||
ts.isArrowFunction(current) ||
ts.isFunctionExpression(current)
) {
return new Map();
}
current = current.parent;
}
return new Map();
}
function jsxAttributes(node) {
const mapped = new Map();
for (const attribute of node.attributes.properties) {
if (!ts.isJsxAttribute(attribute)) continue;
mapped.set(
attribute.name.getText(sourceFile),
jsxAttributeValue(attribute)
);
}
return mapped;
}
function jsxAttributeValue(attribute) {
if (!attribute.initializer) return "true";
if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
if (!ts.isJsxExpression(attribute.initializer)) return null;
const expression = attribute.initializer.expression;
if (!expression) return null;
if (ts.isStringLiteralLike(expression)) return expression.text;
if (ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
if (ts.isTemplateExpression(expression)) return templateText(expression);
return null;
}
function inspectString(value, node, locate) {
if (value.startsWith("i18n:") && value.length > "i18n:".length) {
const target = value.includes("${}") || isStringPrefixCheck(node)
? result.dynamicTranslationUsages
: result.translationUsages;
target.push({ ...locate(node), key: value });
}
if (value.includes("/api/")) {
result.frontendApiReferences.push({ ...locate(node), path: value });
}
}
function isStringPrefixCheck(node) {
const call = node.parent;
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
return false;
}
return ["endsWith", "includes", "startsWith"].includes(
call.expression.name.text
);
}
function inspectProperty(node, locate) {
const propertyName = propertyNameText(node.name);
if (isDirectUiCapabilityProperty(node) && typeof propertyName === "string") {
result.uiCapabilities.push({ ...locate(node), name: propertyName });
result.contributions.push({
...locate(node),
kind: "ui_capability",
id: propertyName
});
}
const value = staticExpressionText(node.initializer);
if (value === null) return;
if (propertyName === "path" && value.startsWith("/") && !value.includes("/api/")) {
result.routes.push({ ...locate(node), path: value });
const routeCollection = nearestCollectionProperty(node);
if (routeCollection === "routes" || routeCollection === "publicRoutes") {
result.contributions.push({
...locate(node),
kind: routeCollection === "publicRoutes" ? "public_route" : "frontend_route",
id: value,
path: value
});
}
}
if (propertyName === "to" && value.startsWith("/")) {
result.navigation.push({ ...locate(node), path: value });
if (nearestCollectionProperty(node) === "navItems") {
result.contributions.push({
...locate(node),
kind: "navigation",
id: value,
path: value
});
}
}
if (propertyName === "id") {
const contributionKind = contributionKindFor(node);
if (contributionKind !== null) {
result.contributions.push({
...locate(node),
kind: contributionKind,
id: value
});
}
}
}
function contributionKindFor(node) {
const collection = nearestCollectionProperty(node);
if (collection === "viewSurfaces") return "view_surface";
if (collection === "widgets") return "widget";
if (collection === "contexts") return "search_object";
if (collection === "actions") return "action";
if (collection === "directories") return "workflow_directory";
if (collection !== "sections") return null;
const variableType = nearestVariableType(node);
for (const [typeName, kind] of contributionTypes) {
if (variableType.includes(typeName)) return kind;
}
return ancestorPropertyName(node, "admin.sections")
? "admin_section"
: ancestorPropertyName(node, "settings.sections")
? "setting"
: "section";
}
function nearestCollectionProperty(node) {
let current = node.parent;
while (current) {
if (
ts.isArrayLiteralExpression(current) &&
ts.isPropertyAssignment(current.parent)
) {
return propertyNameText(current.parent.name);
}
current = current.parent;
}
return null;
}
function nearestVariableType(node) {
let current = node.parent;
while (current) {
if (ts.isVariableDeclaration(current)) {
return current.type?.getText(sourceFile) ?? "";
}
current = current.parent;
}
return "";
}
function isDirectUiCapabilityProperty(node) {
const parent = node.parent;
const uiCapabilities = parent?.parent;
return ts.isObjectLiteralExpression(parent) &&
ts.isPropertyAssignment(uiCapabilities) &&
propertyNameText(uiCapabilities.name) === "uiCapabilities";
}
function inspectTranslationProperty(node, locate) {
const key = propertyNameText(node.name);
if (!key?.startsWith("i18n:")) return;
const value = staticExpressionText(node.initializer);
if (value === null) return;
const locale = nearestLocaleProperty(node);
if (!locale) return;
result.translationCatalog[locale] ??= {};
result.translationCatalog[locale][key] = {
value,
...locate(node)
};
}
function nearestLocaleProperty(node) {
let current = node.parent;
while (current) {
if (
ts.isPropertyAssignment(current) &&
(propertyNameText(current.name) === "en" ||
propertyNameText(current.name) === "de")
) {
return propertyNameText(current.name);
}
if (
ts.isVariableDeclaration(current) &&
ts.isIdentifier(current.name) &&
(current.name.text === "en" || current.name.text === "de") &&
current.initializer &&
isCatalogObject(current.initializer)
) {
return current.name.text;
}
current = current.parent;
}
return null;
}
function isCatalogObject(node) {
let current = node;
while (
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isParenthesizedExpression(current)
) {
current = current.expression;
}
return ts.isObjectLiteralExpression(current);
}
function ancestorPropertyName(node, expected) {
let current = node.parent;
while (current) {
if (
ts.isPropertyAssignment(current) &&
propertyNameText(current.name) === expected
) {
return true;
}
current = current.parent;
}
return false;
}
}
function firstAttribute(attributes, names) {
for (const name of names) {
const value = attributes.get(name);
if (value !== undefined && value !== null) return value;
}
return null;
}
function hasAnyAttribute(attributes, names) {
for (const name of names) {
if (attributes.has(name)) return true;
}
return false;
}
function helpRiskFor({ component, context, file, label, explicitId, name, explicitRisk }) {
if (typeof explicitRisk === "string") {
return supportedHelpRisks.has(explicitRisk)
? { value: explicitRisk, source: "explicit" }
: { value: null, source: "invalid_explicit" };
}
const value = [component, context, file, label, explicitId, name]
.filter((item) => typeof item === "string")
.join(" ")
.toLowerCase()
.replace(/^i18n:/g, "")
.replace(/[._-]+/g, " ");
const patterns = [
["irreversible", /\b(delete|destroy|erase|purge|dispose|disposition|revoke|withdraw|shred)\b/],
["credential", /\b(credential|password|secret|token|api key|private key)\b/],
["retention", /\b(retention|legal hold|archive lifecycle)\b/],
["encryption", /\b(encrypt|encryption|decrypt|decryption|signing key|signature key)\b/],
["disclosure", /\b(disclose|disclosure|publish|share externally|public export)\b/],
["external-effect", /\b(send|deliver|transfer|refund|payment execution|webhook execution)\b/],
["authority", /\b(grant permission|role assignment|approve|reject|formal decision|mandate)\b/],
["policy", /\b(policy apply|policy override|enforcement mode)\b/]
];
for (const [risk, pattern] of patterns) {
if (pattern.test(value)) return { value: risk, source: "inferred" };
}
return { value: null, source: null };
}
function slug(value) {
const normalized = value
.toLowerCase()
.replace(/^i18n:/, "")
.replace(/\.[a-f0-9]{8}$/, "")
.replace(/[^a-z0-9]+/g, ".")
.replace(/^\.+|\.+$/g, "");
return (normalized || "unnamed").slice(0, 72);
}
function propertyNameText(name) {
if (
ts.isIdentifier(name) ||
ts.isStringLiteral(name) ||
ts.isNumericLiteral(name)
) {
return name.text;
}
return null;
}
function staticExpressionText(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
if (ts.isTemplateExpression(node)) return templateText(node);
return null;
}
function templateText(node) {
return (
node.head.text +
node.templateSpans
.map((span) => "${}" + span.literal.text)
.join("")
);
}