Files
tutti/tools/scripts/run-check-changed.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

808 lines
22 KiB
JavaScript

import { spawn, spawnSync } from "node:child_process";
import {
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
writeFileSync
} from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
buildLaneInputFingerprint,
laneFingerprintVersion,
LaneCacheError,
mergeLaneResults,
resolveRetryPushReady,
selectFailedOnlyLanes
} from "./run-check-changed-cache.mjs";
import {
buildGoLintLane,
buildGoTestLane,
discoverGoModuleRoots,
buildPackageTestCommand,
isBuiltinGenerateRequired,
isGlobalGoValidationRelevant,
isGoValidationRelevant,
resolveGoModuleRoot,
resolveGoValidationTargets,
selectGoLintModuleRoots
} from "./run-check-changed-targets.mjs";
import {
classifyChangedFiles,
createPackageManifestPackRelevance,
createRootManifestTestRelevance,
isPackagePackRelevantPath
} from "./change-classification.mjs";
import {
selectRepositoryCheckInputs,
selectRepositoryChecks
} from "./repository-checks.mjs";
import { formatFailureExcerpt } from "./run-validation-lanes.mjs";
import { resolveGolangciLintBinary } from "./golangci-lint-tool.mjs";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = join(scriptDirectory, "..", "..");
const golangciLintBinary = resolveGolangciLintBinary({ cwd: workspaceRoot });
const pnpmCommand = resolvePnpmCommand();
const pnpmShellCommand = formatCommand(pnpmCommand);
let maxParallel = 4;
let tailLines = 80;
let dryRun = false;
let failedOnly = false;
let pushReady = false;
let verbose = false;
let baseRef = resolveDefaultBaseRef();
const tmpRoot = join(workspaceRoot, ".tmp", "check-runs");
const latestSummaryPath = join(tmpRoot, "latest.json");
const packageInfos = loadPackageInfos();
export async function main() {
const previousSummary = failedOnly ? readLatestSummary() : null;
if (failedOnly && !previousSummary) {
console.log("check:changed found no previous run to reuse");
return;
}
pushReady = resolveRetryPushReady(pushReady, previousSummary);
const plannedLanes = buildChangedLanes();
if (plannedLanes.length === 0) {
console.log("check:changed found no changed files to validate");
return;
}
if (dryRun && !failedOnly) {
printPlan(plannedLanes);
return;
}
const currentLanes = plannedLanes.map((lane) => ({
...lane,
inputFingerprint: buildLaneInputFingerprint({
baseRef,
lane,
workspaceRoot
})
}));
const failedOnlySelection = previousSummary
? selectFailedOnlyLanes(currentLanes, previousSummary)
: null;
const lanesToRun = failedOnlySelection?.lanesToRun ?? currentLanes;
const reusedResults = failedOnlySelection?.reusedResults ?? [];
if (dryRun) {
printPlan(lanesToRun, reusedResults);
return;
}
if (lanesToRun.length === 0) {
console.log(
`check:changed found no failed or changed lanes; reused ${reusedResults.length} passed lane(s)`
);
return;
}
const runId = new Date().toISOString().replace(/[:.]/g, "-");
const runDirectory = join(tmpRoot, runId);
mkdirSync(runDirectory, { recursive: true });
if (verbose) {
console.log(`check:changed running ${lanesToRun.length} lane(s)`);
if (reusedResults.length > 0) {
console.log(
`check:changed reusing ${reusedResults.length} passed lane(s)`
);
}
console.log(`logs: ${relative(workspaceRoot, runDirectory)}`);
}
const startedAt = Date.now();
const executedResults = await runLanes(lanesToRun, runDirectory);
const results = mergeLaneResults(
currentLanes,
executedResults,
reusedResults
);
const durationMs = Date.now() - startedAt;
const summary = {
baseRef,
durationMs,
executedLaneCount: executedResults.length,
failedOnly,
laneFingerprintVersion,
pushReady,
reusedLaneCount: reusedResults.length,
runDirectory,
startedAt: new Date(startedAt).toISOString(),
tailLines,
results
};
writeFileSync(
join(runDirectory, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`
);
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(latestSummaryPath, `${JSON.stringify(summary, null, 2)}\n`);
const failures = results.filter((result) => result.exitCode !== 0);
printSummary(results, failures, durationMs, runDirectory);
if (failures.length > 0) {
process.exitCode = 1;
}
}
function buildChangedLanes() {
const changedFiles = listChangedFiles(baseRef);
const isPackageManifestPackRelevant = createPackageManifestPackRelevance({
baseRef,
root: workspaceRoot
});
const classification = classifyChangedFiles(changedFiles, {
isPackageManifestPackRelevant,
isRootManifestTestRelevant: createRootManifestTestRelevance({
baseRef,
root: workspaceRoot
})
});
const lanesByKey = new Map();
const addLane = (lane) => {
const normalizedInputs = Array.from(new Set(lane.inputFiles)).sort();
const existing = lanesByKey.get(lane.key);
if (existing) {
existing.inputFiles = Array.from(
new Set([...existing.inputFiles, ...normalizedInputs])
).sort();
} else {
lanesByKey.set(lane.key, {
...lane,
inputFiles: normalizedInputs
});
}
};
if (changedFiles.length === 0) {
return [];
}
addLane({
key: "diff-check",
label: "diff-check",
command: [
"bash",
"-lc",
`git diff --check ${shellQuote(baseRef)}...HEAD && git diff --check && git diff --cached --check`
],
inputFiles: changedFiles
});
const lintFiles = classification.runTs
? selectExistingLintFiles(changedFiles)
: [];
if (lintFiles.length > 0) {
addLane({
key: "lint:changed",
label: "lint:changed",
command: [
...pnpmCommand,
"exec",
"oxlint",
"--deny-warnings",
"--no-error-on-unmatched-pattern",
...lintFiles
],
inputFiles: lintFiles
});
}
for (const check of selectRepositoryChecks(changedFiles)) {
addLane({
key: check.key,
label: check.label,
command: [...pnpmCommand, "run", check.script],
inputFiles: selectRepositoryCheckInputs(check, changedFiles)
});
}
const goModuleRoots = classification.runGo
? discoverGoModuleRoots({ root: workspaceRoot })
: [];
const goValidationTargets = classification.runGo
? resolveGoValidationTargets(changedFiles, {
lintModuleRoots: selectGoLintModuleRoots(goModuleRoots),
moduleRoots: goModuleRoots
})
: null;
const forceBuiltinGenerate = isBuiltinGenerateRequired(changedFiles);
if (goValidationTargets) {
for (const [moduleRoot, targets] of goValidationTargets.lintByModule) {
const inputFiles = selectGoLaneInputs(
changedFiles,
moduleRoot,
forceBuiltinGenerate,
goModuleRoots
);
addLane({
...buildGoLintLane({
forceBuiltinGenerate,
golangciLintBinary,
moduleRoot,
pnpmCommand: pnpmShellCommand,
targets,
shellQuote,
workspaceRoot
}),
inputFiles
});
}
for (const [moduleRoot, targets] of goValidationTargets.testByModule) {
const inputFiles = selectGoLaneInputs(
changedFiles,
moduleRoot,
forceBuiltinGenerate,
goModuleRoots
);
addLane({
...buildGoTestLane({
forceBuiltinGenerate,
moduleRoot,
pnpmCommand: pnpmShellCommand,
shellQuote,
targets
}),
inputFiles
});
}
if (pushReady) {
addLane({
key: "build:go",
label: "build:go",
serialGroup: "tuttid-builtin-assets",
command: [...pnpmCommand, "run", "build:go"],
inputFiles: changedFiles.filter(isGoValidationRelevant)
});
}
}
const rootGlobalChange =
classification.runTs && changedFiles.some(isGlobalTypecheckRelevant);
if (rootGlobalChange) {
addLane({
key: "typecheck:all",
label: "typecheck:all",
command: [process.execPath, "./tools/scripts/run-typecheck.mjs"],
inputFiles: changedFiles.filter(isTypeScriptValidationInput)
});
}
for (const packageInfo of packageInfos) {
const packageFiles = changedFiles.filter((file) =>
file.startsWith(`${packageInfo.root}/`)
);
if (packageFiles.length === 0) {
continue;
}
const hasRelevantCode = packageFiles.some(isPackageValidationRelevant);
if (hasRelevantCode && packageInfo.scripts.typecheck && !rootGlobalChange) {
addLane({
key: `${packageInfo.name}:typecheck`,
label: `${packageInfo.name}:typecheck`,
command: [
process.execPath,
"./tools/scripts/run-tsgo-typecheck.mjs",
"--package-root",
packageInfo.root
],
inputFiles: packageFiles
});
}
if (hasRelevantCode && packageInfo.scripts.test) {
const command = buildPackageTestCommand({
baseRef,
packageFiles,
packageInfo,
pnpmCommand
});
if (command) {
addLane({
key: `${packageInfo.name}:test`,
label: `${packageInfo.name}:test`,
command,
inputFiles: packageFiles
});
}
}
if (
pushReady &&
!classification.packPackages.includes(packageInfo.name) &&
packageInfo.scripts.build &&
packageFiles.some((file) =>
isPackageBuildRelevant(file, isPackageManifestPackRelevant)
)
) {
addLane({
key: `${packageInfo.name}:build`,
label: `${packageInfo.name}:build`,
command: [...pnpmCommand, "--filter", packageInfo.name, "build"],
inputFiles: packageFiles
});
}
}
if (pushReady && classification.runPack) {
const packageArgs = classification.packAll
? []
: ["--", "--packages-json", JSON.stringify(classification.packPackages)];
addLane({
key: "pack:npm",
label: "npm package pack",
command: [...pnpmCommand, "run", "release:pack:check", ...packageArgs],
inputFiles: changedFiles
});
}
return Array.from(lanesByKey.values());
}
function readLatestSummary() {
if (!existsSync(latestSummaryPath)) {
return null;
}
return JSON.parse(readFileSync(latestSummaryPath, "utf8"));
}
export async function runLanes(inputLanes, runDirectory) {
const results = [];
const serialGroups = new Map();
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(maxParallel, inputLanes.length));
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < inputLanes.length) {
const laneIndex = nextIndex++;
const lane = inputLanes[laneIndex];
results.push(
await runLaneInSerialGroup(
lane,
laneIndex,
runDirectory,
serialGroups
)
);
}
})
);
return results.sort((left, right) => left.index - right.index);
}
function runLaneInSerialGroup(lane, index, runDirectory, serialGroups) {
if (!lane.serialGroup) {
return runLane(lane, index, runDirectory);
}
const previous = serialGroups.get(lane.serialGroup) ?? Promise.resolve();
const current = previous.then(() => runLane(lane, index, runDirectory));
serialGroups.set(
lane.serialGroup,
current.catch(() => undefined)
);
return current;
}
function runLane(lane, index, runDirectory) {
const logPath = join(runDirectory, `${sanitizeFileName(lane.key)}.log`);
const logStream = createWriteStream(logPath, { flags: "w" });
const startedAt = Date.now();
if (verbose) {
console.log(`[${lane.label}] started`);
}
return new Promise((resolve) => {
const [command, ...args] = lane.command;
const useWindowsShellShim =
process.platform === "win32" && /\.(?:cmd|bat)$/iu.test(command);
const child = spawn(command, args, {
cwd: workspaceRoot,
env: process.env,
shell: useWindowsShellShim,
stdio: ["ignore", "pipe", "pipe"]
});
logStream.write(`$ ${formatCommand(lane.command)}\n\n`);
child.stdout.on("data", (chunk) => logStream.write(chunk));
child.stderr.on("data", (chunk) => logStream.write(chunk));
child.on("error", (error) => {
logStream.write(`\n[runner] ${error.message}\n`);
logStream.end();
resolve(buildLaneResult(lane, index, logPath, startedAt, 1));
});
child.on("close", (code) => {
logStream.end();
const exitCode = typeof code === "number" ? code : 1;
const result = buildLaneResult(lane, index, logPath, startedAt, exitCode);
if (verbose) {
console.log(
`[${lane.label}] ${exitCode === 0 ? "passed" : "failed"} ${formatDuration(result.durationMs)}`
);
}
resolve(result);
});
});
}
function buildLaneResult(lane, index, logPath, startedAt, exitCode) {
return {
command: lane.command,
durationMs: Date.now() - startedAt,
exitCode,
index,
inputFiles: lane.inputFiles,
inputFingerprint: lane.inputFingerprint,
key: lane.key,
label: lane.label,
logPath,
logPathRelative: relative(workspaceRoot, logPath),
reused: false
};
}
function printPlan(inputLanes, reusedResults = []) {
const reusedSuffix =
reusedResults.length > 0 ? `, ${reusedResults.length} reused` : "";
console.log(
`check:changed plan (${inputLanes.length} to run${reusedSuffix})`
);
for (const lane of inputLanes) {
console.log(`- ${lane.label}: ${formatCommand(lane.command)}`);
}
for (const result of reusedResults) {
console.log(`- ${result.label}: reuse passed result`);
}
}
export function printSummary(results, failures, durationMs, runDirectory) {
const reusedCount = results.filter((result) => result.reused).length;
const runCount = results.length - reusedCount;
const reuseSuffix =
reusedCount > 0 ? ` (${runCount} run, ${reusedCount} reused)` : "";
if (failures.length === 0) {
console.log(
`check:changed passed ${results.length} lane(s) in ${formatDuration(durationMs)}${reuseSuffix}`
);
return;
}
console.error(
`check:changed failed ${failures.length}/${results.length} lane(s) in ${formatDuration(durationMs)}${reuseSuffix}`
);
for (const failure of failures) {
const output = failureExcerpt(failure.logPath, tailLines);
const header = output.truncated
? `${failure.label} ${output.label} (full log: ${failure.logPathRelative})`
: `${failure.label} ${output.label}`;
console.error(`\n--- ${header} ---`);
console.error(output.text);
}
console.error(
`\nfull logs: ${relative(workspaceRoot, runDirectory)}\nRerun failed lanes with: pnpm check:changed -- --failed-only`
);
}
function listChangedFiles(ref) {
const files = new Set();
for (const args of [
["diff", "--name-only", `${ref}...HEAD`],
["diff", "--name-only"],
["diff", "--cached", "--name-only"],
["ls-files", "--others", "--exclude-standard"]
]) {
for (const file of gitLines(args)) {
files.add(file);
}
}
return Array.from(files).sort();
}
function loadPackageInfos() {
return gitLines([
"ls-files",
"apps/*/package.json",
"packages/*/*/package.json",
"services/tuttid/builtin-apps/*/package.json",
"tools/fixtures/*/package.json"
])
.map((packageJsonPath) => {
const packageJson = JSON.parse(
readFileSync(join(workspaceRoot, packageJsonPath), "utf8")
);
return {
name: packageJson.name,
root: dirname(packageJsonPath).replaceAll("\\", "/"),
scripts: packageJson.scripts ?? {}
};
})
.filter((packageInfo) => packageInfo.name)
.sort((left, right) => right.root.length - left.root.length);
}
function gitLines(args) {
const result = spawnSync("git", args, {
cwd: workspaceRoot,
encoding: "utf8"
});
if (result.status !== 0) {
return [];
}
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function resolveDefaultBaseRef() {
for (const candidate of ["origin/main", "main"]) {
const result = spawnSync("git", ["rev-parse", "--verify", candidate], {
cwd: workspaceRoot,
encoding: "utf8"
});
if (result.status === 0) {
return candidate;
}
}
return "HEAD";
}
function resolvePnpmCommand() {
const fallback = [process.platform === "win32" ? "pnpm.cmd" : "pnpm"];
try {
const packageJson = JSON.parse(
readFileSync(join(workspaceRoot, "package.json"), "utf8")
);
const match = /^pnpm@(.+)$/u.exec(String(packageJson.packageManager ?? ""));
if (!match) {
return fallback;
}
return [
process.platform === "win32" ? "corepack.cmd" : "corepack",
`pnpm@${match[1]}`
];
} catch {
return fallback;
}
}
export function parseCliArgs(inputArgs) {
const options = {
baseRef: null,
dryRun: false,
failedOnly: false,
maxParallel: 4,
pushReady: false,
tailLines: 80,
verbose: false
};
const args = inputArgs.filter((arg) => arg !== "--");
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--dry-run":
options.dryRun = true;
break;
case "--failed-only":
options.failedOnly = true;
break;
case "--push-ready":
options.pushReady = true;
break;
case "--verbose":
options.verbose = true;
break;
case "--base":
options.baseRef = readCliValue(args, ++index, arg);
break;
case "--max-parallel":
options.maxParallel = readPositiveIntegerCliValue(args, ++index, arg);
break;
case "--tail-lines":
options.tailLines = readPositiveIntegerCliValue(args, ++index, arg);
break;
default:
throw new UserFacingError(`unknown option: ${arg}`);
}
}
return options;
}
function readCliValue(args, index, option) {
const value = args[index];
if (!value || value.startsWith("--")) {
throw new UserFacingError(`${option} requires a value`);
}
return value;
}
function readPositiveIntegerCliValue(args, index, option) {
const value = readCliValue(args, index, option);
if (!/^[1-9]\d*$/u.test(value)) {
throw new UserFacingError(`${option} requires a positive integer`);
}
return Number.parseInt(value, 10);
}
function applyCliOptions(options) {
maxParallel = options.maxParallel;
tailLines = options.tailLines;
dryRun = options.dryRun;
failedOnly = options.failedOnly;
pushReady = options.pushReady;
verbose = options.verbose;
baseRef = options.baseRef ?? resolveDefaultBaseRef();
}
function isLintableCodeFile(file) {
return /\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$/u.test(file);
}
export function selectExistingLintFiles(
changedFiles,
fileExists = fileExistsWithinWorkspace
) {
return changedFiles.filter(
(file) => isLintableCodeFile(file) && fileExists(file)
);
}
function isTestFile(file) {
return /\.(?:test|spec)\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/u.test(file);
}
function isPackageValidationRelevant(file) {
return (
isLintableCodeFile(file) ||
isTestFile(file) ||
/(?:^|\/)(package\.json|tsconfig[^/]*\.json|vitest\.config\.ts|tsup\.config\.ts)$/u.test(
file
)
);
}
export function isPackageBuildRelevant(file, isPackageManifestPackRelevant) {
if (!isPackagePackRelevantPath(file)) {
return false;
}
if (
/(?:^|\/)package\.json$/u.test(file) &&
!isPackageManifestPackRelevant(file)
) {
return false;
}
return (
isPackageValidationRelevant(file) ||
/(?:^|\/)(assets|public|style|styles)\//u.test(file) ||
/(?:electron\.vite\.config\.ts|vite\.web\.config\.mjs)$/u.test(file)
);
}
function isGlobalTypecheckRelevant(file) {
return [
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"tsconfig.json"
].includes(file);
}
function isTypeScriptValidationInput(file) {
return (
isPackageValidationRelevant(file) ||
["pnpm-lock.yaml", "pnpm-workspace.yaml"].includes(file) ||
file.startsWith("packages/configs/")
);
}
function selectGoLaneInputs(
changedFiles,
moduleRoot,
forceBuiltinGenerate,
moduleRoots
) {
return changedFiles.filter(
(file) =>
(isGoValidationRelevant(file) &&
(isGlobalGoValidationRelevant(file) ||
resolveGoModuleRoot(file, moduleRoots) === moduleRoot)) ||
(forceBuiltinGenerate &&
moduleRoot === "services/tuttid" &&
file.startsWith("services/tuttid/builtin-apps/tutti-onboarding/"))
);
}
function fileExistsWithinWorkspace(file) {
return existsSync(join(workspaceRoot, file));
}
function formatCommand(command) {
return command.map(shellQuote).join(" ");
}
function shellQuote(value) {
if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
function sanitizeFileName(value) {
return value.replace(/[^A-Za-z0-9_.-]+/gu, "-");
}
function formatDuration(durationMs) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
function failureExcerpt(path, lineCount) {
if (!existsSync(path)) {
return { label: "full log", text: "", truncated: false };
}
const excerpt = formatFailureExcerpt(readFileSync(path, "utf8"), lineCount);
return {
...excerpt,
label: excerpt.truncated
? `failure excerpt last ${lineCount} lines`
: "failure output"
};
}
class UserFacingError extends Error {}
const currentPath = fileURLToPath(import.meta.url);
if (process.argv[1] && resolve(process.argv[1]) === currentPath) {
Promise.resolve()
.then(() => applyCliOptions(parseCliArgs(process.argv.slice(2))))
.then(() => main())
.catch((error) => {
console.error(
error instanceof UserFacingError || error instanceof LaneCacheError
? `check:changed: ${error.message}`
: error instanceof Error
? (error.stack ?? error.message)
: error
);
process.exitCode = 1;
});
}