740 lines
23 KiB
JavaScript
740 lines
23 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] = process.argv.slice(2);
|
|
if (!metaRootArgument) {
|
|
throw new Error("Usage: extract-webui-structure.mjs META_ROOT");
|
|
}
|
|
|
|
const metaRoot = path.resolve(metaRootArgument);
|
|
const repositoryCatalog = JSON.parse(
|
|
fs.readFileSync(path.join(metaRoot, "repositories.json"), "utf8")
|
|
);
|
|
const siblingWorkspaceRoot = path.dirname(metaRoot);
|
|
const configuredWorkspaceRoot = path.resolve(repositoryCatalog.default_parent);
|
|
const workspaceRoot = fs.existsSync(
|
|
path.join(siblingWorkspaceRoot, "govoplan-core", "webui")
|
|
)
|
|
? siblingWorkspaceRoot
|
|
: configuredWorkspaceRoot;
|
|
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 = {
|
|
fields: [],
|
|
actions: [],
|
|
labels: [],
|
|
visibleText: [],
|
|
translationCatalog: {},
|
|
translationUsages: [],
|
|
dynamicTranslationUsages: [],
|
|
routes: [],
|
|
navigation: [],
|
|
frontendApiReferences: [],
|
|
uiCapabilities: [],
|
|
contributions: []
|
|
};
|
|
|
|
for (const repository of repositoryCatalog.repositories) {
|
|
const sourceRoot = path.join(workspaceRoot, repository.path, "webui", "src");
|
|
if (!fs.existsSync(sourceRoot)) continue;
|
|
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 (/\.(?: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.join(workspaceRoot, repository), 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("")
|
|
);
|
|
}
|