#!/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("") ); }