Files
tutti/tools/scripts/change-classification.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

561 lines
16 KiB
JavaScript

import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { createIsolatedGitEnvironment } from "./git-environment.mjs";
import { selectedRepositoryCheckGroups } from "./repository-checks.mjs";
import { isGoValidationRelevant } from "./run-check-changed-targets.mjs";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDirectory, "../..");
export function classifyChangedFiles(
changedFiles,
{
isPackageManifestPackRelevant = () => true,
isRootManifestTestRelevant = () => true,
releasePackages = discoverReleasePackages(),
workspacePackages = discoverWorkspacePackages()
} = {}
) {
const normalizedFiles = changedFiles.map((file) =>
file.replaceAll("\\", "/")
);
const groups = selectedRepositoryCheckGroups(normalizedFiles);
const packSelection = selectPackPackages(normalizedFiles, {
isPackageManifestPackRelevant,
releasePackages
});
const testSelection = selectTypeScriptTestPackages(normalizedFiles, {
isRootManifestTestRelevant,
workspacePackages
});
const testShards = buildTypeScriptTestShards(testSelection.packageNames);
return {
buildWindowsInstaller: normalizedFiles.some(
isWindowsDesktopInstallerRelevant
),
packAll: packSelection.packAll,
packPackages: packSelection.packageNames,
runAgentSessionReplay: normalizedFiles.some(isAgentSessionReplayRelevant),
runBoundaries: groups.has("boundaries"),
runContracts: groups.has("contracts"),
runGenerated: groups.has("generated"),
runGo: normalizedFiles.some(isGoValidationRelevant),
runPack: packSelection.packAll || packSelection.packageNames.length > 0,
runTs: normalizedFiles.some(isTypeScriptRelevant),
runTsTests: testSelection.testAll || testSelection.packageNames.length > 0,
testAll: testSelection.testAll,
testPackages: testSelection.packageNames,
testShards
};
}
export function discoverWorkspacePackages(root = workspaceRoot) {
const packageRoots = [
...readChildPackageRoots(join(root, "apps")),
...readGrandchildPackageRoots(join(root, "packages")),
...readChildPackageRoots(join(root, "services/tuttid/builtin-apps")),
...readChildPackageRoots(join(root, "tools/fixtures"))
];
const packages = [];
for (const packageRoot of packageRoots) {
try {
const manifest = JSON.parse(
readFileSync(join(packageRoot, "package.json"), "utf8")
);
if (typeof manifest.name === "string") {
packages.push({
hasTests: typeof manifest.scripts?.test === "string",
manifest,
name: manifest.name,
root: packageRoot.slice(root.length + 1).replaceAll("\\", "/")
});
}
} catch {
// Non-package directories are outside the workspace package surface.
}
}
const workspaceNames = new Set(
packages.map((packageConfig) => packageConfig.name)
);
return packages
.map(({ manifest, ...packageConfig }) => ({
...packageConfig,
workspaceDependencies: Object.keys({
...manifest.dependencies,
...manifest.devDependencies,
...manifest.optionalDependencies,
...manifest.peerDependencies
})
.filter((name) => workspaceNames.has(name))
.sort()
}))
.sort((left, right) => left.root.localeCompare(right.root));
}
export function discoverReleasePackages(root = workspaceRoot) {
const packagesRoot = join(root, "packages");
const packages = [];
for (const group of readDirectories(packagesRoot)) {
const groupRoot = join(packagesRoot, group);
for (const packageName of readDirectories(groupRoot)) {
const packageRoot = join(groupRoot, packageName);
const manifestPath = join(packageRoot, "package.json");
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (
manifest.private === false &&
manifest.publishConfig?.access === "public"
) {
packages.push({
name: manifest.name,
root: packageRoot.slice(root.length + 1).replaceAll("\\", "/")
});
}
} catch {
// Non-package directories are outside the release surface.
}
}
}
return packages.sort((left, right) => left.root.localeCompare(right.root));
}
export function formatClassificationOutputs(classification) {
return [
["build_windows_installer", classification.buildWindowsInstaller],
["pack_all", classification.packAll],
["pack_packages", JSON.stringify(classification.packPackages)],
["run_agent_session_replay", classification.runAgentSessionReplay],
["run_boundaries", classification.runBoundaries],
["run_contracts", classification.runContracts],
["run_generated", classification.runGenerated],
["run_go", classification.runGo],
["run_pack", classification.runPack],
["run_ts", classification.runTs],
["run_ts_tests", classification.runTsTests],
["test_all", classification.testAll],
["test_packages", JSON.stringify(classification.testPackages)],
["test_shards", JSON.stringify(classification.testShards)]
]
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
export function isWindowsDesktopInstallerRelevant(file) {
return (
["package.json", "pnpm-lock.yaml"].includes(file) ||
file === "config/tutti.app-runtime.lock.json" ||
file === "apps/desktop/package.json" ||
file.startsWith("apps/desktop/build/") ||
file.startsWith("apps/desktop/scripts/") ||
file.startsWith("services/tuttid/builtin-apps/")
);
}
export function buildTypeScriptTestShards(packageNames, maximum = 3) {
const shardCount = Math.max(1, Math.min(maximum, packageNames.length));
return Array.from(
{ length: shardCount },
(_, index) => `${index + 1}/${shardCount}`
);
}
export function isAgentSessionReplayRelevant(file) {
return (
file === "package.json" ||
file === "pnpm-lock.yaml" ||
file === "apps/desktop/package.json" ||
file === "apps/desktop/electron.vite.config.ts" ||
file === "config/tutti.defaults.json" ||
file.startsWith("tools/scripts/run-agent-session-replay") ||
file === "tools/scripts/fixtures/agent-gui-performance/cursor-agent" ||
file.startsWith("packages/agent/session-replay/") ||
file.startsWith("packages/agent/daemon/providerregistry/") ||
file.startsWith("packages/agent/daemon/runtime/") ||
file.startsWith(
"apps/desktop/src/renderer/src/features/agent-session-replay/"
) ||
file.startsWith("apps/desktop/src/main/agentSessionReplay") ||
file.startsWith("apps/desktop/src/main/daemon/") ||
file.startsWith("apps/desktop/src/preload/") ||
file === "apps/desktop/src/main/desktopAppLifecycle.ts" ||
file === "apps/desktop/src/shared/contracts/ipc.ts" ||
file.startsWith("services/tuttid/service/agentsessionreplay/") ||
file.startsWith("services/tuttid/data/agentsessionreplay/") ||
/services\/tuttid\/(?:agent_process_cassette|agent_replay_composition|agent_session_recording)\S*\.go$/u.test(
file
) ||
/services\/tuttid\/api\/\S*agent_session_(?:recording|replay)\S*\.go$/u.test(
file
) ||
/services\/tuttid\/data\/workspace\/\S*agent_session_(?:fixture|replay)\S*\.go$/u.test(
file
)
);
}
function isTypeScriptRelevant(file) {
return (
/\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$/u.test(file) ||
/(?:^|\/)(?:package\.json|tsconfig[^/]*\.json)$/u.test(file) ||
["pnpm-lock.yaml", "pnpm-workspace.yaml"].includes(file) ||
file.startsWith("packages/configs/")
);
}
export function selectTypeScriptTestPackages(
files,
{ isRootManifestTestRelevant, workspacePackages }
) {
const testPackages = workspacePackages.filter(
(packageConfig) => packageConfig.hasTests
);
const testAll =
files.some(isGlobalTypeScriptTestRelevant) ||
(files.includes("package.json") && isRootManifestTestRelevant());
if (testAll) {
return {
packageNames: testPackages.map((packageConfig) => packageConfig.name),
testAll: true
};
}
const affectedPackageNames = new Set();
for (const file of files) {
const packageConfig = workspacePackages.find(
({ root }) => file === root || file.startsWith(`${root}/`)
);
if (packageConfig) {
affectedPackageNames.add(packageConfig.name);
continue;
}
if (
/^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|services\/tuttid\/builtin-apps\/[^/]+|tools\/fixtures\/[^/]+)\/package\.json$/u.test(
file
)
) {
return {
packageNames: testPackages.map((testPackage) => testPackage.name),
testAll: true
};
}
}
addTransitiveWorkspaceDependents(affectedPackageNames, workspacePackages);
return {
packageNames: testPackages
.filter((packageConfig) => affectedPackageNames.has(packageConfig.name))
.map((packageConfig) => packageConfig.name),
testAll: false
};
}
function addTransitiveWorkspaceDependents(affectedPackageNames, packages) {
let changed = true;
while (changed) {
changed = false;
for (const packageConfig of packages) {
if (
!affectedPackageNames.has(packageConfig.name) &&
(packageConfig.workspaceDependencies ?? []).some((name) =>
affectedPackageNames.has(name)
)
) {
affectedPackageNames.add(packageConfig.name);
changed = true;
}
}
}
}
function isGlobalTypeScriptTestRelevant(file) {
return (
[".node-version", "pnpm-lock.yaml", "pnpm-workspace.yaml"].includes(file) ||
file.startsWith("packages/configs/") ||
[
"tools/scripts/run-validation-lanes.mjs",
"tools/scripts/run-workspace-tests.mjs"
].includes(file)
);
}
function selectPackPackages(
files,
{ isPackageManifestPackRelevant, releasePackages }
) {
const packAll = files.some((file) => isGlobalPackRelevant(file));
if (packAll) {
return {
packAll: true,
packageNames: releasePackages.map((packageConfig) => packageConfig.name)
};
}
const packageNames = new Set();
for (const file of files) {
const packageConfig = releasePackages.find(
({ root }) => file === root || file.startsWith(`${root}/`)
);
if (!packageConfig) {
if (/^packages\/[^/]+\/[^/]+\/package\.json$/u.test(file)) {
return {
packAll: true,
packageNames: releasePackages.map(
(releasePackage) => releasePackage.name
)
};
}
continue;
}
const relativePath = file.slice(packageConfig.root.length + 1);
if (relativePath === "package.json") {
if (!isPackageManifestPackRelevant(file)) {
continue;
}
return {
packAll: true,
packageNames: releasePackages.map(
(releasePackage) => releasePackage.name
)
};
}
if (isPackagePackRelevantPath(relativePath)) {
packageNames.add(packageConfig.name);
}
}
return {
packAll: false,
packageNames: [...packageNames].sort()
};
}
function isGlobalPackRelevant(file) {
return (
["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"].includes(file) ||
file === ".changeset/config.json" ||
file === "tools/scripts/build-npm-packages.mjs" ||
file === "tools/scripts/check-package-packs.mjs" ||
file === "tools/scripts/npm-release-packages.mjs" ||
file === "tools/scripts/run-package-pack-check.mjs"
);
}
export function isPackagePackRelevantPath(file) {
return !(
/\.(?:test|spec)\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/u.test(file) ||
/^vitest(?:\.[^/]+)*\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/u.test(
basename(file)
)
);
}
export function createPackageManifestPackRelevance({
baseRef,
headRef = "HEAD",
root = workspaceRoot
}) {
const cache = new Map();
return (file) => {
if (!cache.has(file)) {
cache.set(
file,
isPackageManifestPackRelevant(baseRef, headRef, file, root)
);
}
return cache.get(file);
};
}
export function createRootManifestTestRelevance({
baseRef,
headRef = "HEAD",
root = workspaceRoot
}) {
return () => {
try {
const before = normalizedManifestAtRef(
baseRef,
"package.json",
root,
testRelevantRootManifest
);
const candidates = [
normalizedManifestAtRef(
headRef,
"package.json",
root,
testRelevantRootManifest
)
];
if (headRef === "HEAD") {
candidates.push(
JSON.stringify(
testRelevantRootManifest(
JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
)
)
);
}
return candidates.some((candidate) => candidate !== before);
} catch {
return true;
}
};
}
function isPackageManifestPackRelevant(baseRef, headRef, file, root) {
try {
const before = normalizedManifestAtRef(baseRef, file, root);
const candidates = [normalizedManifestAtRef(headRef, file, root)];
if (headRef === "HEAD") {
candidates.push(
JSON.stringify(
withoutTestScripts(JSON.parse(readFileSync(join(root, file), "utf8")))
)
);
}
return candidates.some((candidate) => candidate !== before);
} catch {
return true;
}
}
function normalizedManifestAtRef(
ref,
file,
root,
normalize = withoutTestScripts
) {
const manifest = JSON.parse(
execFileSync("git", ["show", `${ref}:${file}`], {
cwd: root,
encoding: "utf8",
env: createIsolatedGitEnvironment(root)
})
);
return JSON.stringify(normalize(manifest));
}
function withoutTestScripts(manifest) {
const normalized = structuredClone(manifest);
if (!normalized.scripts || typeof normalized.scripts !== "object") {
return normalized;
}
for (const name of Object.keys(normalized.scripts)) {
if (/^(?:pre|post)?test(?::|$)/u.test(name)) {
delete normalized.scripts[name];
}
}
if (Object.keys(normalized.scripts).length === 0) {
delete normalized.scripts;
}
return normalized;
}
function testRelevantRootManifest(manifest) {
const relevant = {};
for (const key of [
"dependencies",
"devDependencies",
"engines",
"optionalDependencies",
"packageManager",
"peerDependencies",
"pnpm",
"workspaces"
]) {
if (manifest[key] !== undefined) {
relevant[key] = manifest[key];
}
}
const scripts = Object.fromEntries(
Object.entries(manifest.scripts ?? {}).filter(([name]) =>
/^(?:preinstall|install|postinstall|prepare|(?:pre|post)?test:ts(?::.*)?)$/u.test(
name
)
)
);
if (Object.keys(scripts).length > 0) {
relevant.scripts = scripts;
}
return relevant;
}
function readChildPackageRoots(root) {
return readDirectories(root).map((name) => join(root, name));
}
function readGrandchildPackageRoots(root) {
return readDirectories(root).flatMap((group) =>
readChildPackageRoots(join(root, group))
);
}
function readDirectories(root) {
try {
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
return [];
}
}
function readOption(name) {
const index = process.argv.indexOf(name);
return index === -1 ? null : (process.argv[index + 1] ?? null);
}
function isMainModule() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
);
}
if (isMainModule()) {
const base = readOption("--base");
if (!base) {
throw new Error("--base is required");
}
const head = readOption("--head") ?? "HEAD";
const changedFiles = execFileSync(
"git",
["diff", "--name-only", `${base}...${head}`],
{ cwd: workspaceRoot, encoding: "utf8" }
)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const output = formatClassificationOutputs(
classifyChangedFiles(changedFiles, {
isPackageManifestPackRelevant: createPackageManifestPackRelevance({
baseRef: base,
headRef: head
}),
isRootManifestTestRelevant: createRootManifestTestRelevance({
baseRef: base,
headRef: head
})
})
);
const githubOutput = readOption("--github-output");
if (githubOutput) {
writeFileSync(githubOutput, `${output}\n`, { flag: "a" });
}
console.log(output);
}