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
625 lines
17 KiB
JavaScript
625 lines
17 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import { dirname, join, relative, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const workspaceRoot = join(scriptDirectory, "..", "..");
|
|
const searchRoots = ["apps", "packages", "tools"];
|
|
const ignoredDirectories = new Set([
|
|
".git",
|
|
".tutti-ui-system-dev",
|
|
".turbo",
|
|
"dist",
|
|
"node_modules",
|
|
"out"
|
|
]);
|
|
const ignoredFiles = new Set(["tools/scripts/check-ui-boundaries.mjs"]);
|
|
const ignoredPathPrefixes = ["apps/mobile/ios/Pods/"];
|
|
const stagedOnly = process.argv.includes("--staged");
|
|
|
|
const allowedUISystemSpecifiers = new Set([
|
|
"@tutti-os/ui-system",
|
|
"@tutti-os/ui-system/components",
|
|
"@tutti-os/ui-system/icons",
|
|
"@tutti-os/ui-system/metadata",
|
|
"@tutti-os/ui-system/native",
|
|
"@tutti-os/ui-system/native.css",
|
|
"@tutti-os/ui-system/styles.css",
|
|
"@tutti-os/ui-system/utils"
|
|
]);
|
|
const uiSystemDevViteSpecifier = "@tutti-os/ui-system/dev-vite";
|
|
const forbiddenUISystemPrefixes = [
|
|
"@tutti-os/ui-system/src/",
|
|
"@tutti-os/ui-system/lib/",
|
|
"@tutti-os/ui-system/hooks/",
|
|
"@tutti-os/ui-system/components/",
|
|
"@tutti-os/ui-system/icons/"
|
|
];
|
|
const allowedCssFiles = new Set([
|
|
"apps/desktop/src/renderer/src/style.css",
|
|
"apps/ui-storyboard/src/style.css",
|
|
"packages/agent/gui/app/renderer/agentactivity.css",
|
|
"packages/workbench/launchpad/src/styles/workbench-launchpad.css",
|
|
"packages/workspace/terminal/src/styles/terminal.css",
|
|
"packages/workbench/surface/src/styles/workbench.css",
|
|
"tools/fixtures/ui-system-external-vite/src/style.css"
|
|
]);
|
|
const allowedCssPrefixes = [
|
|
"packages/ui/system/src/styles/",
|
|
"packages/ui/rich-text/src/at-panel/"
|
|
];
|
|
const allowedRawSvgPrefixes = [
|
|
"packages/ui/system/src/icons/",
|
|
"packages/agent/gui/"
|
|
];
|
|
const allowedExternalIconImportPrefixes = [
|
|
"packages/ui/system/src/icons/",
|
|
"packages/agent/gui/"
|
|
];
|
|
const externalIconImportPattern =
|
|
/(?:from\s*["']|import\s*\(\s*["'])(lucide-react|@radix-ui\/react-icons|react-icons(?:\/[^"'`\s)]+)?)/g;
|
|
const rendererCodeRoot = "apps/desktop/src/renderer/src/";
|
|
const rendererStyleEntryPath = "apps/desktop/src/renderer/src/style.css";
|
|
const thinTailwindStyleEntryPaths = new Set([
|
|
rendererStyleEntryPath,
|
|
"apps/ui-storyboard/src/style.css"
|
|
]);
|
|
const textExtensions = new Set([
|
|
".cjs",
|
|
".css",
|
|
".cts",
|
|
".js",
|
|
".json",
|
|
".jsx",
|
|
".mjs",
|
|
".mts",
|
|
".ts",
|
|
".tsx"
|
|
]);
|
|
const rendererStyleDirectoryPath = dirname(rendererStyleEntryPath);
|
|
const styleSourcesByEntryPath = new Map();
|
|
const usedRendererTailwindSourcePackages = new Set();
|
|
const packageTailwindSourceRequirements =
|
|
await loadPackageTailwindSourceRequirements();
|
|
const inspectedFiles = new Set();
|
|
|
|
const violations = [];
|
|
|
|
if (stagedOnly) {
|
|
await scanStagedFiles();
|
|
} else {
|
|
for (const root of searchRoots) {
|
|
await walk(join(workspaceRoot, root));
|
|
}
|
|
}
|
|
|
|
validateRendererTailwindSources();
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Found frontend boundary violations:");
|
|
for (const violation of violations) {
|
|
console.error(
|
|
`- [${violation.rule}] ${violation.file}:${violation.line} ${violation.message}`
|
|
);
|
|
}
|
|
|
|
process.exitCode = 1;
|
|
} else {
|
|
runRendererTokenCheck();
|
|
runUIMetadataCheck();
|
|
console.log("ui boundary check passed");
|
|
}
|
|
|
|
async function walk(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const path = join(directory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
if (!ignoredDirectories.has(entry.name)) {
|
|
await walk(path);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = normalizePosix(relative(workspaceRoot, path));
|
|
if (isIgnoredPath(relativePath)) {
|
|
continue;
|
|
}
|
|
|
|
await inspectFile(relativePath, path);
|
|
}
|
|
}
|
|
|
|
async function scanStagedFiles() {
|
|
const output = execFileSync(
|
|
"git",
|
|
["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
{
|
|
cwd: workspaceRoot,
|
|
encoding: "utf8"
|
|
}
|
|
);
|
|
|
|
const files = output
|
|
.split("\n")
|
|
.map((file) => file.trim())
|
|
.filter(Boolean)
|
|
.filter((file) => isTrackedSearchRoot(file))
|
|
.filter((file) => !isIgnoredPath(file));
|
|
|
|
for (const relativePath of files) {
|
|
await inspectFile(relativePath, join(workspaceRoot, relativePath));
|
|
}
|
|
|
|
if (!inspectedFiles.has(rendererStyleEntryPath)) {
|
|
await inspectFile(
|
|
rendererStyleEntryPath,
|
|
join(workspaceRoot, rendererStyleEntryPath)
|
|
);
|
|
}
|
|
}
|
|
|
|
async function inspectFile(relativePath, absolutePath) {
|
|
if (inspectedFiles.has(relativePath)) {
|
|
return;
|
|
}
|
|
inspectedFiles.add(relativePath);
|
|
|
|
if (relativePath.endsWith(".css")) {
|
|
await inspectCssFile(relativePath, absolutePath);
|
|
return;
|
|
}
|
|
|
|
if (relativePath.endsWith(".svg")) {
|
|
inspectSvgAsset(relativePath);
|
|
return;
|
|
}
|
|
|
|
if (!hasTextExtension(relativePath)) {
|
|
return;
|
|
}
|
|
|
|
const content = await readFile(absolutePath, "utf8");
|
|
inspectCodeFile(relativePath, content);
|
|
}
|
|
|
|
async function inspectCssFile(relativePath, absolutePath) {
|
|
if (!isAllowedCssFile(relativePath)) {
|
|
violations.push({
|
|
file: relativePath,
|
|
line: 1,
|
|
message:
|
|
"custom .css files are only allowed in packages/ui/system/src/styles/*, packages/workbench/surface/src/styles/workbench.css, packages/workspace/terminal/src/styles/terminal.css, packages/agent/gui/app/renderer/agentactivity.css, apps/desktop/src/renderer/src/style.css, apps/ui-storyboard/src/style.css, and approved fixtures",
|
|
rule: "css-file"
|
|
});
|
|
return;
|
|
}
|
|
|
|
const content = await readFile(absolutePath, "utf8");
|
|
|
|
if (thinTailwindStyleEntryPaths.has(relativePath)) {
|
|
const lines = content.split("\n");
|
|
const styleSources = new Map();
|
|
styleSourcesByEntryPath.set(relativePath, styleSources);
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const trimmed = lines[index].trim();
|
|
if (
|
|
trimmed.length === 0 ||
|
|
trimmed.startsWith("/*") ||
|
|
trimmed.startsWith("//")
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (!trimmed.startsWith("@import ") && !trimmed.startsWith("@source ")) {
|
|
violations.push({
|
|
file: relativePath,
|
|
line: index + 1,
|
|
message:
|
|
"style.css should stay a thin Tailwind entrypoint with only @import/@source directives",
|
|
rule: "css-entrypoint"
|
|
});
|
|
}
|
|
|
|
const sourceMatch = trimmed.match(/^@source\s+["']([^"']+)["'];?$/);
|
|
if (!sourceMatch) {
|
|
continue;
|
|
}
|
|
|
|
const resolvedSourcePath = normalizePosix(
|
|
relative(workspaceRoot, join(dirname(absolutePath), sourceMatch[1]))
|
|
);
|
|
styleSources.set(resolvedSourcePath, {
|
|
line: index + 1,
|
|
sourceLiteral: sourceMatch[1]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectSvgAsset(relativePath) {
|
|
if (allowedRawSvgPrefixes.some((prefix) => relativePath.startsWith(prefix))) {
|
|
return;
|
|
}
|
|
|
|
violations.push({
|
|
file: relativePath,
|
|
line: 1,
|
|
message: ".svg assets must live under packages/ui/system/src/icons/",
|
|
rule: "svg-asset"
|
|
});
|
|
}
|
|
|
|
function inspectCodeFile(relativePath, content) {
|
|
inspectDevViteImports(relativePath, content);
|
|
inspectRichTextMentionBoundaries(relativePath, content);
|
|
|
|
const lines = content.split("\n");
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
|
|
if (relativePath.startsWith(rendererCodeRoot)) {
|
|
for (const specifier of matchImportSpecifiers(line)) {
|
|
registerRendererTailwindSourcePackageUsage(specifier);
|
|
}
|
|
}
|
|
|
|
for (const specifier of matchUISystemSpecifiers(line)) {
|
|
if (specifier === uiSystemDevViteSpecifier) {
|
|
continue;
|
|
}
|
|
|
|
if (allowedUISystemSpecifiers.has(specifier)) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!forbiddenUISystemPrefixes.some((prefix) =>
|
|
specifier.startsWith(prefix)
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
violations.push({
|
|
file: relativePath,
|
|
line: index + 1,
|
|
message: `invalid ui-system deep import: ${specifier}. Use one of: ${Array.from(allowedUISystemSpecifiers).join(", ")}`,
|
|
rule: "ui-system-import"
|
|
});
|
|
}
|
|
|
|
if (
|
|
line.includes("<svg") &&
|
|
!allowedRawSvgPrefixes.some((prefix) => relativePath.startsWith(prefix))
|
|
) {
|
|
violations.push({
|
|
file: relativePath,
|
|
line: index + 1,
|
|
message:
|
|
"raw <svg> markup is only allowed under packages/ui/system/src/icons/",
|
|
rule: "svg-inline"
|
|
});
|
|
}
|
|
|
|
for (const specifier of matchExternalIconImports(line)) {
|
|
if (
|
|
allowedExternalIconImportPrefixes.some((prefix) =>
|
|
relativePath.startsWith(prefix)
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
violations.push({
|
|
file: relativePath,
|
|
line: index + 1,
|
|
message: `external icon package import ${specifier} is only allowed inside packages/ui/system/src/icons/`,
|
|
rule: "icon-import"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectRichTextMentionBoundaries(relativePath, content) {
|
|
if (relativePath.startsWith("packages/ui/rich-text/src/service/")) {
|
|
for (const match of matchImportSpecifierLocations(content)) {
|
|
if (
|
|
match.specifier === "@tutti-os/workspace-external-core" ||
|
|
match.specifier.startsWith("@tutti-os/workspace-external-core/") ||
|
|
match.specifier.includes("apps/desktop") ||
|
|
/(?:^|\/)tsh(?:\/|$)/.test(match.specifier)
|
|
) {
|
|
violations.push({
|
|
file: relativePath,
|
|
line: match.line,
|
|
message:
|
|
"ui-rich-text service must remain host agnostic; move host bridge imports into an adapter",
|
|
rule: "mention-service-host-import"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (relativePath === "packages/agent/gui/shared/AgentMessageMarkdown.tsx") {
|
|
reportDirectMentionImages(relativePath, content, false);
|
|
}
|
|
if (
|
|
relativePath ===
|
|
"packages/agent/gui/agent-gui/agentGuiNode/agentRichText/AgentMentionNodeView.tsx"
|
|
) {
|
|
reportDirectMentionImages(relativePath, content, true);
|
|
}
|
|
}
|
|
|
|
function reportDirectMentionImages(relativePath, content, allowFileThumbnail) {
|
|
for (const match of content.matchAll(/<img\b/g)) {
|
|
const index = match.index ?? 0;
|
|
const nearbyPrefix = content.slice(Math.max(0, index - 500), index);
|
|
if (
|
|
allowFileThumbnail &&
|
|
nearbyPrefix.includes('data-agent-mention-file-thumb="true"')
|
|
) {
|
|
continue;
|
|
}
|
|
violations.push({
|
|
file: relativePath,
|
|
line: lineNumberAt(content, index),
|
|
message:
|
|
"AgentGUI mention icons must render through MentionPill so image failures keep a semantic fallback",
|
|
rule: "mention-icon-primitive"
|
|
});
|
|
}
|
|
}
|
|
|
|
function inspectDevViteImports(relativePath, content) {
|
|
if (isAllowedDevViteImporter(relativePath)) {
|
|
return;
|
|
}
|
|
|
|
for (const match of matchImportSpecifierLocations(content)) {
|
|
if (match.specifier !== uiSystemDevViteSpecifier) {
|
|
continue;
|
|
}
|
|
|
|
violations.push({
|
|
file: relativePath,
|
|
line: match.line,
|
|
message:
|
|
"@tutti-os/ui-system/dev-vite may only be imported from bundler config or tooling files",
|
|
rule: "ui-system-dev-vite-import"
|
|
});
|
|
}
|
|
}
|
|
|
|
function registerRendererTailwindSourcePackageUsage(specifier) {
|
|
for (const packageName of packageTailwindSourceRequirements.keys()) {
|
|
if (specifier === packageName || specifier.startsWith(`${packageName}/`)) {
|
|
usedRendererTailwindSourcePackages.add(packageName);
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasTextExtension(path) {
|
|
return Array.from(textExtensions).some((extension) =>
|
|
path.endsWith(extension)
|
|
);
|
|
}
|
|
|
|
function isTrackedSearchRoot(path) {
|
|
return searchRoots.some(
|
|
(root) => path === root || path.startsWith(`${root}/`)
|
|
);
|
|
}
|
|
|
|
function isIgnoredPath(relativePath) {
|
|
return (
|
|
ignoredFiles.has(relativePath) ||
|
|
ignoredPathPrefixes.some((prefix) => relativePath.startsWith(prefix))
|
|
);
|
|
}
|
|
|
|
function isAllowedCssFile(relativePath) {
|
|
return (
|
|
allowedCssFiles.has(relativePath) ||
|
|
allowedCssPrefixes.some((prefix) => relativePath.startsWith(prefix))
|
|
);
|
|
}
|
|
|
|
function isAllowedDevViteImporter(relativePath) {
|
|
return (
|
|
relativePath === "vite.config.ts" ||
|
|
relativePath === "vite.config.mts" ||
|
|
relativePath.endsWith("/vite.config.ts") ||
|
|
relativePath.endsWith("/vite.config.mts") ||
|
|
relativePath === "electron.vite.config.ts" ||
|
|
relativePath.endsWith("/electron.vite.config.ts") ||
|
|
relativePath.startsWith(
|
|
"packages/ui/system/agent/tutti-ui-system/scripts/"
|
|
) ||
|
|
relativePath.startsWith("tools/scripts/") ||
|
|
relativePath.startsWith("scripts/")
|
|
);
|
|
}
|
|
|
|
function matchUISystemSpecifiers(line) {
|
|
const matches = line.matchAll(/@tutti-os\/ui-system(?:\/[^"'`\s,)]+)?/g);
|
|
return Array.from(matches, (match) => match[0]);
|
|
}
|
|
|
|
function matchExternalIconImports(line) {
|
|
const matches = line.matchAll(externalIconImportPattern);
|
|
return Array.from(matches, (match) => match[1]);
|
|
}
|
|
|
|
function matchImportSpecifiers(line) {
|
|
const matches = line.matchAll(
|
|
/(?:import|export)\s+(?:type\s+)?(?:[^"'`]*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g
|
|
);
|
|
return Array.from(matches, (match) => match[1] ?? match[2]).filter(Boolean);
|
|
}
|
|
|
|
function matchImportSpecifierLocations(content) {
|
|
const matches = [];
|
|
const patterns = [
|
|
/\b(?:import|export)\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/g,
|
|
/\bimport\s*["']([^"']+)["']/g,
|
|
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
for (const match of content.matchAll(pattern)) {
|
|
const specifier = match[1];
|
|
const specifierIndex = match.index + match[0].lastIndexOf(specifier);
|
|
|
|
matches.push({
|
|
line: lineNumberAt(content, specifierIndex),
|
|
specifier
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
function lineNumberAt(content, index) {
|
|
let line = 1;
|
|
|
|
for (let offset = 0; offset < index; offset += 1) {
|
|
if (content[offset] === "\n") {
|
|
line += 1;
|
|
}
|
|
}
|
|
|
|
return line;
|
|
}
|
|
|
|
function normalizePosix(path) {
|
|
return path.split(sep).join("/");
|
|
}
|
|
|
|
async function loadPackageTailwindSourceRequirements() {
|
|
const requirements = new Map();
|
|
await walkPackageDirectory(join(workspaceRoot, "packages"), requirements);
|
|
return requirements;
|
|
}
|
|
|
|
async function walkPackageDirectory(directory, requirements) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const path = join(directory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
if (!ignoredDirectories.has(entry.name)) {
|
|
await walkPackageDirectory(path, requirements);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile() || entry.name !== "package.json") {
|
|
continue;
|
|
}
|
|
|
|
const relativeManifestPath = normalizePosix(relative(workspaceRoot, path));
|
|
const packageDirectoryPath = dirname(relativeManifestPath);
|
|
const manifest = JSON.parse(await readFile(path, "utf8"));
|
|
const tailwindSourceRoot = manifest.tutti?.tailwindSourceRoot;
|
|
|
|
if (
|
|
typeof manifest.name !== "string" ||
|
|
typeof tailwindSourceRoot !== "string" ||
|
|
tailwindSourceRoot.trim().length === 0
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
requirements.set(manifest.name, {
|
|
packageDirectoryPath,
|
|
sourcePath: normalizePosix(join(packageDirectoryPath, tailwindSourceRoot))
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateRendererTailwindSources() {
|
|
const rendererStyleSources =
|
|
styleSourcesByEntryPath.get(rendererStyleEntryPath) ?? new Map();
|
|
|
|
for (const packageName of usedRendererTailwindSourcePackages) {
|
|
const requirement = packageTailwindSourceRequirements.get(packageName);
|
|
if (!requirement) {
|
|
continue;
|
|
}
|
|
|
|
if (rendererStyleSources.has(requirement.sourcePath)) {
|
|
continue;
|
|
}
|
|
|
|
const expectedSourceLiteral = normalizePosix(
|
|
relative(rendererStyleDirectoryPath, requirement.sourcePath)
|
|
);
|
|
const mismatchedSourceEntry = findRendererSourceEntryForPackage(
|
|
rendererStyleSources,
|
|
requirement.packageDirectoryPath
|
|
);
|
|
|
|
if (mismatchedSourceEntry) {
|
|
violations.push({
|
|
file: rendererStyleEntryPath,
|
|
line: mismatchedSourceEntry.line,
|
|
message: `renderer style.css has the wrong @source for ${packageName}. Expected "@source \\"${expectedSourceLiteral}\\";" but found "@source \\"${mismatchedSourceEntry.sourceLiteral}\\";".`,
|
|
rule: "tailwind-source"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
violations.push({
|
|
file: rendererStyleEntryPath,
|
|
line: 1,
|
|
message: `renderer style.css is missing @source for ${packageName}. Add: @source "${expectedSourceLiteral}";`,
|
|
rule: "tailwind-source"
|
|
});
|
|
}
|
|
}
|
|
|
|
function findRendererSourceEntryForPackage(styleSources, packageDirectoryPath) {
|
|
for (const [sourcePath, entry] of styleSources.entries()) {
|
|
if (
|
|
sourcePath === packageDirectoryPath ||
|
|
sourcePath.startsWith(`${packageDirectoryPath}/`)
|
|
) {
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function runUIMetadataCheck() {
|
|
runCheck("tools/scripts/check-ui-metadata.mjs");
|
|
}
|
|
|
|
function runRendererTokenCheck() {
|
|
runCheck("tools/scripts/check-ui-renderer-tokens.mjs");
|
|
}
|
|
|
|
function runCheck(scriptPath) {
|
|
try {
|
|
execFileSync(process.execPath, [scriptPath], {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit"
|
|
});
|
|
} catch (error) {
|
|
process.exit(error.status ?? 1);
|
|
}
|
|
}
|