Enforce declared platform interface inventory
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
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);
|
||||
@@ -60,9 +61,19 @@ const helpAttributes = new Set([
|
||||
"helperText",
|
||||
"helpText"
|
||||
]);
|
||||
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: {},
|
||||
@@ -71,7 +82,8 @@ const result = {
|
||||
routes: [],
|
||||
navigation: [],
|
||||
frontendApiReferences: [],
|
||||
uiCapabilities: []
|
||||
uiCapabilities: [],
|
||||
contributions: []
|
||||
};
|
||||
|
||||
for (const repository of repositoryCatalog.repositories) {
|
||||
@@ -115,6 +127,7 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
||||
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));
|
||||
@@ -178,6 +191,7 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
||||
const isField =
|
||||
fieldComponents.has(component) ||
|
||||
(fieldComponentPattern.test(component) && component !== "FormField");
|
||||
inspectAction(node, component, attributes, locate);
|
||||
if (!isField) return;
|
||||
|
||||
const parentFormField = nearestFormField(node);
|
||||
@@ -191,8 +205,25 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
||||
null;
|
||||
const help = firstAttribute(attributes, helpAttributes) ??
|
||||
firstAttribute(parentAttributes, helpAttributes);
|
||||
const hasHelp = hasAnyAttribute(attributes, helpAttributes) ||
|
||||
hasAnyAttribute(parentAttributes, helpAttributes);
|
||||
const explicitId = firstAttribute(
|
||||
attributes,
|
||||
new Set(["interfaceId", "data-interface-id", "id", "name", "field"])
|
||||
);
|
||||
const context = nearestNamedContext(node);
|
||||
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") ??
|
||||
@@ -202,8 +233,102 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
||||
label,
|
||||
placeholder: attributes.get("placeholder") ?? null,
|
||||
help: help ?? null,
|
||||
helpCandidate: help === null
|
||||
helpId: hasHelp ? `${stableId}.help` : null,
|
||||
helpDynamic: hasHelp && help === null,
|
||||
helpCandidate: !hasHelp
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
result.actions.push({
|
||||
...locate(node),
|
||||
id: sourceIdentity(
|
||||
"action",
|
||||
node,
|
||||
component,
|
||||
explicitId ?? label ?? "action"
|
||||
),
|
||||
explicitId,
|
||||
idSource: explicitId === null ? "source_anchor" : "explicit",
|
||||
context,
|
||||
component,
|
||||
label
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -275,22 +400,103 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
||||
|
||||
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 (
|
||||
ancestorPropertyName(node, "uiCapabilities") &&
|
||||
typeof propertyName === "string"
|
||||
) {
|
||||
result.uiCapabilities.push({ ...locate(node), name: propertyName });
|
||||
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;
|
||||
@@ -343,6 +549,23 @@ function firstAttribute(attributes, names) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAnyAttribute(attributes, names) {
|
||||
for (const name of names) {
|
||||
if (attributes.has(name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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) ||
|
||||
|
||||
Reference in New Issue
Block a user