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
538 lines
16 KiB
JavaScript
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");
|