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
265 lines
6.7 KiB
JavaScript
265 lines
6.7 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import { dirname, join, normalize, relative, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const defaultWorkspaceRoot = join(scriptDirectory, "..", "..");
|
|
const workspaceRoot = process.env.TUTTI_WORKSPACE_ROOT ?? defaultWorkspaceRoot;
|
|
const rendererRoot =
|
|
process.env.TUTTI_RENDERER_ROOT ?? "apps/desktop/src/renderer/src";
|
|
const ignoredDirectories = new Set([
|
|
".git",
|
|
".turbo",
|
|
"dist",
|
|
"node_modules",
|
|
"out"
|
|
]);
|
|
const stagedOnly = process.argv.includes("--staged");
|
|
const textExtensions = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx"]);
|
|
const violations = [];
|
|
|
|
if (stagedOnly) {
|
|
await scanStagedFiles();
|
|
} else {
|
|
await walk(join(workspaceRoot, rendererRoot));
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Found renderer feature boundary violations:");
|
|
for (const violation of violations) {
|
|
console.error(
|
|
`- [${violation.rule}] ${violation.file}:${violation.line} ${violation.message}`
|
|
);
|
|
}
|
|
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log("renderer feature 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 = toPosixPath(relative(workspaceRoot, path));
|
|
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) => file.startsWith(`${rendererRoot}/`));
|
|
|
|
for (const relativePath of files) {
|
|
await inspectFile(relativePath, join(workspaceRoot, relativePath));
|
|
}
|
|
}
|
|
|
|
async function inspectFile(relativePath, absolutePath) {
|
|
if (!hasTextExtension(relativePath)) {
|
|
return;
|
|
}
|
|
|
|
const content = await readFile(absolutePath, "utf8");
|
|
inspectCodeFile(relativePath, content);
|
|
}
|
|
|
|
function inspectCodeFile(relativePath, content) {
|
|
const lines = content.split("\n");
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
for (const specifier of matchImportSpecifiers(lines[index])) {
|
|
inspectImport(relativePath, specifier, index + 1);
|
|
}
|
|
|
|
inspectWindowTuttiAccess(relativePath, lines[index], index + 1);
|
|
inspectWorkspaceLaunchCoordinatorMap(relativePath, lines[index], index + 1);
|
|
}
|
|
}
|
|
|
|
function inspectWorkspaceLaunchCoordinatorMap(relativePath, lineContent, line) {
|
|
if (!isWorkspaceLaunchCoordinator(relativePath)) {
|
|
return;
|
|
}
|
|
|
|
if (!/\bnew\s+Map\s*(?:<|\()/u.test(lineContent)) {
|
|
return;
|
|
}
|
|
|
|
violations.push({
|
|
file: relativePath,
|
|
line,
|
|
message:
|
|
"workspace launch coordinators must use WorkspaceScopedRegistrationRegistry instead of owning a Map",
|
|
rule: "workspace-launch-coordinator-private-map"
|
|
});
|
|
}
|
|
|
|
function isWorkspaceLaunchCoordinator(relativePath) {
|
|
const servicesRoot = `${rendererRoot}/features/workspace-workbench/services/`;
|
|
if (!relativePath.startsWith(servicesRoot)) {
|
|
return false;
|
|
}
|
|
|
|
const servicePath = relativePath.slice(servicesRoot.length);
|
|
if (servicePath.includes("/")) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
servicePath.endsWith("LaunchCoordinator.ts") ||
|
|
servicePath === "workspaceMessageCenterCoordinator.ts"
|
|
);
|
|
}
|
|
|
|
function inspectImport(importerPath, specifier, line) {
|
|
const targetPath = resolveRendererSpecifier(importerPath, specifier);
|
|
if (!targetPath) {
|
|
return;
|
|
}
|
|
|
|
inspectWorkspaceWorkbenchUiInternalImport(
|
|
importerPath,
|
|
targetPath,
|
|
specifier,
|
|
line
|
|
);
|
|
|
|
const internalMatch = targetPath.match(
|
|
/^apps\/desktop\/src\/renderer\/src\/features\/([^/]+)\/services\/internal(?:\/|$)/
|
|
);
|
|
if (!internalMatch) {
|
|
return;
|
|
}
|
|
|
|
const targetFeature = internalMatch[1];
|
|
const allowedPrefix = `apps/desktop/src/renderer/src/features/${targetFeature}/`;
|
|
if (importerPath.startsWith(allowedPrefix)) {
|
|
return;
|
|
}
|
|
|
|
violations.push({
|
|
file: importerPath,
|
|
line,
|
|
message: `cross-feature import of ${targetFeature}/services/internal is not allowed: ${specifier}`,
|
|
rule: "feature-internal-import"
|
|
});
|
|
}
|
|
|
|
function inspectWorkspaceWorkbenchUiInternalImport(
|
|
importerPath,
|
|
targetPath,
|
|
specifier,
|
|
line
|
|
) {
|
|
const workbenchFeatureRoot = `${rendererRoot}/features/workspace-workbench/`;
|
|
if (!importerPath.startsWith(`${workbenchFeatureRoot}ui/`)) {
|
|
return;
|
|
}
|
|
|
|
if (!targetPath.startsWith(`${workbenchFeatureRoot}services/internal/`)) {
|
|
return;
|
|
}
|
|
|
|
violations.push({
|
|
file: importerPath,
|
|
line,
|
|
message: `workspace workbench UI must use public service/controller seams instead of importing services/internal: ${specifier}`,
|
|
rule: "workspace-workbench-ui-internal-import"
|
|
});
|
|
}
|
|
|
|
function inspectWindowTuttiAccess(relativePath, lineContent, line) {
|
|
if (!/\bwindow\s*\.\s*tutti\b/.test(lineContent)) {
|
|
return;
|
|
}
|
|
|
|
if (canAccessRendererPreloadApi(relativePath)) {
|
|
return;
|
|
}
|
|
|
|
violations.push({
|
|
file: relativePath,
|
|
line,
|
|
message:
|
|
"renderer code must receive DesktopApi through window composition instead of reading window.tutti directly",
|
|
rule: "renderer-window-tutti-access"
|
|
});
|
|
}
|
|
|
|
function canAccessRendererPreloadApi(relativePath) {
|
|
if (relativePath === `${rendererRoot}/global.d.ts`) {
|
|
return true;
|
|
}
|
|
|
|
const windowsRoot = `${rendererRoot}/app/windows/`;
|
|
if (!relativePath.startsWith(windowsRoot)) {
|
|
return false;
|
|
}
|
|
|
|
return /^create[A-Z][^/]*Container\.ts$/.test(
|
|
relativePath.slice(windowsRoot.length).split("/").at(-1) ?? ""
|
|
);
|
|
}
|
|
|
|
function resolveRendererSpecifier(importerPath, specifier) {
|
|
if (specifier.startsWith("@renderer/")) {
|
|
return normalizePosix(
|
|
`${rendererRoot}/${specifier.slice("@renderer/".length)}`
|
|
);
|
|
}
|
|
|
|
if (specifier.startsWith(".")) {
|
|
return normalizePosix(join(dirname(importerPath), specifier));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function hasTextExtension(path) {
|
|
return Array.from(textExtensions).some((extension) =>
|
|
path.endsWith(extension)
|
|
);
|
|
}
|
|
|
|
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 normalizePosix(path) {
|
|
return toPosixPath(normalize(path));
|
|
}
|
|
|
|
function toPosixPath(path) {
|
|
return path.split(sep).join("/");
|
|
}
|