Files
openclaw/scripts/check-changed.mjs
2026-07-31 22:28:20 +08:00

1162 lines
39 KiB
JavaScript

// Runs the changed-file check lanes selected by `scripts/changed-lanes.mjs`.
import { execFileSync } from "node:child_process";
import {
accessSync,
chmodSync,
constants,
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import {
LIVE_DOCKER_AUTH_SHELL_TARGETS,
detectChangedLanesForPaths,
hasDeadcodeScannedSource,
listChangedPathsFromGit,
listStagedChangedPaths,
} from "./changed-lanes.mjs";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs";
import { printTimingSummary } from "./lib/check-timing-summary.mjs";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import {
acquireLocalHeavyCheckLockSync,
resolveLocalHeavyCheckEnv,
} from "./lib/local-heavy-check-runtime.mjs";
import { runManagedCommand } from "./lib/managed-child-process.mjs";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mjs";
const NPM_LOCK_POLICY_PATH_RE =
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|scripts\/generate-npm-package-lock\.mjs|(?:extensions|packages)\/[^/]+(?:\/.*)?\/package\.json)$/u;
const PROMPT_SNAPSHOT_CHECK_PATH_RE =
/^(?:scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.ts|sync-codex-model-prompt-fixture\.ts)|test\/helpers\/agents\/(?:happy-path-prompt-snapshots|prompt-snapshot-paths)\.ts|test\/fixtures\/agents\/prompt-snapshots\/.+)$/u;
const PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE =
/^(?:scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.ts|sync-codex-model-prompt-fixture\.ts)|test\/helpers\/agents\/(?:happy-path-prompt-snapshots|prompt-snapshot-paths)\.ts|test\/fixtures\/agents\/prompt-snapshots\/codex-model-catalog\/.+)$/u;
const RUNTIME_SIDECAR_BASELINE_PATH_RE =
/^(?:scripts\/generate-runtime-sidecar-paths-baseline\.ts|scripts\/lib\/bundled-runtime-sidecar-paths\.json|src\/plugins\/runtime-sidecar-paths(?:-baseline)?\.ts)$/u;
const SQLITE_SESSION_SCHEMA_BASELINE_PATH_RE =
/^(?:src\/state\/openclaw-agent-schema\.sql|scripts\/(?:generate-sqlite-session-schema-baseline\.ts|lib\/sqlite-session-schema-baseline\.ts)|test\/scripts\/sqlite-session-schema-baseline\.test\.ts|docs\/\.generated\/sqlite-session-transcript-schema-baseline\.sha256)$/u;
const PLUGIN_SDK_API_BASELINE_PATH_RE =
/^(?:src\/|packages\/|extensions\/|pnpm-lock\.yaml$|tsconfig\.json$|scripts\/(?:generate-plugin-sdk-api-baseline\.ts|lib\/plugin-sdk-(?:doc-metadata\.ts|entries\.mjs|entrypoints\.json|private-local-only-subpaths\.json))|docs\/\.generated\/plugin-sdk-api-baseline\.sha256$)/u;
const PLUGIN_SDK_SURFACE_PATH_RE =
/^(?:package\.json$|src\/plugin-sdk\/|scripts\/(?:plugin-sdk-surface-report\.mjs|sync-plugin-sdk-exports\.mjs|lib\/plugin-sdk-(?:declaration-budget\.mjs|deprecated-barrel-subpaths\.json|deprecated-public-subpaths\.json|entries\.mjs|entrypoints\.json|private-local-only-subpaths\.json)))/u;
const DEPRECATION_HYGIENE_PATH_RE =
/^(?:package\.json$|src\/|extensions\/|packages\/|scripts\/(?:check-deprecated-api-usage\.mjs$|plugin-boundary-report\.ts$|lib\/plugin-sdk))/u;
const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE =
/^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u;
const CONTROL_UI_I18N_VERIFY_PATH_RE =
/^(?:package\.json$|ui\/src\/|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
const SCRIPTS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.scripts.json";
const TARGETED_LINT_PATH_LIMIT = 8;
const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
const LINTABLE_EXTENSION_PATH_RE = /^extensions\/[^/]+\/.+\.[cm]?[jt]sx?$/u;
const LINTABLE_SCRIPT_PATH_RE = /^scripts\/.+\.[cm]?[jt]sx?$/u;
const MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE = /^(?:docs\/|README\.md$|.*\.mdx?$)/u;
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$/u;
const EXTENSION_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:test\/scripts\/|\.github\/workflows\/ci\.yml$)/u;
const SCRIPT_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:test\/scripts\/|\.github\/workflows\/ci\.yml$)/u;
const ANDROID_VERSION_SYNC_PATHS = new Set([
"apps/android/CHANGELOG.md",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
"apps/android/version.json",
]);
const MACOS_APP_CI_PATH_RE =
/^(?:apps\/(?:macos|macos-mlx-tts|shared|swabble)\/|Swabble\/|scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh$|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh$|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts$)/u;
let corepackPnpmShimDir;
let corepackPnpmShimCleanupRegistered = false;
let cachedGeneratedExtensionAssetPaths;
let npmLockPackageDirsForChangedPaths;
async function ensureChangedCheckRuntimeDependencies(paths) {
if (!shouldRunNpmLockGuard(paths) || npmLockPackageDirsForChangedPaths) {
return;
}
({ npmLockPackageDirsForChangedPaths } = await import("./generate-npm-package-lock.mjs"));
}
// Imported consumers expect the synchronous planning API. Direct CLI execution
// delays package-backed imports until after lane and remote-routing selection.
if (!isDirectRun()) {
await ensureChangedCheckRuntimeDependencies(["package.json"]);
}
export function createChangedCheckChildEnv(baseEnv = process.env) {
const resolvedBaseEnv = resolveLocalHeavyCheckEnv(baseEnv);
return {
...resolvedBaseEnv,
OPENCLAW_OXLINT_SKIP_LOCK: "1",
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
};
}
function isTruthyEnvFlag(value) {
const normalized = String(value ?? "")
.trim()
.toLowerCase();
return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
}
function hasAndroidVersionSyncPath(paths) {
return paths.some((changedPath) =>
ANDROID_VERSION_SYNC_PATHS.has(normalizeChangedPath(changedPath)),
);
}
function hasMacosAppCiPath(paths) {
return paths.some((changedPath) => MACOS_APP_CI_PATH_RE.test(normalizeChangedPath(changedPath)));
}
function executableExistsOnPath(command, env = process.env) {
const pathValue = env.PATH ?? env.Path ?? "";
const pathExts =
process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
for (const searchPath of pathValue.split(path.delimiter)) {
if (!searchPath) {
continue;
}
for (const ext of pathExts) {
try {
accessSync(path.join(searchPath, `${command}${ext}`), constants.X_OK);
return true;
} catch {
continue;
}
}
}
return false;
}
function shouldSkipAppLintForMissingSwiftlint(options = {}) {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const swiftlintAvailable = options.swiftlintAvailable ?? executableExistsOnPath("swiftlint", env);
return platform !== "darwin" && !swiftlintAvailable;
}
export function changedCheckLocalDependenciesReady(cwd = process.cwd()) {
const nodeModules = path.join(cwd, "node_modules");
return (
existsSync(path.join(nodeModules, ".modules.yaml")) &&
existsSync(path.join(nodeModules, ".bin", "oxfmt")) &&
existsSync(path.join(nodeModules, "typescript", "package.json"))
);
}
export function changedCheckRequiresRemote(result) {
if (!result || result.paths.length === 0) {
return false;
}
if (
shouldRunSqliteSessionSchemaBaselineCheck(result.paths) ||
shouldRunPluginSdkApiBaselineCheck(result.paths)
) {
return true;
}
if (result.docsOnly) {
return false;
}
return Object.entries(result.lanes).some(
([lane, enabled]) => enabled && lane !== "docs" && lane !== "releaseMetadata",
);
}
export function shouldDelegateChangedCheckToCrabbox(argv = [], env = process.env, options = {}) {
if (isTruthyEnvFlag(env.OPENCLAW_CHECK_CHANGED_REMOTE_CHILD)) {
return false;
}
if (isTruthyEnvFlag(env.CI) || isTruthyEnvFlag(env.GITHUB_ACTIONS)) {
return false;
}
if (argv.includes("--dry-run")) {
return false;
}
const result = options.result;
if (!result) {
return true;
}
if (result.paths.length === 0) {
return false;
}
if (isTruthyEnvFlag(env.OPENCLAW_TESTBOX)) {
return true;
}
// Release metadata plans diff the supplied commits after classification. A missing
// ref needs the hydrated remote checkout even when the explicit path itself is cheap.
if (result.lanes.releaseMetadata && options.diffRefsReady === false) {
return true;
}
return (
changedCheckRequiresRemote(result) ||
!changedCheckLocalDependenciesReady(options.cwd ?? process.cwd())
);
}
function changedCheckDiffRefsReady({ base, head, cwd = process.cwd() }) {
for (const ref of [base, head]) {
try {
execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
cwd,
stdio: "ignore",
});
} catch {
return false;
}
}
return true;
}
export function buildChangedCheckCrabboxArgs(argv = [], options = {}) {
const delegatedArgv = buildDelegatedChangedCheckArgv(argv, options);
return [
"scripts/crabbox-wrapper.mjs",
"run",
"--workload",
"ci-fast",
// Keep workload-routed calls provider-neutral. Blacksmith reads its workflow
// defaults from .crabbox.yaml; cloud fallbacks must not receive its flags.
"--idle-timeout",
"90m",
"--ttl",
"240m",
"--timing-json",
"--",
"env",
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1",
"OPENCLAW_CHANGED_LANES_RAW_SYNC=1",
"CI=1",
"PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false",
"corepack",
"pnpm",
"check:changed",
...delegatedArgv,
];
}
function buildDelegatedChangedCheckArgv(argv, options = {}) {
const args = parseArgs(argv);
if (!args.staged || args.paths.length > 0) {
return argv;
}
const stagedPaths = listStagedChangedPaths(options.cwd);
const timedArgs = args.timed ? ["--timed"] : [];
if (stagedPaths.length === 0) {
return [...timedArgs, "--no-changes"];
}
return [...timedArgs, "--base", "HEAD", "--head", "HEAD", "--", ...stagedPaths];
}
export function shouldRunNpmLockGuard(paths) {
return paths.some((changedPath) => NPM_LOCK_POLICY_PATH_RE.test(changedPath));
}
export function shouldRunPromptSnapshotCheck(paths) {
return paths.some((changedPath) => PROMPT_SNAPSHOT_CHECK_PATH_RE.test(changedPath));
}
export function shouldRunPromptSnapshotOwnerTest(paths) {
return paths.some((changedPath) => PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE.test(changedPath));
}
export function shouldRunControlUiI18nVerify(paths) {
return paths.some((changedPath) =>
CONTROL_UI_I18N_VERIFY_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
export function shouldRunRuntimeSidecarBaselineCheck(paths) {
return paths.some((changedPath) => RUNTIME_SIDECAR_BASELINE_PATH_RE.test(changedPath));
}
/** Returns whether changed files can affect the sessions/transcripts SQLite schema baseline. */
export function shouldRunSqliteSessionSchemaBaselineCheck(paths) {
return paths.some((changedPath) =>
SQLITE_SESSION_SCHEMA_BASELINE_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
/** Returns whether changed files can alter the published Plugin SDK API contract. */
export function shouldRunPluginSdkApiBaselineCheck(paths) {
return paths.some((changedPath) => {
const normalizedPath = normalizeChangedPath(changedPath);
return (
!getChangedPathFacts(normalizedPath).isTestOnly &&
PLUGIN_SDK_API_BASELINE_PATH_RE.test(normalizedPath)
);
});
}
/** Returns whether changed files can alter Plugin SDK exports or surface budgets. */
export function shouldRunPluginSdkSurfaceChecks(paths) {
return paths.some((changedPath) =>
PLUGIN_SDK_SURFACE_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
/** Returns whether changed files can alter deprecated API or plugin-boundary results. */
export function shouldRunDeprecationHygieneChecks(paths) {
return paths.some((changedPath) =>
DEPRECATION_HYGIENE_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
export function shouldRunCanvasA2uiNativeResourceCheck(paths) {
return paths.some((changedPath) =>
CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
export function shouldRunAppcastOwnerTest(paths) {
return paths.some((changedPath) => normalizeChangedPath(changedPath) === "appcast.xml");
}
export function shouldRunTestTempCreationReport(paths) {
return paths.some(
(changedPath) => getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest,
);
}
export function createNpmLockGuardCommand(paths) {
if (!shouldRunNpmLockGuard(paths)) {
return null;
}
if (!npmLockPackageDirsForChangedPaths) {
throw new Error("changed-check npm-lock runtime dependencies were not loaded");
}
const packageDirs = npmLockPackageDirsForChangedPaths(paths);
if (packageDirs.length === 0) {
return null;
}
return {
name:
packageDirs.length === 1
? "npm package-lock guard"
: `npm package-lock guard (${packageDirs.length} packages)`,
bin: "node",
args: [
"scripts/generate-npm-package-lock.mjs",
...packageDirs.flatMap((packageDir) => ["--package-dir", packageDir]),
],
};
}
// Enough of the wrapper tail to hold its run summary; the rest is streamed, not kept.
const DELEGATION_OUTPUT_TAIL_LIMIT = 64 * 1024;
/**
* Signatures of a failure that happened before the remote command was dispatched:
* the broker or its API was unreachable, or no lease was ever obtained.
*/
const BACKEND_UNAVAILABLE_SIGNATURES = [
/request failed: \w+ "https?:\/\/[^"]*blacksmith[^"]*"/iu,
/context deadline exceeded/iu,
/(?:no such host|dial tcp|connection refused|network is unreachable)/iu,
/failed to (?:acquire|create|warm|start)\b[^\n]*\b(?:lease|testbox)/iu,
];
/**
* Whether a failed delegation provably never ran our command.
*
* Fails closed on purpose. A missing final summary alone cannot prove the remote
* never started — a wrapper that crashes or loses its output transport after
* dispatch looks identical — so this requires a positive pre-dispatch signature
* and treats everything else as a real failure. Getting this backwards is the
* dangerous direction: some lanes (prompt snapshots) are Linux-only truth, so a
* local rerun on macOS could turn an unknown or failing gate green.
*
* `command-exit` vetoes regardless: it only appears once the command reached the
* box, so it is proof a verdict exists and must be propagated as-is.
*/
export function delegationFailedBeforeRunning(output) {
if (/"errorKind"\s*:\s*"command-exit"/u.test(output)) {
return false;
}
return BACKEND_UNAVAILABLE_SIGNATURES.some((signature) => signature.test(output));
}
async function runChangedCheckViaCrabbox(argv = [], env = process.env) {
console.error("[check:changed] delegating through Crabbox workload routing.");
let tail = "";
const exitCode = await runManagedCommand({
bin: "node",
args: buildChangedCheckCrabboxArgs(argv),
env,
stdio: ["inherit", "pipe", "pipe"],
onReady: (child) => {
for (const stream of [child.stdout, child.stderr]) {
stream?.on("data", (chunk) => {
tail = (tail + chunk).slice(-DELEGATION_OUTPUT_TAIL_LIMIT);
// Inherited stdio used to get OS backpressure for free. Piping means we
// have to reapply it, or a verbose delegated run buffers its whole
// output in this process when stderr is an async pipe (typical in CI).
if (!process.stderr.write(chunk)) {
stream.pause();
process.stderr.once("drain", () => stream.resume());
}
});
}
},
});
return {
exitCode,
backendUnavailable: exitCode !== 0 && delegationFailedBeforeRunning(tail),
};
}
export function createChangedCheckPlan(result, options = {}) {
const commands = [];
const baseEnv = createChangedCheckChildEnv(options.env ?? process.env);
const generatedExtensionAssetPaths = result.paths.some((changedPath) =>
LINTABLE_EXTENSION_PATH_RE.test(changedPath),
)
? (cachedGeneratedExtensionAssetPaths ??= new Set(listGeneratedExtensionAssetSources()))
: new Set();
const add = (name, args, env) => {
if (!commands.some((command) => command.name === name && sameArgs(command.args, args))) {
commands.push({ name, args, ...(env ? { env } : {}) });
}
};
const addCommand = (name, bin, args, env) => {
if (
!commands.some(
(command) => command.name === name && command.bin === bin && sameArgs(command.args, args),
)
) {
commands.push({ name, bin, args, ...(env ? { env } : {}) });
}
};
const addTypecheck = (name, args) => add(name, args, createSparseTsgoSkipEnv(baseEnv));
const addLint = (name, args) => add(name, args, baseEnv);
const addTargetedLint = (
createCommand,
lintablePathRe,
fallbackName,
fallbackArgs,
ignoredPaths,
) => {
const candidatePaths = ignoredPaths
? result.paths.filter((changedPath) => !ignoredPaths.has(changedPath))
: result.paths;
const targets = candidatePaths.filter((changedPath) => lintablePathRe.test(changedPath));
const otherPaths = candidatePaths.filter((changedPath) => !lintablePathRe.test(changedPath));
const targetedCommands = [];
for (let offset = 0; offset < targets.length; offset += TARGETED_LINT_PATH_LIMIT) {
const command = createCommand(
[...otherPaths, ...targets.slice(offset, offset + TARGETED_LINT_PATH_LIMIT)],
baseEnv,
);
if (!command) {
addLint(fallbackName, fallbackArgs);
return false;
}
targetedCommands.push(command);
}
if (targetedCommands.length === 0) {
addLint(fallbackName, fallbackArgs);
return false;
}
for (const command of targetedCommands) {
addCommand(command.name, command.bin, command.args, command.env);
}
return true;
};
const addTestTempCreationReport = () => {
if (!shouldRunTestTempCreationReport(result.paths)) {
return;
}
addCommand(
"test temp creation report (warning-only)",
"node",
[
"scripts/report-test-temp-creations.mjs",
...(options.staged
? ["--staged"]
: ["--base", options.base ?? "origin/main", "--head", options.head ?? "HEAD"]),
],
baseEnv,
);
};
add("conflict markers", ["check:no-conflict-markers"]);
if (
result.paths.some((filePath) =>
/^(?:src\/|packages\/|extensions\/|config\/env-var-count-budget\.txt$|scripts\/check-env-var-count\.mjs$)/u.test(
filePath,
),
)
) {
add("environment variable count ratchet", [
"check:env-var-count",
...(options.staged ? ["--staged"] : []),
"--base",
options.staged ? "HEAD" : (options.base ?? "origin/main"),
]);
}
if (
result.paths.some((filePath) =>
/^(?:src\/|ui\/src\/|packages\/|extensions\/|\.oxlintrc\.json$|config\/max-lines-baseline\.txt$|scripts\/check-max-lines-ratchet\.mjs$)/u.test(
filePath,
),
)
) {
add("max-lines suppression ratchet", [
"check:max-lines-ratchet",
...(options.staged ? ["--staged"] : []),
"--base",
options.staged ? "HEAD" : (options.base ?? "origin/main"),
]);
}
add("changelog attributions", ["check:changelog-attributions"]);
add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]);
add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]);
add("duplicate scan target coverage", ["dup:check:coverage"]);
add("dependency pin guard", ["deps:pins:check"]);
if (result.paths.length > 0) {
add("format changed files", [
"format:check",
"--no-error-on-unmatched-pattern",
"--",
...result.paths,
]);
}
const npmLockGuardCommand = createNpmLockGuardCommand(result.paths);
if (npmLockGuardCommand) {
addCommand(
npmLockGuardCommand.name,
npmLockGuardCommand.bin,
npmLockGuardCommand.args,
baseEnv,
);
}
if (shouldRunPromptSnapshotCheck(result.paths)) {
add("prompt snapshot drift", ["prompt:snapshots:check"]);
}
if (shouldRunPromptSnapshotOwnerTest(result.paths)) {
add(
"prompt snapshot owner test",
["test:serial", "test/scripts/prompt-snapshots.test.ts"],
baseEnv,
);
}
if (shouldRunRuntimeSidecarBaselineCheck(result.paths)) {
add("runtime sidecar baseline", ["runtime-sidecars:check"]);
add(
"runtime sidecar owner test",
["test:serial", "src/plugins/bundled-plugin-metadata.test.ts"],
baseEnv,
);
}
if (result.lanes.all || result.lanes.bundledChannelConfigMetadata) {
add("bundled channel config metadata", ["check:bundled-channel-config-metadata"]);
}
if (shouldRunSqliteSessionSchemaBaselineCheck(result.paths)) {
add("SQLite sessions/transcripts schema baseline", ["sqlite:sessions-schema:check"]);
}
if (shouldRunPluginSdkApiBaselineCheck(result.paths)) {
add("Plugin SDK API contract manifest", ["plugin-sdk:api:check"]);
}
if (!result.lanes.releaseMetadata && shouldRunPluginSdkSurfaceChecks(result.paths)) {
add("Plugin SDK package exports", ["plugin-sdk:check-exports"]);
add("Plugin SDK surface budget", ["plugin-sdk:surface:check"]);
}
if (result.lanes.all || shouldRunDeprecationHygieneChecks(result.paths)) {
add("deprecated API usage", ["check:deprecated-api-usage"]);
// After 2026-07-24, lapsed compatibility windows intentionally fail this gate
// until their scheduled deletion PRs land.
add("plugin boundaries", ["plugins:boundary-report:ci"]);
}
if (shouldRunCanvasA2uiNativeResourceCheck(result.paths)) {
addCommand(
"Canvas A2UI native resource sync",
"node",
["scripts/sync-native-a2ui.mjs", "--check"],
baseEnv,
);
}
if (shouldRunAppcastOwnerTest(result.paths)) {
add(
"appcast owner tests",
["test:serial", "test/appcast.test.ts", "test/scripts/make-appcast.test.ts"],
baseEnv,
);
}
add("package patch guard", ["deps:patches:check"]);
if (
hasDeadcodeScannedSource(result.paths) &&
!isTruthyEnvFlag(baseEnv.OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE)
) {
addCommand(
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
"node",
["scripts/check-deadcode-exports.mjs"],
baseEnv,
);
}
if (result.docsOnly) {
return {
commands,
summary: "docs-only",
};
}
addTestTempCreationReport();
const lanes = result.lanes;
const runAll = lanes.all;
const shouldRunAndroidVersionSync = hasAndroidVersionSyncPath(result.paths);
if (lanes.scripts || lanes.tooling || lanes.testRoot) {
add("script declaration contracts", ["check:script-declarations"]);
}
if (lanes.releaseMetadata) {
add("release metadata guard", [
"release-metadata:check",
...(options.staged
? ["--staged"]
: ["--base", options.base ?? "origin/main", "--head", options.head ?? "HEAD"]),
]);
add("Android version sync", ["android:version:check"]);
add("iOS version sync", ["ios:version:check"]);
add("config schema baseline", ["config:schema:check"]);
add("config docs baseline", ["config:docs:check"]);
add("root dependency ownership", ["deps:root-ownership:check"]);
return {
commands,
summary: "release metadata",
};
}
if (shouldRunAndroidVersionSync) {
add("Android version sync", ["android:version:check"]);
}
if (runAll) {
add("database-first legacy-store guard", ["check:database-first-legacy-stores"]);
add("media download helper guard", ["check:media-download-helpers"]);
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
addTypecheck("typecheck all", ["tsgo:all"]);
addLint("lint", ["lint"]);
add("runtime import cycles", ["check:import-cycles"]);
return {
commands,
summary: "all",
};
}
if (shouldRunControlUiI18nVerify(result.paths)) {
addLint("Control UI i18n catalog", ["lint:ui:i18n"]);
}
if (lanes.core) {
addTypecheck("typecheck core", ["tsgo:core"]);
}
if (lanes.coreTests) {
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
}
if (lanes.ui) {
addTypecheck("typecheck UI", ["tsgo:ui"]);
}
if (lanes.extensions) {
addTypecheck("typecheck extensions", ["tsgo:extensions"]);
}
if (lanes.extensionTests) {
addTypecheck("typecheck extension tests", ["tsgo:extensions:test"]);
}
if (lanes.scripts) {
addTypecheck("typecheck scripts", ["tsgo:scripts"]);
}
if (lanes.testRoot) {
addTypecheck("typecheck test root", ["tsgo:test:root"]);
}
if (lanes.core || lanes.coreTests || lanes.ui) {
addTargetedLint(createTargetedCoreLintCommand, LINTABLE_CORE_PATH_RE, "lint core", [
"lint:core",
]);
}
if (
lanes.liveDockerTooling &&
result.paths.some((changedPath) => getChangedPathFacts(changedPath).surface === "source")
) {
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
addLint("lint core", ["lint:core"]);
}
if (lanes.extensions || lanes.extensionTests) {
// Generated plugin outputs have their own asset-integrity gate and are
// intentionally ignored by oxlint; manifests still need full-lane fallback.
if (
!result.paths.some((changedPath) => generatedExtensionAssetPaths.has(changedPath)) ||
result.paths.some(
(changedPath) =>
getChangedPathFacts(changedPath).surface === "extension" &&
!generatedExtensionAssetPaths.has(changedPath),
)
) {
addTargetedLint(
createTargetedExtensionLintCommand,
LINTABLE_EXTENSION_PATH_RE,
"lint extensions",
["lint:extensions"],
generatedExtensionAssetPaths,
);
}
}
if (lanes.tooling || lanes.liveDockerTooling) {
if (
addTargetedLint(createTargetedScriptLintCommand, LINTABLE_SCRIPT_PATH_RE, "lint scripts", [
"lint:scripts",
])
) {
addLint("lint docker-e2e", ["lint:docker-e2e"]);
addLint("raw HTTP/2 import guard", ["lint:tmp:no-raw-http2-imports"]);
}
}
if (lanes.apps && shouldSkipAppLintForMissingSwiftlint({ ...options, env: baseEnv })) {
addCommand(
"lint apps (swiftlint unavailable on this host)",
"node",
[
"-e",
"console.error('[check:changed] Swift app lint skipped: swiftlint is unavailable on this non-macOS host; macOS CI owns SwiftLint coverage.')",
],
baseEnv,
);
} else if (lanes.apps) {
addLint("lint apps", ["lint:apps"]);
}
if (hasMacosAppCiPath(result.paths)) {
add("macOS app CI tests", ["test:macos:ci"], baseEnv);
}
if (lanes.apps || lanes.core) {
addCommand(
"native state schema version guard",
"node",
["scripts/check-native-state-schema-version.mjs"],
baseEnv,
);
}
if (lanes.core || lanes.extensions) {
add("database-first legacy-store guard", ["check:database-first-legacy-stores"]);
add("media download helper guard", ["check:media-download-helpers"]);
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
add("runtime import cycles", ["check:import-cycles"]);
}
if (lanes.core) {
add("webhook body guard", ["lint:webhook:no-low-level-body-read"]);
add("pairing store guard", ["lint:auth:no-pairing-store-group"]);
add("pairing account guard", ["lint:auth:pairing-account-scope"]);
}
if (lanes.liveDockerTooling) {
addCommand("live Docker shell syntax", "bash", ["-n", ...LIVE_DOCKER_AUTH_SHELL_TARGETS]);
addCommand("live Docker scheduler dry run", "node", ["scripts/test-docker-all.mjs"], {
...baseEnv,
OPENCLAW_DOCKER_ALL_DRY_RUN: "1",
OPENCLAW_DOCKER_ALL_LIVE_MODE: "only",
});
}
return {
commands,
summary: Object.entries(lanes)
.filter(([, enabled]) => enabled)
.map(([lane]) => lane)
.join(", "),
};
}
export function createTargetedCoreLintCommand(paths, env = process.env, options = {}) {
return createTargetedOxlintCommand({
env,
label: "core",
lintablePathRe: LINTABLE_CORE_PATH_RE,
neutralPathRe: CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE,
paths,
tsconfig: CORE_OXLINT_TS_CONFIG,
...options,
});
}
export function createTargetedExtensionLintCommand(paths, env = process.env, options = {}) {
return createTargetedOxlintCommand({
env,
label: "extension",
lintablePathRe: LINTABLE_EXTENSION_PATH_RE,
neutralPathRe: EXTENSION_LINT_OPTIMIZATION_NEUTRAL_PATH_RE,
paths,
tsconfig: EXTENSIONS_OXLINT_TS_CONFIG,
...options,
});
}
export function createTargetedScriptLintCommand(paths, env = process.env, options = {}) {
return createTargetedOxlintCommand({
env,
label: "script",
lintablePathRe: LINTABLE_SCRIPT_PATH_RE,
neutralPathRe: SCRIPT_LINT_OPTIMIZATION_NEUTRAL_PATH_RE,
paths,
tsconfig: SCRIPTS_OXLINT_TS_CONFIG,
...options,
});
}
function createTargetedOxlintCommand({
env = process.env,
fileExists = existsSync,
label,
lintablePathRe,
neutralPathRe,
paths,
tsconfig,
}) {
if (
paths.some(
(changedPath) =>
!lintablePathRe.test(changedPath) &&
!LINTABLE_CORE_PATH_RE.test(changedPath) &&
!LINTABLE_EXTENSION_PATH_RE.test(changedPath) &&
!LINTABLE_SCRIPT_PATH_RE.test(changedPath) &&
!neutralPathRe.test(changedPath) &&
!MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath),
)
) {
return null;
}
const targets = paths
.filter((changedPath) => lintablePathRe.test(changedPath))
.toSorted((left, right) => left.localeCompare(right));
if (targets.length === 0 || targets.length > TARGETED_LINT_PATH_LIMIT) {
return null;
}
if (!targets.every((target) => fileExists(target))) {
return null;
}
return {
name: targets.length === 1 ? `lint ${label} changed file` : `lint ${label} changed files`,
bin: "node",
args: ["scripts/run-oxlint.mjs", "--tsconfig", tsconfig, ...targets],
env,
};
}
async function runChangedCheck(result, options = {}) {
if (result.paths.length === 0) {
console.error("[check:changed] no changed paths; nothing to run");
return 0;
}
await ensureChangedCheckRuntimeDependencies(result.paths);
const baseEnv = resolveLocalHeavyCheckEnv(options.env ?? process.env);
const childEnv = createChangedCheckChildEnv(baseEnv);
const plan = createChangedCheckPlan(result, {
...options,
env: childEnv,
});
const releaseLock = options.dryRun
? () => {}
: acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env: baseEnv,
toolName: "check:changed",
});
try {
printPlan(result, plan, options);
if (options.dryRun) {
return 0;
}
const timings = [];
for (const command of plan.commands) {
const status = await runPlanCommand(command, timings);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
printSummary(timings, options);
return 0;
} finally {
releaseLock();
}
}
function sameArgs(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function printPlan(result, plan, options) {
const prefix = options.dryRun ? "[check:changed:dry-run]" : "[check:changed]";
console.error(`${prefix} lanes=${plan.summary || "none"}`);
if (result.extensionImpactFromCore) {
console.error(`${prefix} extension-impacting surface; extension typecheck included`);
}
for (const reason of result.reasons) {
console.error(`${prefix} ${reason}`);
}
if (options.dryRun) {
for (const command of plan.commands) {
console.error(`${prefix} would run: ${formatPlanCommand(command)}`);
}
}
}
async function runPnpm(command, timings) {
return await runCommand(createPnpmManagedCommand(command), timings);
}
async function runPlanCommand(command, timings) {
if (command.bin) {
return await runCommand(command, timings);
}
return await runPnpm(command, timings);
}
function formatPlanCommand(command) {
const argv = command.bin ? [command.bin, ...command.args] : ["pnpm", ...command.args];
return argv.map(formatShellToken).join(" ");
}
function formatShellToken(token) {
return /^[A-Za-z0-9_./:@=-]+$/u.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`;
}
export function createPnpmManagedCommand(command, env = process.env) {
const commandEnv = command.env ?? resolveLocalHeavyCheckEnv(env);
if (isTruthyEnvFlag(commandEnv.CI) || isTruthyEnvFlag(commandEnv.GITHUB_ACTIONS)) {
const shimmedEnv = prependCorepackPnpmShim(commandEnv);
return {
...command,
bin: "corepack",
args: ["pnpm", ...command.args],
env: shimmedEnv,
};
}
return { ...command, bin: "pnpm", env: commandEnv };
}
function prependCorepackPnpmShim(env) {
const shimDir = ensureCorepackPnpmShimDir();
return {
...env,
PATH: [shimDir, env.PATH ?? env.Path ?? ""].filter(Boolean).join(path.delimiter),
};
}
function ensureCorepackPnpmShimDir() {
if (corepackPnpmShimDir) {
return corepackPnpmShimDir;
}
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-corepack-pnpm-"));
const pnpmPath = path.join(dir, "pnpm");
writeFileSync(pnpmPath, '#!/bin/sh\nexec corepack pnpm "$@"\n', "utf8");
chmodSync(pnpmPath, 0o755);
writeFileSync(path.join(dir, "pnpm.cmd"), "@echo off\r\ncorepack pnpm %*\r\n", "utf8");
corepackPnpmShimDir = dir;
registerCorepackPnpmShimCleanup();
return dir;
}
function registerCorepackPnpmShimCleanup() {
if (corepackPnpmShimCleanupRegistered) {
return;
}
corepackPnpmShimCleanupRegistered = true;
process.once("exit", cleanupCorepackPnpmShimDir);
}
export function cleanupCorepackPnpmShimDir() {
if (!corepackPnpmShimDir) {
return;
}
const dir = corepackPnpmShimDir;
corepackPnpmShimDir = undefined;
rmSync(dir, { recursive: true, force: true });
}
async function runCommand(command, timings) {
const startedAt = performance.now();
console.error(`\n[check:changed] ${command.name}`);
let status = 1;
try {
status = await runManagedCommand({
bin: command.bin,
args: command.args,
env: command.env ?? resolveLocalHeavyCheckEnv(),
});
} catch (error) {
console.error(error);
}
timings.push({
name: command.name,
durationMs: performance.now() - startedAt,
status,
});
return status;
}
function printSummary(timings, options) {
printTimingSummary("check:changed", timings, { skipWhenAllOk: !options.timed });
}
function parseArgs(argv) {
const separatorIndex = argv.indexOf("--");
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const explicitPaths =
separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1).map(normalizeChangedPath);
const args = {
base: "origin/main",
head: "HEAD",
staged: false,
dryRun: false,
timed: false,
noChanges: false,
help: false,
paths: [],
};
const parsed = parseFlagArgs(
flagArgv,
args,
[
stringFlag("--base", "base"),
stringFlag("--head", "head"),
booleanFlag("--staged", "staged"),
booleanFlag("--dry-run", "dryRun"),
booleanFlag("--timed", "timed"),
booleanFlag("--no-changes", "noChanges"),
booleanFlag("--help", "help"),
booleanFlag("-h", "help"),
],
{
onUnhandledArg(arg, target) {
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
target.paths.push(normalizeChangedPath(arg));
return "handled";
},
},
);
parsed.paths.push(...explicitPaths);
return parsed;
}
function printUsage() {
process.stdout.write(
[
"Usage: node scripts/check-changed.mjs [options] [-- <paths...>]",
"",
"Options:",
" --base <ref> Base ref for changed paths (default: origin/main)",
" --head <ref> Head ref for changed paths (default: HEAD)",
" --staged Check staged paths instead of git diff paths",
" --dry-run Print the planned checks without running them",
" --timed Print timing summary",
" --no-changes Treat the changed path set as empty",
" -h, --help Show this help",
"",
].join("\n"),
);
}
function isDirectRun() {
return isDirectRunUrl(process.argv[1], import.meta.url);
}
if (isDirectRun()) {
const argv = process.argv.slice(2);
let args;
try {
args = parseArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (args.help) {
printUsage();
process.exitCode = 0;
} else {
let paths;
try {
paths = args.noChanges
? []
: args.paths.length > 0
? args.paths
: args.staged
? listStagedChangedPaths()
: listChangedPathsFromGit({ base: args.base, head: args.head });
} catch (error) {
// A sparse/fresh checkout may not have the requested base ref yet. The remote
// workflow fetches it, so preserve explicit/default delegation instead of dying locally.
if (!shouldDelegateChangedCheckToCrabbox(argv, process.env)) {
throw error;
}
// No local fallback here: this path exists because the checkout cannot
// resolve the diff refs itself, so there is nothing local to run.
const delegated = await runChangedCheckViaCrabbox(argv, process.env);
if (delegated.backendUnavailable) {
throw error;
}
process.exitCode = delegated.exitCode;
}
if (paths) {
const result = detectChangedLanesForPaths({
paths,
base: args.base,
head: args.head,
staged: args.staged,
});
if (
shouldDelegateChangedCheckToCrabbox(argv, process.env, {
cwd: process.cwd(),
result,
diffRefsReady: result.lanes.releaseMetadata
? args.staged ||
changedCheckDiffRefsReady({
base: args.base,
head: args.head,
})
: undefined,
})
) {
const delegated = await runChangedCheckViaCrabbox(argv, process.env);
if (delegated.backendUnavailable) {
// Say this loudly: the proof below is local, so whoever reads the run
// knows which machine produced it and that Linux-only lanes are unproven.
console.error(
"[check:changed] Blacksmith never ran the checks (no run summary). Falling back to local execution; note this in the proof summary.",
);
}
process.exitCode = delegated.backendUnavailable
? await runChangedCheck(result, {
...args,
explicitPaths: args.paths.length > 0,
})
: delegated.exitCode;
} else {
process.exitCode = await runChangedCheck(result, {
...args,
explicitPaths: args.paths.length > 0,
});
}
}
}
}