Files
tutti/tools/scripts/check-ui-metadata.mjs
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

538 lines
16 KiB
JavaScript

import { existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const allowedCategories = new Set([
"primitive",
"composition",
"icon",
"style-entry",
"utility"
]);
const allowedStatuses = new Set(["experimental", "stable", "deprecated"]);
const allowedLayers = new Set(["base", "business"]);
const allowedIconVariants = new Set(["lined", "filled"]);
const allowedTopLevelKeys = new Set(["$schema", "schemaVersion", "components"]);
const allowedComponentKeys = new Set([
"id",
"layer",
"name",
"export",
"from",
"category",
"status",
"source",
"iconVariant",
"propsType",
"description",
"useCases",
"migrationHints",
"nativePreview",
"storyboard"
]);
const publicEntrypoints = new Map([
["@tutti-os/ui-system", "src/index.ts"],
["@tutti-os/ui-system/components", "src/components/index.ts"],
["@tutti-os/ui-system/icons", "src/icons/index.ts"],
["@tutti-os/ui-system/metadata", "src/metadata/index.ts"],
["@tutti-os/ui-system/native", "src/native/index.ts"],
["@tutti-os/ui-system/native.css", "src/styles/native.css"],
["@tutti-os/ui-system/styles.css", "src/styles/index.css"],
["@tutti-os/ui-system/utils", "src/lib/utils.ts"]
]);
const metadataCoveredEntrypoints = new Set([
"@tutti-os/ui-system/components",
"@tutti-os/ui-system/icons"
]);
const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(scriptPath), "../..");
const packageRoot = path.join(repoRoot, "packages/ui/system");
const metadataPath = path.join(packageRoot, "src/metadata/components.json");
const packageJsonPath = path.join(packageRoot, "package.json");
const nativeGalleryPath = path.join(
repoRoot,
"apps/mobile/src/dev/NativeComponentGallery.tsx"
);
const nativeGallerySource = existsSync(nativeGalleryPath)
? readFileSync(nativeGalleryPath, "utf8")
: "";
const violations = [];
function addViolation(message) {
violations.push(message);
}
function formatComponent(component, index) {
if (component && typeof component.name === "string") {
return `${component.name} at metadata.components[${index}]`;
}
return `metadata.components[${index}]`;
}
function readPackageFile(relativePath) {
return readFileSync(path.join(packageRoot, relativePath), "utf8");
}
function moduleSpecifierForSource(barrelPath, source) {
const parsed = path.parse(source);
const barrelDir = path.dirname(barrelPath);
const sourcePathWithoutExtension =
parsed.name === "index"
? path.relative(barrelDir, parsed.dir)
: path.join(path.relative(barrelDir, parsed.dir), parsed.name);
return `./${sourcePathWithoutExtension}`;
}
function exportedValueNames(sourceText) {
const names = new Set();
const directExportPattern =
/export\s+(?:declare\s+)?(?:const|let|var|function|class|enum)\s+([A-Za-z_$][\w$]*)/g;
const namedExportPattern = /export\s*\{([^}]+)\}/g;
for (const match of sourceText.matchAll(directExportPattern)) {
names.add(match[1]);
}
for (const match of sourceText.matchAll(namedExportPattern)) {
for (const rawName of match[1].split(",")) {
const name = exportedNameFromSpecifier(rawName);
if (name) {
names.add(name);
}
}
}
return names;
}
function exportedNameFromSpecifier(rawName) {
const trimmed = rawName.trim();
if (trimmed.length === 0 || trimmed.startsWith("type ")) {
return null;
}
const aliasMatch = trimmed.match(
/^[A-Za-z_$][\w$]*\s+as\s+([A-Za-z_$][\w$]*)$/u
);
if (aliasMatch) {
return aliasMatch[1];
}
return trimmed;
}
function barrelExportsSource(barrelPath, barrelText, source) {
const specifier = moduleSpecifierForSource(barrelPath, source);
const normalizedSpecifier = specifier.replaceAll(path.sep, "/");
const escapedSpecifier = normalizedSpecifier.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
);
const exportAllPattern = new RegExp(
`export\\s+\\*\\s+from\\s+["']${escapedSpecifier}["']`
);
const namedFromPattern = new RegExp(
`export\\s*\\{[^}]+\\}\\s*from\\s+["']${escapedSpecifier}["']`
);
return exportAllPattern.test(barrelText) || namedFromPattern.test(barrelText);
}
function publicValueExports(barrelPath) {
const barrelText = readPackageFile(barrelPath);
const names = exportedValueNames(barrelText);
const exportAllPattern = /export\s+\*\s+from\s+["']([^"']+)["']/g;
for (const match of barrelText.matchAll(exportAllPattern)) {
const sourcePath = resolveBarrelSpecifier(barrelPath, match[1]);
if (!sourcePath) {
addViolation(
`${barrelPath} re-exports "${match[1]}", but that source could not be resolved`
);
continue;
}
for (const name of exportedValueNames(readPackageFile(sourcePath))) {
names.add(name);
}
}
return names;
}
function resolveBarrelSpecifier(barrelPath, specifier) {
const basePath = path.normalize(
path.join(path.dirname(barrelPath), specifier)
);
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
`${basePath}.mts`,
`${basePath}.mjs`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx")
];
for (const candidate of candidates) {
const absoluteCandidate = path.join(packageRoot, candidate);
const relativeCandidate = path.relative(packageRoot, absoluteCandidate);
if (
relativeCandidate.startsWith("..") ||
path.isAbsolute(relativeCandidate)
) {
continue;
}
if (existsSync(absoluteCandidate) && statSync(absoluteCandidate).isFile()) {
return candidate;
}
}
return null;
}
function validatePublicExportCoverage(metadataExports) {
for (const entrypoint of metadataCoveredEntrypoints) {
const barrelPath = publicEntrypoints.get(entrypoint);
if (!barrelPath || !existsSync(path.join(packageRoot, barrelPath))) {
addViolation(
`${entrypoint} does not point to an existing public barrel for metadata coverage`
);
continue;
}
for (const exportName of publicValueExports(barrelPath)) {
if (metadataExports.has(`${entrypoint}:${exportName}`)) {
continue;
}
addViolation(
`${entrypoint} export "${exportName}" is missing UI-system metadata`
);
}
}
}
let packageJson;
let metadata;
try {
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
} catch (error) {
addViolation(
`Unable to read or parse packages/ui/system/package.json: ${error.message}`
);
}
try {
metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
} catch (error) {
addViolation(
`Unable to read or parse packages/ui/system/src/metadata/components.json: ${error.message}`
);
}
if (metadata !== undefined) {
if (
typeof metadata !== "object" ||
Array.isArray(metadata) ||
metadata === null
) {
addViolation("metadata must be an object");
} else {
for (const key of Object.keys(metadata)) {
if (!allowedTopLevelKeys.has(key)) {
addViolation(`metadata has unexpected top-level key "${key}"`);
}
}
if (
!Number.isInteger(metadata.schemaVersion) ||
metadata.schemaVersion < 1
) {
addViolation("metadata.schemaVersion must be a positive integer");
}
}
if (!Array.isArray(metadata?.components)) {
addViolation("metadata.components must be an array");
} else {
const names = new Map();
const ids = new Map();
const exports = new Map();
const metadataExports = new Set();
metadata.components.forEach((component, index) => {
const label = formatComponent(component, index);
if (
!component ||
typeof component !== "object" ||
Array.isArray(component)
) {
addViolation(`${label} must be an object`);
return;
}
for (const key of Object.keys(component)) {
if (!allowedComponentKeys.has(key)) {
addViolation(`${label} has unexpected key "${key}"`);
}
}
if (typeof component.id !== "string" || component.id.length === 0) {
addViolation(`${label} must have a non-empty string id`);
} else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(component.id)) {
addViolation(
`${label} id "${component.id}" must use readable kebab-case`
);
} else if (ids.has(component.id)) {
addViolation(
`${label} duplicates id "${component.id}" from metadata.components[${ids.get(
component.id
)}]`
);
} else {
ids.set(component.id, index);
}
if (!allowedLayers.has(component.layer)) {
addViolation(
`${label} has invalid layer "${component.layer}"; allowed layers are ${[
...allowedLayers
].join(", ")}`
);
}
if (typeof component.name !== "string" || component.name.length === 0) {
addViolation(`${label} must have a non-empty string name`);
} else if (names.has(component.name)) {
addViolation(
`${label} duplicates name "${component.name}" from metadata.components[${names.get(
component.name
)}]`
);
} else {
names.set(component.name, index);
}
if (
typeof component.export !== "string" ||
component.export.length === 0
) {
addViolation(`${label} must have a non-empty string export`);
} else if (exports.has(component.export)) {
addViolation(
`${label} duplicates export "${component.export}" from metadata.components[${exports.get(
component.export
)}]`
);
} else {
exports.set(component.export, index);
}
if (
typeof component.from === "string" &&
typeof component.export === "string" &&
component.from.length > 0 &&
component.export.length > 0
) {
metadataExports.add(`${component.from}:${component.export}`);
}
if (
typeof component.name === "string" &&
typeof component.export === "string" &&
component.name !== component.export
) {
addViolation(
`${label} name must match export "${component.export}", got "${component.name}"`
);
}
if (!publicEntrypoints.has(component.from)) {
addViolation(
`${label} has invalid from "${component.from}"; allowed entrypoints are ${[
...publicEntrypoints.keys()
].join(", ")}`
);
} else if (packageJson?.exports) {
const packageExport =
component.from === "@tutti-os/ui-system"
? "."
: `.${component.from.replace("@tutti-os/ui-system", "")}`;
if (!packageJson.exports[packageExport]) {
addViolation(
`${label} from "${component.from}" is not exposed in package.json exports`
);
}
}
if (!allowedCategories.has(component.category)) {
addViolation(
`${label} has invalid category "${component.category}"; allowed categories are ${[
...allowedCategories
].join(", ")}`
);
}
if (component.iconVariant !== undefined) {
if (component.category !== "icon") {
addViolation(`${label} iconVariant is only valid for icon metadata`);
} else if (!allowedIconVariants.has(component.iconVariant)) {
addViolation(
`${label} has invalid iconVariant "${component.iconVariant}"; allowed variants are ${[
...allowedIconVariants
].join(", ")}`
);
}
}
if (!allowedStatuses.has(component.status)) {
addViolation(
`${label} has invalid status "${component.status}"; allowed statuses are ${[
...allowedStatuses
].join(", ")}`
);
}
if (typeof component.source !== "string") {
addViolation(`${label} source must be a string`);
} else if (!component.source.startsWith("src/")) {
addViolation(`${label} source must start with "src/"`);
} else {
const resolvedSource = path.resolve(packageRoot, component.source);
const relativeSource = path.relative(packageRoot, resolvedSource);
if (
relativeSource.startsWith("..") ||
path.isAbsolute(relativeSource)
) {
addViolation(`${label} source must stay under packages/ui/system`);
} else if (!existsSync(resolvedSource)) {
addViolation(
`${label} source file does not exist: ${component.source}`
);
} else if (component.category !== "style-entry") {
const sourceExports = publicValueExports(component.source);
if (!sourceExports.has(component.export)) {
addViolation(
`${label} export "${component.export}" was not found in ${component.source}`
);
}
}
}
if (
typeof component.description !== "string" ||
component.description.trim().length === 0
) {
addViolation(`${label} description must be a non-empty string`);
}
if (!Array.isArray(component.useCases)) {
addViolation(`${label} useCases must be an array`);
} else {
component.useCases.forEach((useCase, useCaseIndex) => {
if (typeof useCase !== "string") {
addViolation(`${label} useCases[${useCaseIndex}] must be a string`);
}
});
}
if (!Array.isArray(component.migrationHints)) {
addViolation(`${label} migrationHints must be an array`);
} else {
component.migrationHints.forEach((migrationHint, hintIndex) => {
if (typeof migrationHint !== "string") {
addViolation(
`${label} migrationHints[${hintIndex}] must be a string`
);
}
});
}
if (
component.storyboard !== undefined &&
typeof component.storyboard !== "boolean"
) {
addViolation(`${label} storyboard must be boolean when present`);
}
if (
component.nativePreview !== undefined &&
typeof component.nativePreview !== "boolean"
) {
addViolation(`${label} nativePreview must be boolean when present`);
} else if (component.nativePreview === true) {
if (component.from !== "@tutti-os/ui-system/native") {
addViolation(
`${label} nativePreview requires the @tutti-os/ui-system/native entrypoint`
);
}
if (!nativeGallerySource.includes(`title="${component.id}"`)) {
addViolation(
`${label} nativePreview requires a matching Mobile development gallery section`
);
}
}
if (
publicEntrypoints.has(component.from) &&
component.category !== "style-entry" &&
typeof component.source === "string" &&
component.source.startsWith("src/")
) {
const barrelPath = publicEntrypoints.get(component.from);
const sourcePath = path.normalize(component.source);
if (path.normalize(barrelPath) === sourcePath) {
return;
}
if (!barrelPath || !existsSync(path.join(packageRoot, barrelPath))) {
addViolation(
`${label} from "${component.from}" does not point to an existing public barrel`
);
return;
}
const barrelText = readPackageFile(barrelPath);
if (!barrelExportsSource(barrelPath, barrelText, sourcePath)) {
addViolation(
`${label} source "${component.source}" is not re-exported by ${component.from}`
);
}
}
});
validatePublicExportCoverage(metadataExports);
}
}
if (violations.length > 0) {
console.error("ui metadata check failed:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
process.exit(1);
}
console.log("ui metadata check passed");