源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
join,
|
||||
normalize,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const defaultWorkspaceRoot = join(scriptDirectory, "..", "..");
|
||||
const workspaceRoot = process.env.TUTTI_WORKSPACE_ROOT ?? defaultWorkspaceRoot;
|
||||
const ts = await loadTypeScriptModule();
|
||||
const desktopRoot = "apps/desktop";
|
||||
const runtimeRoots = ["apps/desktop/src/main", "apps/desktop/src/preload"];
|
||||
const relevantStagedPrefixes = [
|
||||
"apps/desktop/electron.vite.config.ts",
|
||||
"apps/desktop/src/main/",
|
||||
"apps/desktop/src/preload/",
|
||||
"apps/desktop/src/shared/",
|
||||
"packages/"
|
||||
];
|
||||
const ignoredDirectories = new Set([
|
||||
".git",
|
||||
".turbo",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out"
|
||||
]);
|
||||
const runtimeCodeExtensions = new Set([
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts"
|
||||
]);
|
||||
const sourceLikeExtensions = new Set([".ts", ".tsx", ".mts", ".cts", ".css"]);
|
||||
const repoAliases = new Map([
|
||||
["@main/", "apps/desktop/src/main/"],
|
||||
["@preload/", "apps/desktop/src/preload/"],
|
||||
["@renderer/", "apps/desktop/src/renderer/src/"],
|
||||
["@shared/", "apps/desktop/src/shared/"]
|
||||
]);
|
||||
const stagedOnly = process.argv.includes("--staged");
|
||||
|
||||
const packageRegistry = await loadWorkspacePackageRegistry();
|
||||
const externalizedPackageExcludes = await loadElectronRuntimeExcludePackages();
|
||||
const localEdgeCache = new Map();
|
||||
const packageHazardCache = new Map();
|
||||
const packageInfoByRoot = Array.from(packageRegistry.values()).sort(
|
||||
(left, right) => right.rootPath.length - left.rootPath.length
|
||||
);
|
||||
const violations = [];
|
||||
const reportedViolationKeys = new Set();
|
||||
|
||||
if (stagedOnly) {
|
||||
const stagedFiles = listStagedFiles();
|
||||
const hasRelevantChange = stagedFiles.some((file) =>
|
||||
relevantStagedPrefixes.some((prefix) =>
|
||||
prefix.endsWith(".ts") ? file === prefix : file.startsWith(prefix)
|
||||
)
|
||||
);
|
||||
|
||||
if (!hasRelevantChange) {
|
||||
console.log(
|
||||
"electron runtime boundary check skipped (no relevant staged changes)"
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeEntryFiles = await collectRuntimeEntryFiles();
|
||||
for (const entryFile of runtimeEntryFiles) {
|
||||
const visitedLocalFiles = new Set();
|
||||
await inspectReachableRuntimeFile(entryFile, visitedLocalFiles);
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Found Electron runtime boundary violations:");
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`- [${violation.rule}] ${violation.file}:${violation.line} ${violation.message}`
|
||||
);
|
||||
console.error(` suggestion: ${violation.suggestion}`);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("electron runtime boundary check passed");
|
||||
}
|
||||
|
||||
async function collectRuntimeEntryFiles() {
|
||||
const files = [];
|
||||
|
||||
for (const runtimeRoot of runtimeRoots) {
|
||||
await walkRuntimeRoot(join(workspaceRoot, runtimeRoot), files);
|
||||
}
|
||||
|
||||
files.sort();
|
||||
return files;
|
||||
}
|
||||
|
||||
async function walkRuntimeRoot(directory, files) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(entry.name)) {
|
||||
await walkRuntimeRoot(absolutePath, files);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRuntimeSourceFile(absolutePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
function isRuntimeSourceFile(absolutePath) {
|
||||
const relativePath = toWorkspaceRelative(absolutePath);
|
||||
const extension = extname(relativePath);
|
||||
|
||||
if (!runtimeCodeExtensions.has(extension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (relativePath.endsWith(".d.ts")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isTestFile(relativePath);
|
||||
}
|
||||
|
||||
function isTestFile(relativePath) {
|
||||
return /\.(test|spec)\.[^.]+$/.test(relativePath);
|
||||
}
|
||||
|
||||
async function inspectReachableRuntimeFile(absolutePath, visitedLocalFiles) {
|
||||
const normalizedPath = normalize(absolutePath);
|
||||
if (visitedLocalFiles.has(normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
visitedLocalFiles.add(normalizedPath);
|
||||
|
||||
const runtimeEdges = await getRuntimeEdges(normalizedPath);
|
||||
for (const edge of runtimeEdges) {
|
||||
const resolved = resolveImportSpecifier(normalizedPath, edge.specifier);
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolved.kind === "external") {
|
||||
if (isReactPackage(resolved.packageName)) {
|
||||
reportViolation({
|
||||
file: toWorkspaceRelative(normalizedPath),
|
||||
line: edge.line,
|
||||
message: `runtime import ${edge.specifier} reaches React, which is not allowed in Electron main/preload execution paths`,
|
||||
rule: "electron-runtime-ui-leak",
|
||||
suggestion:
|
||||
"Keep Electron main/preload on non-React modules. Move the needed value into a .ts service/model module and import that instead of a React-facing surface."
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolved.kind === "file") {
|
||||
if (isTsxFile(resolved.path)) {
|
||||
reportViolation({
|
||||
file: toWorkspaceRelative(normalizedPath),
|
||||
line: edge.line,
|
||||
message: `runtime import ${edge.specifier} resolves to ${toWorkspaceRelative(resolved.path)}, which is a .tsx module and not allowed in Electron main/preload execution paths`,
|
||||
rule: "electron-runtime-ui-leak",
|
||||
suggestion:
|
||||
"Move the needed value into a .ts module and import that instead of a .tsx file or a barrel that re-exports React UI."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceLocalFile(resolved.path)) {
|
||||
await inspectReachableRuntimeFile(resolved.path, visitedLocalFiles);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const hazard = await analyzeWorkspacePackageImport({
|
||||
importerPath: normalizedPath,
|
||||
line: edge.line,
|
||||
specifier: edge.specifier,
|
||||
resolvedPackage: resolved
|
||||
});
|
||||
|
||||
if (!hazard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
reportViolation(hazard);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeWorkspacePackageImport({
|
||||
importerPath,
|
||||
line,
|
||||
specifier,
|
||||
resolvedPackage
|
||||
}) {
|
||||
const relativeImporterPath = toWorkspaceRelative(importerPath);
|
||||
const trackSourceLike = !externalizedPackageExcludes.has(
|
||||
resolvedPackage.packageName
|
||||
);
|
||||
const cacheKey = `${resolvedPackage.entryPath}:${trackSourceLike ? "source" : "bundled"}`;
|
||||
let analysis = packageHazardCache.get(cacheKey);
|
||||
if (!analysis) {
|
||||
analysis = await analyzeResolvedTargetGraph(resolvedPackage.entryPath, {
|
||||
trackSourceLike
|
||||
});
|
||||
packageHazardCache.set(cacheKey, analysis);
|
||||
}
|
||||
|
||||
if (analysis.uiLeakPath) {
|
||||
return {
|
||||
file: relativeImporterPath,
|
||||
line,
|
||||
message: `runtime import ${specifier} reaches React UI code via ${formatTrail(
|
||||
analysis.uiLeakPath
|
||||
)}`,
|
||||
rule: "electron-runtime-ui-leak",
|
||||
suggestion: buildUiLeakSuggestion({
|
||||
packageInfo: resolvedPackage.packageInfo,
|
||||
specifier
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (!analysis.sourceLikePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
file: relativeImporterPath,
|
||||
line,
|
||||
message: `runtime import ${specifier} is externalized in Electron and resolves to source files via ${formatTrail(
|
||||
analysis.sourceLikePath
|
||||
)}`,
|
||||
rule: "electron-runtime-externalized-source",
|
||||
suggestion: buildExternalizedSourceSuggestion({
|
||||
packageInfo: resolvedPackage.packageInfo,
|
||||
specifier
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function analyzeResolvedTargetGraph(entryPath, options) {
|
||||
return analyzeFileHazards(normalize(entryPath), options, new Set());
|
||||
}
|
||||
|
||||
async function analyzeFileHazards(filePath, options, activeFiles) {
|
||||
const normalizedPath = normalize(filePath);
|
||||
const relativePath = toWorkspaceRelative(normalizedPath);
|
||||
const extension = extname(relativePath);
|
||||
let sourceLikePath =
|
||||
options.trackSourceLike && sourceLikeExtensions.has(extension)
|
||||
? [relativePath]
|
||||
: null;
|
||||
|
||||
if (isTsxFile(normalizedPath)) {
|
||||
return {
|
||||
sourceLikePath,
|
||||
uiLeakPath: [relativePath]
|
||||
};
|
||||
}
|
||||
|
||||
if (activeFiles.has(normalizedPath)) {
|
||||
return {
|
||||
sourceLikePath,
|
||||
uiLeakPath: null
|
||||
};
|
||||
}
|
||||
|
||||
activeFiles.add(normalizedPath);
|
||||
const runtimeEdges = await getRuntimeEdges(normalizedPath);
|
||||
|
||||
for (const edge of runtimeEdges) {
|
||||
const resolved = resolveImportSpecifier(normalizedPath, edge.specifier);
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolved.kind === "external") {
|
||||
if (isReactPackage(resolved.packageName)) {
|
||||
activeFiles.delete(normalizedPath);
|
||||
return {
|
||||
sourceLikePath,
|
||||
uiLeakPath: [relativePath, resolved.packageName]
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const childPath =
|
||||
resolved.kind === "workspace-package"
|
||||
? resolved.entryPath
|
||||
: resolved.path;
|
||||
const childOptions =
|
||||
resolved.kind === "workspace-package"
|
||||
? {
|
||||
...options,
|
||||
trackSourceLike: !externalizedPackageExcludes.has(
|
||||
resolved.packageName
|
||||
)
|
||||
}
|
||||
: options;
|
||||
const childHazards = await analyzeFileHazards(
|
||||
childPath,
|
||||
childOptions,
|
||||
activeFiles
|
||||
);
|
||||
|
||||
if (childHazards.uiLeakPath) {
|
||||
activeFiles.delete(normalizedPath);
|
||||
return {
|
||||
sourceLikePath:
|
||||
sourceLikePath ??
|
||||
prefixTrail(relativePath, childHazards.sourceLikePath),
|
||||
uiLeakPath: prefixTrail(relativePath, childHazards.uiLeakPath)
|
||||
};
|
||||
}
|
||||
|
||||
if (!sourceLikePath && childHazards.sourceLikePath) {
|
||||
sourceLikePath = prefixTrail(relativePath, childHazards.sourceLikePath);
|
||||
}
|
||||
}
|
||||
|
||||
activeFiles.delete(normalizedPath);
|
||||
return {
|
||||
sourceLikePath,
|
||||
uiLeakPath: null
|
||||
};
|
||||
}
|
||||
|
||||
function prefixTrail(label, trail) {
|
||||
if (!trail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trail[0] === label) {
|
||||
return trail;
|
||||
}
|
||||
|
||||
return [label, ...trail];
|
||||
}
|
||||
|
||||
async function getRuntimeEdges(absolutePath) {
|
||||
const normalizedPath = normalize(absolutePath);
|
||||
if (localEdgeCache.has(normalizedPath)) {
|
||||
return localEdgeCache.get(normalizedPath);
|
||||
}
|
||||
|
||||
const sourceText = await readFile(normalizedPath, "utf8");
|
||||
const scriptKind = normalizedPath.endsWith(".tsx")
|
||||
? ts.ScriptKind.TSX
|
||||
: ts.ScriptKind.TS;
|
||||
const sourceFile = ts.createSourceFile(
|
||||
normalizedPath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind
|
||||
);
|
||||
const edges = [];
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const moduleSpecifier = readModuleSpecifier(node.moduleSpecifier);
|
||||
if (moduleSpecifier && !node.importClause?.isTypeOnly) {
|
||||
edges.push({
|
||||
line:
|
||||
sourceFile.getLineAndCharacterOfPosition(
|
||||
node.moduleSpecifier.getStart()
|
||||
).line + 1,
|
||||
specifier: moduleSpecifier
|
||||
});
|
||||
}
|
||||
} else if (ts.isExportDeclaration(node)) {
|
||||
const moduleSpecifier = readModuleSpecifier(node.moduleSpecifier);
|
||||
if (moduleSpecifier && !node.isTypeOnly) {
|
||||
edges.push({
|
||||
line:
|
||||
sourceFile.getLineAndCharacterOfPosition(
|
||||
node.moduleSpecifier.getStart()
|
||||
).line + 1,
|
||||
specifier: moduleSpecifier
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteralLike(node.arguments[0])
|
||||
) {
|
||||
edges.push({
|
||||
line:
|
||||
sourceFile.getLineAndCharacterOfPosition(node.arguments[0].getStart())
|
||||
.line + 1,
|
||||
specifier: node.arguments[0].text
|
||||
});
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
localEdgeCache.set(normalizedPath, edges);
|
||||
return edges;
|
||||
}
|
||||
|
||||
function readModuleSpecifier(node) {
|
||||
if (!node || !ts.isStringLiteralLike(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.text;
|
||||
}
|
||||
|
||||
function resolveImportSpecifier(importerPath, specifier) {
|
||||
if (specifier.startsWith(".")) {
|
||||
return resolveRelativeSpecifier(dirname(importerPath), specifier);
|
||||
}
|
||||
|
||||
if (specifier.startsWith("/")) {
|
||||
return resolveRelativeSpecifier(workspaceRoot, `.${specifier}`);
|
||||
}
|
||||
|
||||
for (const [prefix, targetPrefix] of repoAliases.entries()) {
|
||||
if (specifier.startsWith(prefix)) {
|
||||
return resolveRelativeSpecifier(
|
||||
workspaceRoot,
|
||||
`./${targetPrefix}${specifier.slice(prefix.length)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (specifier.startsWith("#")) {
|
||||
return resolvePackageImportAlias(importerPath, specifier);
|
||||
}
|
||||
|
||||
if (specifier.startsWith("@tutti-os/")) {
|
||||
return resolveWorkspacePackageSpecifier(specifier);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
packageName: getBarePackageName(specifier)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelativeSpecifier(baseDirectory, specifier) {
|
||||
const candidate = normalize(resolve(baseDirectory, specifier));
|
||||
const resolvedPath = resolveFileCandidate(candidate);
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "file",
|
||||
path: resolvedPath
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackageImportAlias(importerPath, specifier) {
|
||||
const packageInfo = getNearestPackageInfo(importerPath);
|
||||
if (!packageInfo || !packageInfo.manifest.imports) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveImportsMapEntry(
|
||||
packageInfo.manifest.imports,
|
||||
specifier
|
||||
);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveRelativeSpecifier(packageInfo.rootPath, target);
|
||||
}
|
||||
|
||||
function resolveWorkspacePackageSpecifier(specifier) {
|
||||
const packageName = getWorkspacePackageName(specifier);
|
||||
const packageInfo = packageRegistry.get(packageName);
|
||||
if (!packageInfo) {
|
||||
return {
|
||||
kind: "external",
|
||||
packageName
|
||||
};
|
||||
}
|
||||
|
||||
const subpath = getWorkspacePackageSubpath(specifier);
|
||||
const exportTarget = resolvePackageExportTarget(
|
||||
packageInfo.manifest.exports,
|
||||
subpath
|
||||
);
|
||||
if (!exportTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entryPath = resolveFileCandidate(
|
||||
join(packageInfo.rootPath, exportTarget)
|
||||
);
|
||||
if (!entryPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
entryPath,
|
||||
kind: "workspace-package",
|
||||
packageInfo,
|
||||
packageName
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackageExportTarget(exportsField, subpath) {
|
||||
if (!exportsField) {
|
||||
return subpath === "." ? "./index.js" : null;
|
||||
}
|
||||
|
||||
if (typeof exportsField === "string") {
|
||||
return subpath === "." ? exportsField : null;
|
||||
}
|
||||
|
||||
const value = exportsField[subpath];
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pickConditionalExportTarget(value);
|
||||
}
|
||||
|
||||
function pickConditionalExportTarget(value) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredKeys = ["import", "default", "node"];
|
||||
for (const key of preferredKeys) {
|
||||
if (typeof value[key] === "string") {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of Object.values(value)) {
|
||||
const nested = pickConditionalExportTarget(candidate);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveImportsMapEntry(importsField, specifier) {
|
||||
if (typeof importsField[specifier] === "string") {
|
||||
return importsField[specifier];
|
||||
}
|
||||
|
||||
for (const [pattern, rawTarget] of Object.entries(importsField)) {
|
||||
if (typeof rawTarget !== "string" || !pattern.includes("*")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [prefix, suffix] = pattern.split("*");
|
||||
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wildcardValue = specifier.slice(
|
||||
prefix.length,
|
||||
specifier.length - suffix.length
|
||||
);
|
||||
return rawTarget.replace("*", wildcardValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveFileCandidate(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.mts`,
|
||||
`${basePath}.cts`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
`${basePath}.mjs`,
|
||||
`${basePath}.cjs`,
|
||||
`${basePath}.json`,
|
||||
join(basePath, "index.ts"),
|
||||
join(basePath, "index.tsx"),
|
||||
join(basePath, "index.mts"),
|
||||
join(basePath, "index.cts"),
|
||||
join(basePath, "index.js"),
|
||||
join(basePath, "index.jsx"),
|
||||
join(basePath, "index.mjs"),
|
||||
join(basePath, "index.cjs"),
|
||||
join(basePath, "index.json")
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (ts.sys.fileExists(candidate)) {
|
||||
return normalize(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadWorkspacePackageRegistry() {
|
||||
const packagesRoot = join(workspaceRoot, "packages");
|
||||
const packageInfos = [];
|
||||
|
||||
await walkPackageDirectories(packagesRoot, packageInfos);
|
||||
|
||||
return new Map(
|
||||
packageInfos
|
||||
.filter((packageInfo) =>
|
||||
packageInfo.manifest.name?.startsWith("@tutti-os/")
|
||||
)
|
||||
.map((packageInfo) => [packageInfo.manifest.name, packageInfo])
|
||||
);
|
||||
}
|
||||
|
||||
async function walkPackageDirectories(directory, packageInfos) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDirectories.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absolutePath = join(directory, entry.name);
|
||||
const packageJsonPath = join(absolutePath, "package.json");
|
||||
if (ts.sys.fileExists(packageJsonPath)) {
|
||||
const manifest = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
packageInfos.push({
|
||||
manifest,
|
||||
packageJsonPath,
|
||||
rootPath: absolutePath
|
||||
});
|
||||
}
|
||||
|
||||
await walkPackageDirectories(absolutePath, packageInfos);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadElectronRuntimeExcludePackages() {
|
||||
const configPath = join(
|
||||
workspaceRoot,
|
||||
desktopRoot,
|
||||
"electron.vite.config.ts"
|
||||
);
|
||||
const sourceText = await readFile(configPath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
configPath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS
|
||||
);
|
||||
const excludes = new Set();
|
||||
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === "externalizeDepsPlugin" &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isObjectLiteralExpression(node.arguments[0])
|
||||
) {
|
||||
const optionsObject = node.arguments[0];
|
||||
for (const property of optionsObject.properties) {
|
||||
if (
|
||||
ts.isPropertyAssignment(property) &&
|
||||
getPropertyName(property.name) === "exclude" &&
|
||||
ts.isArrayLiteralExpression(property.initializer)
|
||||
) {
|
||||
for (const element of property.initializer.elements) {
|
||||
if (ts.isStringLiteralLike(element)) {
|
||||
excludes.add(element.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return excludes;
|
||||
}
|
||||
|
||||
function getPropertyName(nameNode) {
|
||||
if (!nameNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isIdentifier(nameNode) ||
|
||||
ts.isStringLiteralLike(nameNode) ||
|
||||
ts.isNumericLiteral(nameNode)
|
||||
) {
|
||||
return nameNode.text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNearestPackageInfo(filePath) {
|
||||
const normalizedPath = normalize(filePath);
|
||||
return (
|
||||
packageInfoByRoot.find((packageInfo) =>
|
||||
normalizedPath.startsWith(`${normalize(packageInfo.rootPath)}${sep}`)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspacePackageName(specifier) {
|
||||
const segments = specifier.split("/");
|
||||
return `${segments[0]}/${segments[1]}`;
|
||||
}
|
||||
|
||||
function getWorkspacePackageSubpath(specifier) {
|
||||
const segments = specifier.split("/");
|
||||
if (segments.length <= 2) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
return `./${segments.slice(2).join("/")}`;
|
||||
}
|
||||
|
||||
function getBarePackageName(specifier) {
|
||||
if (specifier.startsWith("@")) {
|
||||
const [scope, name] = specifier.split("/");
|
||||
return `${scope}/${name}`;
|
||||
}
|
||||
|
||||
return specifier.split("/")[0];
|
||||
}
|
||||
|
||||
function isReactPackage(packageName) {
|
||||
return packageName === "react" || packageName === "react-dom";
|
||||
}
|
||||
|
||||
function isTsxFile(filePath) {
|
||||
return extname(filePath) === ".tsx";
|
||||
}
|
||||
|
||||
function isWorkspaceLocalFile(filePath) {
|
||||
const relativePath = toWorkspaceRelative(filePath);
|
||||
return (
|
||||
!relativePath.startsWith("..") &&
|
||||
!relativePath.includes("/node_modules/") &&
|
||||
!ignoredDirectories.has(relativePath.split("/")[0] ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function buildUiLeakSuggestion({ packageInfo, specifier }) {
|
||||
const candidateSubpaths = listNonUiExportSubpaths(packageInfo);
|
||||
if (specifier === packageInfo.manifest.name && candidateSubpaths.length > 0) {
|
||||
return `Import a non-UI subpath instead of the root barrel. Candidate exports: ${candidateSubpaths
|
||||
.map((subpath) => `${packageInfo.manifest.name}${subpath.slice(1)}`)
|
||||
.join(
|
||||
", "
|
||||
)}. If the needed value is still only exposed from the root barrel, split it into a dedicated .ts service/model subpath.`;
|
||||
}
|
||||
|
||||
return "Keep Electron main/preload on non-React modules. Import a non-UI subpath, or split service/model exports into a dedicated .ts barrel that does not re-export React UI.";
|
||||
}
|
||||
|
||||
function buildExternalizedSourceSuggestion({ packageInfo, specifier }) {
|
||||
const candidateSubpaths = listNonUiExportSubpaths(packageInfo);
|
||||
const baseSuggestion = `Either add ${packageInfo.manifest.name} to apps/desktop/electron.vite.config.ts externalizeDepsPlugin({ exclude }), or export a JS runtime entry instead of raw source files for Electron runtime consumers.`;
|
||||
|
||||
if (specifier === packageInfo.manifest.name && candidateSubpaths.length > 0) {
|
||||
return `${baseSuggestion} If only a subset is needed, prefer a narrower subpath such as ${candidateSubpaths
|
||||
.map((subpath) => `${packageInfo.manifest.name}${subpath.slice(1)}`)
|
||||
.join(", ")}.`;
|
||||
}
|
||||
|
||||
return baseSuggestion;
|
||||
}
|
||||
|
||||
function listNonUiExportSubpaths(packageInfo) {
|
||||
const exportsField = packageInfo.manifest.exports;
|
||||
if (!exportsField || typeof exportsField !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(exportsField).filter((subpath) => {
|
||||
if (subpath === ".") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(
|
||||
subpath.includes("components") ||
|
||||
subpath.includes("icons") ||
|
||||
subpath.includes("styles") ||
|
||||
subpath.endsWith(".css")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function formatTrail(trail) {
|
||||
return trail.join(" -> ");
|
||||
}
|
||||
|
||||
function reportViolation(violation) {
|
||||
const key = `${violation.rule}:${violation.file}:${violation.line}:${violation.message}`;
|
||||
if (reportedViolationKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportedViolationKeys.add(key);
|
||||
violations.push(violation);
|
||||
}
|
||||
|
||||
function listStagedFiles() {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
encoding: "utf8"
|
||||
}
|
||||
);
|
||||
|
||||
return output
|
||||
.split("\n")
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toWorkspaceRelative(absolutePath) {
|
||||
return normalizeToPosix(relative(workspaceRoot, absolutePath));
|
||||
}
|
||||
|
||||
function normalizeToPosix(path) {
|
||||
return normalize(path).split(sep).join("/");
|
||||
}
|
||||
|
||||
async function loadTypeScriptModule() {
|
||||
const candidatePaths = [
|
||||
join(defaultWorkspaceRoot, "node_modules/typescript/lib/typescript.js"),
|
||||
join(
|
||||
defaultWorkspaceRoot,
|
||||
"apps/desktop/node_modules/typescript/lib/typescript.js"
|
||||
),
|
||||
join(
|
||||
defaultWorkspaceRoot,
|
||||
"packages/clients/tuttid-ts/node_modules/typescript/lib/typescript.js"
|
||||
),
|
||||
join(
|
||||
defaultWorkspaceRoot,
|
||||
"packages/workbench/snapshot/node_modules/typescript/lib/typescript.js"
|
||||
),
|
||||
join(
|
||||
defaultWorkspaceRoot,
|
||||
"packages/workbench/surface/node_modules/typescript/lib/typescript.js"
|
||||
),
|
||||
join(
|
||||
defaultWorkspaceRoot,
|
||||
"packages/workspace/file-manager/node_modules/typescript/lib/typescript.js"
|
||||
)
|
||||
];
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
if (!existsSync(candidatePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const module = await import(pathToFileURL(candidatePath).href);
|
||||
return module.default ?? module;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to locate a TypeScript runtime for check-electron-runtime-boundaries.mjs"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user