openclaw/scripts/check-changed.mts
Peter Steinberger 92108db80c
Some checks are pending
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
CodeQL / Security High (process-exec-boundary) (push) Waiting to run
Control UI Locale Refresh / resolve-base (push) Waiting to run
Control UI Locale Refresh / Verify generated PR App permissions (push) Blocked by required conditions
Control UI Locale Refresh / Refresh (push) Blocked by required conditions
Control UI Locale Refresh / Commit control UI locale refresh (push) Blocked by required conditions
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
Native App Locale Refresh / Refresh native ar (push) Blocked by required conditions
Native App Locale Refresh / Refresh native de (push) Blocked by required conditions
Native App Locale Refresh / Refresh native es (push) Blocked by required conditions
Native App Locale Refresh / Refresh native fa (push) Blocked by required conditions
Native App Locale Refresh / Refresh native fr (push) Blocked by required conditions
Native App Locale Refresh / Refresh native hi (push) Blocked by required conditions
Native App Locale Refresh / Refresh native id (push) Blocked by required conditions
Native App Locale Refresh / Refresh native it (push) Blocked by required conditions
Native App Locale Refresh / Refresh native ja-JP (push) Blocked by required conditions
Native App Locale Refresh / Refresh native ko (push) Blocked by required conditions
Native App Locale Refresh / Refresh native nl (push) Blocked by required conditions
Native App Locale Refresh / Refresh native pl (push) Blocked by required conditions
Native App Locale Refresh / Refresh native pt-BR (push) Blocked by required conditions
Native App Locale Refresh / Refresh native ru (push) Blocked by required conditions
Native App Locale Refresh / Refresh native sv (push) Blocked by required conditions
Native App Locale Refresh / Refresh native th (push) Blocked by required conditions
Native App Locale Refresh / Refresh native tr (push) Blocked by required conditions
Native App Locale Refresh / Refresh native uk (push) Blocked by required conditions
Native App Locale Refresh / Refresh native vi (push) Blocked by required conditions
Native App Locale Refresh / Refresh native zh-CN (push) Blocked by required conditions
Native App Locale Refresh / Refresh native zh-TW (push) Blocked by required conditions
Native App Locale Refresh / Commit native locale refresh (push) Blocked by required conditions
Native App Locale Refresh / resolve-base (push) Waiting to run
Native App Locale Refresh / Verify generated PR App permissions (push) Blocked by required conditions
Node Runtime Conformance / scope (push) Waiting to run
Node Runtime Conformance / TypeScript contracts (push) Blocked by required conditions
Node Runtime Conformance / Rust workspace (push) Blocked by required conditions
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / scope (push) Waiting to run
Plugin Init Scaffold Validation / Validate provider scaffold (push) Blocked by required conditions
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Plugin NPM Release / Preflight plugin npm package () (push) Blocked by required conditions
Plugin NPM Release / Seal prepared plugin npm release (push) Blocked by required conditions
Plugin NPM Release / Trusted publisher OIDC exchange (push) Blocked by required conditions
Plugin NPM Release / approve_plugins_npm_release (push) Blocked by required conditions
Plugin NPM Release / Publish plugin npm package () (push) Blocked by required conditions
Plugin NPM Release / verify_plugins_npm (push) Blocked by required conditions
Vitest Cache Warm / dependencies (push) Waiting to run
Vitest Cache Warm / warm (push) Waiting to run
Workflow Sanity / actionlint (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run
fix(tooling): keep max-lines baseline shrinks on targeted lint
The max-lines baseline is consumed by its existing ratchet, not Oxlint. Keep this exact path neutral when selecting changed-file lint, with the same guard and broad-input fallbacks.

Extend the mixed-owner planner regression across guarded baselines. The max-lines row failed before the fix; nine focused planner and fallback cases pass afterward. Scoped guards, lint, and typechecks pass after consuming the canonical workflow fixture type correction.
2026-09-30 03:11:17 -07:00

1818 lines
64 KiB
TypeScript

// Runs the changed-file check lanes selected by `scripts/changed-lanes.mts`.
import { execFileSync } from "node:child_process";
import {
accessSync,
chmodSync,
constants,
existsSync,
mkdtempSync,
readFileSync,
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,
getChangedCoreTestPaths,
hasConfigDocInput,
isConfigDocSchemaSourcePath,
hasDeadcodeScannedSource,
hasProtocolEventCoverageInput,
listChangedPathsFromGit,
listStagedChangedPaths,
} from "./changed-lanes.mts";
import type { ChangedLaneResult } from "./changed-lanes.mts";
import { detectChangedScope, isMacosToolingPath } from "./ci-changed-scope.mjs";
import {
booleanFlag,
isOpenEndedTruthyValue,
parseFlagArgs,
stringFlag,
} from "./lib/arg-utils.mts";
import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs";
import { printTimingSummary } from "./lib/check-timing-summary.mts";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { runWithFailedTrailer } from "./lib/failed-trailer.mts";
import { resolveLocalCheckEnv } from "./lib/local-check-runtime.mts";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import { readNativeTypeScriptConfig } from "./lib/native-typescript-config.mts";
import { createChangedOxlintEnv, isOxlintCommand } from "./lib/oxlint-changed-scope.mts";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mts";
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mts";
import type { createChangedCoreTestCheck } from "./run-tsgo-core-test-shards.mts";
import {
hasImportGraphImpactOnTargets,
resolveImportGraphDependents,
} from "./test-projects.test-support.mts";
type ChangedCheckCommand = {
coreTestCheck?: "checkBoundary" | "checkTypes";
name: string;
args: string[];
bin?: string;
env?: NodeJS.ProcessEnv;
};
type CiLintSelection = {
files: string[];
rootTestFiles?: string[];
coreStripes: number[];
extensionStripes: number[];
groups: ("core" | "extensions" | "scripts")[];
central: boolean;
};
type ChangedCheckPlanOptions = {
typecheckResult?: ChangedLaneResult;
lintOnly?: boolean;
lintSelection?: CiLintSelection;
lintThreads?: 1 | 8;
env?: NodeJS.ProcessEnv;
staged?: boolean;
base?: string;
head?: string;
platform?: NodeJS.Platform;
swiftlintAvailable?: boolean;
};
type TargetedLintOptions = {
fileExists?: (path: string) => boolean;
};
type ChangedCheckDelegateOptions = {
result?: ChangedLaneResult;
diffRefsReady?: boolean;
};
type ChangedCheckRunOptions = ChangedCheckPlanOptions & {
dryRun?: boolean;
timed?: boolean;
explicitPaths?: boolean;
};
type ChangedCheckTiming = Parameters<typeof printTimingSummary>[1][number];
type TargetedOxlintCommandOptions = TargetedLintOptions & {
env?: NodeJS.ProcessEnv;
label: string;
lintablePathRe: RegExp;
neutralPathRe: RegExp;
paths: string[];
tsconfig: string;
maxPaths?: number;
};
type NpmLockPackageDirsResolver = (changedPaths: string[]) => string[];
type TargetedLintCommand = NonNullable<ReturnType<typeof createTargetedOxlintCommand>>;
const NPM_LOCK_POLICY_PATH_RE =
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|scripts\/generate-npm-package-lock\.mts|(?: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;
// Doctor-contract declaration truth depends on arbitrary plugin source (artifacts
// re-export from src), so any non-test extension module or plugin manifest change
// must re-prove the src/plugins-owned declaration and closure-guard tests that the
// extension lanes would otherwise never select.
const DOCTOR_CONTRACT_OWNER_TEST_PATH_RE =
/^extensions\/[^/]+\/(?:openclaw\.plugin\.json$|(?!.*\.test\.).*\.(?:c|m)?[jt]s$)/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_SURFACE_PATH_RE =
/^(?:package\.json$|src\/plugin-sdk\/|packages\/plugin-sdk\/|extensions\/(?:tsconfig\.package-boundary\.paths\.json|xai\/tsconfig\.json)$|scripts\/(?:plugin-sdk-surface-report\.mts|sync-plugin-sdk-exports\.mts|lib\/plugin-sdk-(?:declaration-budget\.mts|deprecated-barrel-subpaths\.json|deprecated-public-subpaths\.json|entries\.mts|entrypoints\.json|private-local-only-subpaths\.json)))/u;
const DEPRECATION_HYGIENE_PATH_RE =
/^(?:package\.json$|src\/|extensions\/|packages\/|scripts\/(?:check-deprecated-api-usage\.mts$|plugin-boundary-report\.ts$|lib\/plugin-sdk))/u;
const WRAPPER_SHADOWING_PATH_RE =
/^(?:package\.json$|src\/|scripts\/(?:check-(?:export-name-collisions|wrapper-shadowing)\.mts$|lib\/(?:source-file-scan-cache|ts-guard-utils)\.mts$))/u;
const EXTENSION_TEST_CORE_IMPORT_PATH_RE =
/^(?:extensions\/|test\/helpers\/|scripts\/(?:check-no-extension-test-core-imports|check-file-utils)\.ts$|scripts\/check-changed\.m[jt]s$)/u;
const CONTROL_UI_I18N_VERIFY_PATH_RE =
/^(?:package\.json$|ui\/(?:src\/|config\/control-ui-locales\.ts$)|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/(?:control-ui-i18n-[^/]+\.ts|control-ui-i18n-config\.json))$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
const SHRINK_RATCHET_OWNER_PATH = "scripts/lib/shrink-ratchet.mts";
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
const EXTENSIONS_OXLINT_TS_CONFIG = "extensions/tsconfig.json";
const SCRIPTS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.scripts.json";
const ROOT_TEST_TS_CONFIG = "test/tsconfig/tsconfig.test.root.json";
const TARGETED_LINT_PATH_LIMIT = 8;
const CORE_LINT_ARGV_BYTES = 24 * 1024;
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 LINTABLE_UI_STYLE_PATH_RE = /^ui\/(?:src\/.+\.(?:css|ts)|public\/themes\/[^/]+\.css)$/u;
// These baselines are checked by their ratchets, not consumed by Oxlint.
const LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:docs\/|README\.md$|.*\.mdx?$|config\/(?:assertion-safety-baseline|env-var-count-budget|max-lines-baseline)\.txt$)/u;
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$|^ui\/(?:src\/.+|public\/themes\/[^/]+)\.css$/u;
const TOOLING_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 SWIFT_BUILD_CACHE_METADATA_TEST_PATH = "test/scripts/swift-build-cache-metadata.test.ts";
const MACOS_APP_CI_PATH_RE =
/^(?:apps\/(?:macos\/(?!Tests\/.+\.swift$)|(?:macos-mlx-tts|shared|swabble)\/)|Swabble\/|src\/(?:agents\/github-exec-(?:launcher|credential)\.ts|shared\/worker-bundle-hash\.ts|worker\/workspace-rsync-receiver\.ts|gateway\/worker-environments\/workspace-(?:accepted-(?:remote-script|sync)|mutation-remote-script|rsync-path\.test|sync(?:-helpers)?)\.ts)$)/u;
let corepackPnpmShimDir: string | undefined;
let corepackPnpmShimCleanupRegistered = false;
let cachedGeneratedExtensionAssetPaths: { cwd: string; paths: Set<string> } | undefined;
let npmLockPackageDirsForChangedPaths: NpmLockPackageDirsResolver | undefined;
async function ensureChangedCheckRuntimeDependencies(paths: string[]) {
if (!shouldRunNpmLockGuard(paths) || npmLockPackageDirsForChangedPaths) {
return;
}
({ npmLockPackageDirsForChangedPaths } = await import("./generate-npm-package-lock.mts"));
}
// 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"]);
}
function hasAndroidVersionSyncPath(paths: string[]) {
return paths.some((changedPath) => ANDROID_VERSION_SYNC_PATHS.has(changedPath));
}
function hasMacosAppCiPath(paths: string[]) {
// The metadata test has its own command; production edits still need native app proof.
// Swift test-target sources do not feed the packaged app; native CI still covers them.
return paths.some((changedPath) => {
return (
changedPath !== SWIFT_BUILD_CACHE_METADATA_TEST_PATH &&
(MACOS_APP_CI_PATH_RE.test(changedPath) || isMacosToolingPath(changedPath))
);
});
}
function executableExistsOnPath(command: string, env: NodeJS.ProcessEnv = 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: ChangedCheckPlanOptions = {}) {
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 shouldDelegateChangedCheckToCrabbox(
argv: string[] = [],
env: NodeJS.ProcessEnv = process.env,
options: ChangedCheckDelegateOptions = {},
) {
if (isOpenEndedTruthyValue(env.OPENCLAW_CHECK_CHANGED_REMOTE_CHILD)) {
return false;
}
if (isOpenEndedTruthyValue(env.CI) || isOpenEndedTruthyValue(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 (isOpenEndedTruthyValue(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 false;
}
function changedCheckDiffRefsReady({
base,
head,
cwd = process.cwd(),
}: {
base: string;
head: string;
cwd?: string;
}) {
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: string[] = [], options: { cwd?: string } = {}) {
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: string[], options: { cwd?: string } = {}) {
const args = parseArgs(argv);
if (!args.staged || args.paths.length > 0) {
return argv;
}
const stagedPaths = listStagedChangedPaths(options.cwd, args.base);
const timedArgs = args.timed ? ["--timed"] : [];
if (stagedPaths.length === 0) {
return [...timedArgs, "--no-changes"];
}
return [
...timedArgs,
"--paths-from-git",
"--base",
args.base ?? "HEAD",
"--head",
"HEAD",
"--",
...stagedPaths,
];
}
export function shouldRunNpmLockGuard(paths: string[]) {
return paths.some((changedPath) => NPM_LOCK_POLICY_PATH_RE.test(changedPath));
}
function shouldRunPromptSnapshotCheck(paths: string[]) {
return paths.some((changedPath) => PROMPT_SNAPSHOT_CHECK_PATH_RE.test(changedPath));
}
function shouldRunPromptSnapshotOwnerTest(paths: string[]) {
return paths.some((changedPath) => PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE.test(changedPath));
}
function shouldRunControlUiI18nVerify(paths: string[]) {
return paths.some((changedPath) => CONTROL_UI_I18N_VERIFY_PATH_RE.test(changedPath));
}
function shouldRunRuntimeSidecarBaselineCheck(paths: string[]) {
return paths.some((changedPath) => RUNTIME_SIDECAR_BASELINE_PATH_RE.test(changedPath));
}
/** Returns whether changed files can drift bundled doctor-contract declarations or closures. */
function shouldRunDoctorContractOwnerTests(paths: string[]) {
return paths.some((changedPath) => DOCTOR_CONTRACT_OWNER_TEST_PATH_RE.test(changedPath));
}
/** Returns whether changed files can affect the sessions/transcripts SQLite schema baseline. */
function shouldRunSqliteSessionSchemaBaselineCheck(paths: string[]) {
return paths.some((changedPath) => SQLITE_SESSION_SCHEMA_BASELINE_PATH_RE.test(changedPath));
}
/** Returns whether changed files can alter Plugin SDK exports or surface budgets. */
function shouldRunPluginSdkSurfaceChecks(paths: string[]) {
return paths.some((changedPath) => PLUGIN_SDK_SURFACE_PATH_RE.test(changedPath));
}
/** Returns whether changed files can alter deprecated API or plugin-boundary results. */
function shouldRunDeprecationHygieneChecks(paths: string[]) {
return paths.some((changedPath) => DEPRECATION_HYGIENE_PATH_RE.test(changedPath));
}
/** Returns whether changed files can alter wrapper-shadowing results. */
function shouldRunWrapperShadowingCheck(paths: string[]) {
return paths.some((changedPath) => WRAPPER_SHADOWING_PATH_RE.test(changedPath));
}
function shouldRunAppcastOwnerTest(paths: string[]) {
return paths.some((changedPath) => /^appcast(?:-(?:arm64|x86_64))?\.xml$/u.test(changedPath));
}
export function shouldRunTestTempCreationReport(paths: string[]) {
return paths.some((changedPath) => getChangedPathFacts(changedPath).isChangedLaneTest);
}
export function createNpmLockGuardCommand(paths: string[]) {
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: [
"--import",
"tsx",
"scripts/generate-npm-package-lock.mts",
...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, no lease was ever obtained, or workload
* routing exhausted its provider chain (every provider doctor failed).
*/
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,
// crabbox-wrapper prints this and exits before dispatching anything remote.
/\[crabbox\] no ready provider for workload=/u,
];
/**
* 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: string) {
if (/"errorKind"\s*:\s*"command-exit"/u.test(output)) {
return false;
}
return BACKEND_UNAVAILABLE_SIGNATURES.some((signature) => signature.test(output));
}
async function runChangedCheckViaCrabbox(
argv: string[] = [],
env: NodeJS.ProcessEnv = 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: Buffer) => {
tail = (tail + chunk.toString("utf8")).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: ChangedLaneResult,
options: ChangedCheckPlanOptions = {},
) {
const commands: ChangedCheckCommand[] = [];
const broadAudits = new Set<ChangedCheckCommand>();
const typechecks = new Set<ChangedCheckCommand>();
const lintChecks = new Set<ChangedCheckCommand>();
const baseEnv = { ...resolveLocalCheckEnv(options.env ?? process.env) };
delete baseEnv.OPENCLAW_OXLINT_CHANGED_PATHS;
const cwd = process.cwd();
if (
result.paths.some((changedPath) => LINTABLE_EXTENSION_PATH_RE.test(changedPath)) &&
cachedGeneratedExtensionAssetPaths?.cwd !== cwd
) {
cachedGeneratedExtensionAssetPaths = {
cwd,
paths: new Set(listGeneratedExtensionAssetSources()),
};
}
const generatedExtensionAssetPaths =
cachedGeneratedExtensionAssetPaths?.cwd === cwd
? cachedGeneratedExtensionAssetPaths.paths
: new Set<string>();
const add = (name: string, args: string[], env?: NodeJS.ProcessEnv) => {
const existing = commands.find(
(command) => command.name === name && sameArgs(command.args, args),
);
if (existing) {
return existing;
}
const command = { name, args, ...(env ? { env } : {}) };
commands.push(command);
return command;
};
const addCommand = (name: string, bin: string, args: string[], env?: NodeJS.ProcessEnv) => {
const existing = commands.find(
(command) => command.name === name && command.bin === bin && sameArgs(command.args, args),
);
if (existing) {
return existing;
}
const command = { name, bin, args, ...(env ? { env } : {}) };
commands.push(command);
return command;
};
const addTypecheck = (name: string, args: string[]) =>
typechecks.add(add(name, args, createSparseTsgoSkipEnv(baseEnv)));
const addLintCommand = (name: string, bin: string, args: string[], env?: NodeJS.ProcessEnv) =>
lintChecks.add(addCommand(name, bin, args, env));
const finishPlan = (summary: string) => {
// Full lint shards exclude test/. Keep changed root sources covered even
// when another path selects the all-lane early return, without widening lint.
let rootTestTargets = (options.lintSelection?.rootTestFiles ?? result.paths).filter(
(file) => getChangedPathFacts(file).isRootTestSource && existsSync(file),
);
if (rootTestTargets.length > 0) {
// --tsconfig affects import resolution, not native semantic discovery or
// target selection. Expand the canonical roots before passing explicit files.
const config = readNativeTypeScriptConfig({
cwd: process.cwd(),
configFileName: ROOT_TEST_TS_CONFIG,
});
const roots = new Set(config.fileNames.map((file) => path.resolve(file)));
rootTestTargets = rootTestTargets.filter((file) => roots.has(path.resolve(file)));
}
for (let offset = 0; offset < rootTestTargets.length; offset += TARGETED_LINT_PATH_LIMIT) {
const batch = rootTestTargets.slice(offset, offset + TARGETED_LINT_PATH_LIMIT);
addLintCommand(
batch.length === 1 ? "lint test root changed file" : "lint test root changed files",
"node",
["scripts/run-oxlint.mjs", "--tsconfig", ROOT_TEST_TS_CONFIG, ...batch],
baseEnv,
);
}
for (const command of [...lintChecks].filter(isOxlintCommand)) {
command.env = createChangedOxlintEnv(result.paths, command.env ?? baseEnv);
}
if (options.lintOnly) {
const lintEnv = createChangedOxlintEnv(result.paths, baseEnv);
const lintCommands = commands.filter((command) => lintChecks.has(command));
const selection = options.lintSelection;
return {
commands: selection
? [
...(selection.central
? lintCommands
.filter((command) => !targetedLintOwner(command))
.map((command) =>
options.lintThreads &&
command.bin === "node" &&
command.args[0] === "scripts/run-oxlint.mjs"
? Object.assign({}, command, {
args: [...command.args, `--threads=${options.lintThreads}`],
})
: command,
)
: []),
...createCiLintCommands(selection, options.lintThreads ?? 1, lintEnv),
]
: lintCommands,
summary,
};
}
const end = commands.findLastIndex((command) => typechecks.has(command)) + 1;
const prefix = commands.slice(0, end);
// These audits produce diagnostics, not compiler inputs. Defer them without
// moving compiler prerequisites or overlapping their resource-heavy processes.
return {
commands: [
...prefix.filter((command) => !broadAudits.has(command)),
...prefix.filter((command) => broadAudits.has(command)),
...commands.slice(end),
],
summary,
};
};
const addLint = (name: string, args: string[]) => lintChecks.add(add(name, args, baseEnv));
const addTargetedLint = (
createCommand: (
paths: string[],
env?: NodeJS.ProcessEnv,
options?: TargetedLintOptions,
) => TargetedLintCommand | null,
lintablePathRe: RegExp,
fallbackName: string,
fallbackArgs: string[],
ignoredPaths?: Set<string>,
fallbackWithoutTargets = true,
) => {
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: TargetedLintCommand[] = [];
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) {
if (fallbackWithoutTargets) {
addLint(fallbackName, fallbackArgs);
}
return !fallbackWithoutTargets;
}
for (const command of targetedCommands) {
addLintCommand(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", ...(options.base ? ["--base", options.base] : [])]
: ["--base", options.base ?? "origin/main", "--head", options.head ?? "HEAD"]),
],
baseEnv,
);
};
if (result.lanes.all || hasProtocolEventCoverageInput(result.paths)) {
addCommand(
"mobile protocol event coverage",
"node",
["scripts/check-protocol-event-coverage.mjs"],
baseEnv,
);
}
add("conflict markers", ["check:no-conflict-markers"]);
if (
result.paths.some((file) => /\.(?:ts|tsx|mts|mjs)$/u.test(file) || file === ".oxlintrc.json")
) {
add("line-cap growth ratchet", [
"check:line-cap-ratchet",
...(options.staged ? ["--staged"] : []),
"--base",
options.base ?? (options.staged ? "HEAD" : "origin/main"),
]);
}
if (
result.paths.some(
(filePath) =>
filePath === SHRINK_RATCHET_OWNER_PATH ||
/^(?:src\/|ui\/src\/|packages\/|extensions\/|\.oxlintrc\.json$|config\/(?:env-var-count-budget|max-lines-baseline)\.txt$|scripts\/check-(?:env-var-count|max-lines-ratchet)\.mts$)/u.test(
filePath,
),
)
) {
add("max-lines suppression ratchet", [
"check:max-lines-ratchet",
...(options.staged ? ["--staged"] : []),
"--base",
options.base ?? (options.staged ? "HEAD" : "origin/main"),
]);
}
if (
result.paths.some(
(filePath) =>
filePath === SHRINK_RATCHET_OWNER_PATH ||
/^(?:src\/|ui\/src\/|packages\/|extensions\/|config\/assertion-safety-baseline\.txt$|scripts\/check-assertion-safety-ratchet\.mts$|scripts\/lib\/type-assertion-guard-scope\.mjs$|scripts\/oxlint-boundary-guards\.mjs$)/u.test(
filePath,
),
)
) {
add("assertion SAFETY comment ratchet", [
"check:assertion-safety",
...(options.staged ? ["--staged"] : []),
"--base",
options.base ?? (options.staged ? "HEAD" : "origin/main"),
]);
}
add("changelog attributions", ["check:changelog-attributions"]);
add("doctor deprecation registry", ["check:doctor-deprecation-registry"]);
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"]);
if (
result.lanes.all ||
result.paths.some((changedPath) => EXTENSION_TEST_CORE_IMPORT_PATH_RE.test(changedPath))
) {
add("extension test core imports", ["lint:plugins:no-extension-test-core-imports"]);
}
add("duplicate scan target coverage", ["dup:check:coverage"]);
broadAudits.add(add("coercion helper declaration guard", ["check:coercion-helpers"]));
add("dependency pin guard", ["deps:pins:check"]);
if (result.paths.length > 0) {
lintChecks.add(
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 (shouldRunDoctorContractOwnerTests(result.paths)) {
add(
"doctor contract declaration + closure guard tests",
[
"test:serial",
"src/plugins/doctor-contract-declarations.test.ts",
"src/plugins/doctor-contract-closure-guard.test.ts",
],
baseEnv,
);
}
if (result.lanes.all || result.lanes.bundledChannelConfigMetadata) {
add("bundled channel config metadata", ["check:bundled-channel-config-metadata"]);
}
// Select before docs-only returns; trace schema entries without expanding config IO/loaders.
if (
!options.lintOnly &&
(result.lanes.all ||
result.lanes.releaseMetadata ||
hasConfigDocInput(result.paths) ||
hasImportGraphImpactOnTargets(
result.paths.filter(
(file) => /\.[cm]?[jt]sx?$/u.test(file) && !getChangedPathFacts(file).isChangedLaneTest,
),
isConfigDocSchemaSourcePath,
process.cwd(),
{ tooling: true },
))
) {
add("config docs baseline", ["config:docs:check"]);
}
if (shouldRunSqliteSessionSchemaBaselineCheck(result.paths)) {
add("SQLite sessions/transcripts schema baseline", ["sqlite:sessions-schema: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)) {
broadAudits.add(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 (result.lanes.all || shouldRunWrapperShadowingCheck(result.paths)) {
add("wrapper shadowing", ["check:wrapper-shadowing"]);
}
if (shouldRunAppcastOwnerTest(result.paths)) {
add(
"appcast owner tests",
["test:serial", "test/appcast.test.ts", "test/scripts/make-appcast.test.ts"],
baseEnv,
);
}
if (
result.paths.some(
(changedPath) =>
changedPath === "scripts/swift-build-cache-metadata.py" ||
changedPath === SWIFT_BUILD_CACHE_METADATA_TEST_PATH,
)
) {
add(
"Swift build cache metadata tests",
["test:serial", SWIFT_BUILD_CACHE_METADATA_TEST_PATH],
baseEnv,
);
}
add("package patch guard", ["deps:patches:check"]);
if (
hasDeadcodeScannedSource(result.paths) &&
!isOpenEndedTruthyValue(baseEnv.OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE)
) {
broadAudits.add(
addCommand(
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
"node",
["--import", "tsx", "scripts/check-deadcode-exports.mts"],
baseEnv,
),
);
}
if (result.docsOnly) {
return finishPlan("docs-only");
}
addTestTempCreationReport();
const lanes = result.lanes;
const typecheckResult = options.typecheckResult ?? result;
// Release metadata is exempt only with its validating guard, selected by the
// full diff. Filtering trivia must not grant that exemption to package changes.
const typeLanes =
typecheckResult.lanes.releaseMetadata &&
!lanes.releaseMetadata &&
!typecheckResult.paths.every((file) => getChangedPathFacts(file).surface === "docs")
? lanes
: typecheckResult.lanes;
const runAll =
lanes.all ||
(options.lintOnly &&
result.paths.some((file) =>
/^(?:\.oxlint|config\/(?:oxlint|tsconfig\/oxlint)|scripts\/(?:run-oxlint|oxlint)|scripts\/lib\/local-check-runtime\.)|(?:^|\/)tsconfig[^/]*\.json$/u.test(
file,
),
));
const shouldRunAndroidVersionSync = hasAndroidVersionSyncPath(result.paths);
// Typechecking alone accepts extension imports; the graph guard also covers
// shared test/tooling dependencies that core tests can pull into their graph.
const narrowCoreTests = getChangedCoreTestPaths(typecheckResult) !== undefined;
const liveDockerTypes =
typeLanes.liveDockerTooling &&
typecheckResult.paths.some(
(changedPath) => getChangedPathFacts(changedPath).surface === "source",
);
if (
typeLanes.all ||
typeLanes.core ||
typeLanes.coreTests ||
typeLanes.ui ||
typeLanes.tooling ||
liveDockerTypes
) {
add("core tsgo graph boundary", ["lint:tmp:tsgo-core-boundary"]);
if (narrowCoreTests) {
commands.at(-1)!.coreTestCheck = "checkBoundary";
}
}
if (runAll || lanes.scripts || result.paths.includes("scripts/check-script-erasability.mjs")) {
add("script TypeScript erasability", ["check:script-erasability"]);
}
if (lanes.releaseMetadata) {
add("release metadata guard", [
"release-metadata:check",
...(options.staged
? ["--staged", ...(options.base ? ["--base", options.base] : [])]
: ["--base", options.base ?? "origin/main", "--head", options.head ?? "HEAD"]),
]);
// Metadata selectors bind Git/index bytes; artifact checks inspect the working tree.
add("release changelog artifacts", ["changelog:check"]);
add("Android version sync", ["android:version:check"]);
add("config schema baseline", ["config:schema:check"]);
add("root dependency ownership", ["deps:root-ownership:check"]);
return finishPlan("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"]);
} else if (shouldRunControlUiI18nVerify(result.paths)) {
addLint("Control UI i18n catalog", ["lint:ui:i18n"]);
}
if (typeLanes.all) {
addTypecheck("typecheck all", ["tsgo:all"]);
} else {
if (typeLanes.core) {
addTypecheck("typecheck core", ["tsgo:core"]);
}
if (typeLanes.coreTests || liveDockerTypes) {
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
if (narrowCoreTests) {
commands.at(-1)!.coreTestCheck = "checkTypes";
}
}
if (typeLanes.ui) {
addTypecheck("typecheck UI", ["tsgo:ui"]);
}
if (typeLanes.extensions) {
addTypecheck("typecheck extensions", ["tsgo:extensions"]);
}
if (typeLanes.extensionTests) {
addTypecheck("typecheck extension tests", ["tsgo:extensions:test"]);
}
if (typeLanes.scripts) {
addTypecheck("typecheck scripts", ["tsgo:scripts"]);
}
if (typeLanes.testRoot) {
addTypecheck("typecheck test root", ["tsgo:test:root"]);
}
}
if (runAll) {
addLint("lint", ["lint"]);
add("runtime import cycles", ["check:import-cycles"]);
return finishPlan("all");
}
if (lanes.core || lanes.coreTests || lanes.ui) {
// CSS is covered by targeted Stylelint below. Other non-Oxlint core/UI
// inputs keep the full lane so changed checks do not silently drop lint.
const fallbackWithoutTargets = result.paths.some((changedPath) => {
const surface = getChangedPathFacts(changedPath).surface;
return (
(surface === "source" || surface === "package" || surface === "ui") &&
!CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath) &&
!LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath)
);
});
const coreLint = createTargetedCoreLintCommands(result.paths, baseEnv, {
platform: options.platform,
});
if (coreLint) {
for (const command of coreLint) {
addLintCommand(command.name, command.bin, command.args, command.env);
}
} else if (fallbackWithoutTargets) {
addLint("lint core", ["lint:core"]);
}
}
if (lanes.ui) {
const targets = result.paths
.filter(
(changedPath) => LINTABLE_UI_STYLE_PATH_RE.test(changedPath) && existsSync(changedPath),
)
.toSorted((left, right) => left.localeCompare(right));
for (let offset = 0; offset < targets.length; offset += TARGETED_LINT_PATH_LIMIT) {
const batch = targets.slice(offset, offset + TARGETED_LINT_PATH_LIMIT);
addLintCommand(
batch.length === 1 ? "lint UI changed style file" : "lint UI changed style files",
"node",
["--import", "tsx", "scripts/run-stylelint.mts", ...batch],
baseEnv,
);
}
}
if (
lanes.liveDockerTooling &&
result.paths.some((changedPath) => getChangedPathFacts(changedPath).surface === "source")
) {
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",
])
) {
add("lint docker-e2e", ["lint:docker-e2e"]);
add("raw HTTP/2 import guard", ["lint:tmp:no-raw-http2-imports"]);
}
}
if (lanes.apps && !options.lintOnly) {
const appScopes = result.paths
.filter((changedPath) => getChangedPathFacts(changedPath).surface === "app")
.map((changedPath) => detectChangedScope([changedPath]));
// Shared Apple sources select Android consumer CI, but Gradle ktlint owns
// only Android-exclusive app paths. Classify each path so mixed diffs retain both.
if (
appScopes.some(
({ runAndroid, runMacos, runIosBuild }) => runAndroid && !runMacos && !runIosBuild,
)
) {
addLint("lint Android", ["android:lint"]);
}
if (appScopes.some(({ runMacos, runIosBuild }) => runMacos || runIosBuild)) {
if (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 {
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 finishPlan(
Object.entries(lanes)
.filter(([, enabled]) => enabled)
.map(([lane]) => lane)
.join(", "),
);
}
function targetedLintOwner(
command: ChangedCheckCommand,
): "core" | "extensions" | "scripts" | undefined {
if (
command.bin !== "node" ||
command.args[0] !== "scripts/run-oxlint.mjs" ||
command.args[1] !== "--tsconfig"
) {
return undefined;
}
return command.args[2] === CORE_OXLINT_TS_CONFIG
? "core"
: command.args[2] === EXTENSIONS_OXLINT_TS_CONFIG
? "extensions"
: command.args[2] === SCRIPTS_OXLINT_TS_CONFIG
? "scripts"
: undefined;
}
function createCiLintCommands(
selection: CiLintSelection,
threads: 1 | 8,
env: NodeJS.ProcessEnv,
): ChangedCheckCommand[] {
if (selection.files.length === 0) {
return [];
}
const command = (name: string, args: string[]) => ({
name,
bin: "node",
env,
args: [
"--import",
"./scripts/tsx.mjs",
"scripts/run-oxlint-shards.mts",
...args,
`--threads=${threads}`,
"--files-json",
JSON.stringify(selection.files),
],
});
return [
...selection.coreStripes.map((stripe) =>
command(`lint core file stripe ${stripe}`, [
"--only=core",
"--split-core",
`--core-stripe=${stripe}/5`,
]),
),
...selection.extensionStripes.map((stripe) =>
command(`lint extension file stripe ${stripe}`, [
"--only=extensions",
`--extension-stripe=${stripe}/6`,
]),
),
...(selection.groups.length
? [
command(
"lint remaining file groups",
selection.groups.map((group) => `--only=${group}`),
),
]
: []),
];
}
/** Expand changes once; executing rows consume these prepared file facts. */
export async function resolveChangedOxlintFileScope(
changedFiles: readonly string[],
cwd = process.cwd(),
) {
const { createOxlintFileScope, isOxlintSourcePath } = await import("./run-oxlint-shards.mts");
const rootTest = (file: string) =>
/^test\/.+\.[cm]?[jt]sx?$/u.test(file) &&
!/\.d\.[cm]?ts$/u.test(file) &&
!file.split("/").includes("..") &&
existsSync(path.join(cwd, file));
if (!changedFiles.every((file) => isOxlintSourcePath(file, cwd) || rootTest(file))) {
return undefined;
}
// Declarations can affect consumers without import edges. Match the keyword
// so comments between declare and global/module cannot hide an augmentation.
const hasAmbientImpact = (file: string) =>
/\.d\.[cm]?ts$/u.test(file) || /\bdeclare\b/u.test(readFileSync(path.join(cwd, file), "utf8"));
if (changedFiles.some(hasAmbientImpact)) {
return undefined;
}
const consumers = changedFiles.length
? resolveImportGraphDependents(changedFiles, cwd, { tooling: true, resolveAliases: true })
: [];
// An unchanged augmentation can carry a changed type into implicit consumers.
if (consumers.some(hasAmbientImpact)) {
return undefined;
}
const selected = [...new Set([...changedFiles, ...consumers])];
return {
...createOxlintFileScope(
selected.filter((file) => isOxlintSourcePath(file, cwd)),
cwd,
),
rootTestFiles: selected.filter(rootTest).toSorted((left, right) => left.localeCompare(right)),
};
}
/** PRs keep changed files and all transitive consumers on their existing lint owners. */
export async function createChangedCiLintPlan(
result: ChangedLaneResult,
{ runnerProfile }: { runnerProfile: string },
) {
const commands = createChangedCheckPlan(result, { lintOnly: true }).commands;
if (
result.paths.some((file) => path.basename(file) === "package.json") ||
commands.some(({ args }) =>
["lint", "lint:core", "lint:extensions", "lint:scripts"].includes(args[0] ?? ""),
)
) {
return null;
}
const { createOxlintShards, selectCoreOxlintStripe, selectExtensionOxlintStripe } =
await import("./run-oxlint-shards.mts");
const changedFiles = result.paths.filter((file) => /\.[cm]?[jt]sx?$/u.test(file));
const fileScope = await resolveChangedOxlintFileScope(changedFiles);
if (!fileScope) {
return null;
}
const shards = createOxlintShards({ splitCore: true, splitExtensions: true, platform: "linux" });
const selected = fileScope.selectShards(shards);
if (
fileScope.files.some((file) => !selected.some((shard) => shard.args.slice(2).includes(file)))
) {
return null;
}
const central: CiLintSelection = {
files: [],
rootTestFiles: fileScope.rootTestFiles,
coreStripes: [],
extensionStripes: [],
groups: [],
central: true,
};
const core: { stripe: number; lint_selection_json: string }[] = [];
const extensions: { stripe: number; lint_selection_json: string }[] = [];
const coreShards = shards.filter((shard) => shard.name.startsWith("core:"));
const extensionShards = shards.filter((shard) => shard.name.startsWith("extensions:"));
const filesFor = (coreStripes: number[], extensionStripes: number[], groups: string[]) =>
[
...new Set(
fileScope
.selectShards([
...coreStripes.flatMap((index) =>
selectCoreOxlintStripe(coreShards, { index, total: 5 }),
),
...extensionStripes.flatMap((index) =>
selectExtensionOxlintStripe(extensionShards, { index, total: 6 }),
),
...shards.filter((shard) =>
groups.some((group) => shard.name === group || shard.name.startsWith(`${group}:`)),
),
])
.flatMap((shard) => shard.args.slice(2)),
),
].toSorted((left, right) => left.localeCompare(right));
const row = (stripe: number, coreStripes: number[], extensionStripes: number[]) => ({
stripe,
lint_selection_json: JSON.stringify({
files: filesFor(coreStripes, extensionStripes, []),
coreStripes,
extensionStripes,
groups: [],
central: false,
}),
});
if (!["github", "hybrid"].includes(runnerProfile)) {
const groups: CiLintSelection["groups"] = ["core", "extensions", "scripts"];
central.groups = groups.filter((group) =>
selected.some((shard) => shard.name === group || shard.name.startsWith(`${group}:`)),
);
} else {
const coreStripes = [1, 2, 3, 4, 5].filter((stripe) => filesFor([stripe], [], []).length);
const extensionStripes = [1, 2, 3, 4, 5, 6].filter(
(stripe) => filesFor([], [stripe], []).length,
);
if (runnerProfile === "hybrid") {
for (const [index, stripes] of [
coreStripes.filter((stripe) => stripe <= 2),
coreStripes.filter((stripe) => stripe > 2),
].entries()) {
if (stripes.length) {
core.push(row(index + 1, stripes, []));
}
}
for (const stripe of extensionStripes) {
extensions.push(row(stripe, [], [stripe]));
}
} else {
for (const stripe of [1, 2, 3, 4, 5]) {
if (coreStripes.includes(stripe) || extensionStripes.includes(stripe)) {
core.push(
row(
stripe,
coreStripes.includes(stripe) ? [stripe] : [],
extensionStripes.includes(stripe) ? [stripe] : [],
),
);
}
}
central.extensionStripes = extensionStripes.filter((stripe) => stripe === 6);
}
if (selected.some((shard) => shard.name === "scripts")) {
central.groups = ["scripts"];
}
}
central.files = filesFor(central.coreStripes, central.extensionStripes, central.groups);
// Both workflow outputs and process arguments are bounded. A very broad
// closure keeps full lint instead of truncating consumers or overflowing exec.
if (
[
JSON.stringify(central),
...core.map((entry) => entry.lint_selection_json),
...extensions.map((entry) => entry.lint_selection_json),
].some((selection) => Buffer.byteLength(selection) > CORE_LINT_ARGV_BYTES)
) {
return null;
}
return { core, extensions, central };
}
export function createTargetedCoreLintCommands(
paths: string[],
env: NodeJS.ProcessEnv = process.env,
options: TargetedLintOptions & { platform?: NodeJS.Platform } = {},
) {
const command = createTargetedOxlintCommand({
env: resolveLocalCheckEnv(env),
label: "core",
lintablePathRe: LINTABLE_CORE_PATH_RE,
neutralPathRe: CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE,
paths,
tsconfig: CORE_OXLINT_TS_CONFIG,
...options,
});
return command ? batchCoreLintCommand(command, options.platform ?? process.platform) : null;
}
function batchCoreLintCommand(command: TargetedLintCommand, platform: NodeJS.Platform) {
const prefix = command.args.slice(0, 3);
const windows = platform === "win32";
// POSIX uses the formatter's conservative argv budget. Keep Windows batches
// unchanged until the installed cmd/pnpm shim chain has its own budget.
const maxBytes = windows ? Infinity : CORE_LINT_ARGV_BYTES;
const maxPaths = windows ? TARGETED_LINT_PATH_LIMIT : Infinity;
const argumentBytes = (args: string[]) =>
args.reduce((bytes, arg) => bytes + Buffer.byteLength(arg, "utf8") + 1, 0);
const prefixBytes = argumentBytes([command.bin, ...prefix]);
const batches: string[][] = [];
let batch: string[] = [];
let bytes = prefixBytes;
for (const file of command.args.slice(3)) {
const fileBytes = argumentBytes([file]);
if (prefixBytes + fileBytes > maxBytes) {
throw new Error(`Core lint target exceeds the command-line budget: ${file}`);
}
if (batch.length === maxPaths || bytes + fileBytes > maxBytes) {
batches.push(batch);
batch = [];
bytes = prefixBytes;
}
batch.push(file);
bytes += fileBytes;
}
if (batch.length) {
batches.push(batch);
}
return batches.map((files) => ({
name: files.length === 1 ? "lint core changed file" : "lint core changed files",
bin: command.bin,
args: [...prefix, ...files],
env: command.env,
}));
}
export function createTargetedExtensionLintCommand(
paths: string[],
env: NodeJS.ProcessEnv = process.env,
options: TargetedLintOptions = {},
) {
return createTargetedOxlintCommand({
env,
label: "extension",
lintablePathRe: LINTABLE_EXTENSION_PATH_RE,
neutralPathRe: TOOLING_LINT_OPTIMIZATION_NEUTRAL_PATH_RE,
paths,
tsconfig: EXTENSIONS_OXLINT_TS_CONFIG,
maxPaths: TARGETED_LINT_PATH_LIMIT,
...options,
});
}
function createTargetedScriptLintCommand(
paths: string[],
env: NodeJS.ProcessEnv = process.env,
options: TargetedLintOptions = {},
) {
return createTargetedOxlintCommand({
env,
label: "script",
lintablePathRe: LINTABLE_SCRIPT_PATH_RE,
neutralPathRe: TOOLING_LINT_OPTIMIZATION_NEUTRAL_PATH_RE,
paths,
tsconfig: SCRIPTS_OXLINT_TS_CONFIG,
maxPaths: TARGETED_LINT_PATH_LIMIT,
...options,
});
}
function createTargetedOxlintCommand({
env = process.env,
fileExists = existsSync,
label,
lintablePathRe,
neutralPathRe,
paths,
tsconfig,
maxPaths,
}: TargetedOxlintCommandOptions) {
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) &&
!getChangedPathFacts(changedPath).isRootTestSource &&
!neutralPathRe.test(changedPath) &&
!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 || (maxPaths !== undefined && targets.length > maxPaths)) {
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,
};
}
export async function runChangedCheck(
result: ChangedLaneResult,
options: ChangedCheckRunOptions = {},
) {
if (result.paths.length === 0) {
console.error("[check:changed] no changed paths; nothing to run");
return 0;
}
await ensureChangedCheckRuntimeDependencies(result.paths);
const childEnv = resolveLocalCheckEnv(options.env ?? process.env);
const plan = createChangedCheckPlan(result, {
...options,
env: childEnv,
});
printPlan(result, plan, options);
if (options.dryRun) {
return 0;
}
const coreTestCheck = plan.commands.some((command) => command.coreTestCheck)
? (await import("./run-tsgo-core-test-shards.mts")).createChangedCoreTestCheck(
getChangedCoreTestPaths(options.typecheckResult ?? result)!,
createSparseTsgoSkipEnv(childEnv),
)
: undefined;
const timings: ChangedCheckTiming[] = [];
for (const command of plan.commands) {
const status = await runPlanCommand(command, timings, coreTestCheck);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
printSummary(timings, options);
return 0;
}
function sameArgs(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function printPlan(
result: ChangedLaneResult,
plan: ReturnType<typeof createChangedCheckPlan>,
options: ChangedCheckRunOptions,
) {
const prefix = options.dryRun ? "[check:changed:dry-run]" : "[check:changed]";
console.error(`${prefix} lanes=${plan.summary || "none"}`);
if (options.typecheckResult) {
const retained = new Set(options.typecheckResult.paths);
const inert = result.paths.filter((file) => !retained.has(file));
if (inert.length) {
console.error(
`${prefix} comment/whitespace-only TypeScript changes; typecheck lanes skip: ${inert.slice(0, 8).join(", ")}${inert.length > 8 ? ` (+${inert.length - 8} more)` : ""}`,
);
}
}
if ((options.typecheckResult ?? 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: ChangedCheckCommand, timings: ChangedCheckTiming[]) {
return await runCommand(createPnpmManagedCommand(command), timings);
}
async function runPlanCommand(
command: ChangedCheckCommand,
timings: ChangedCheckTiming[],
coreTestCheck?: ReturnType<typeof createChangedCoreTestCheck>,
) {
if (command.coreTestCheck && coreTestCheck) {
return await runCommand(
createPnpmManagedCommand(command),
timings,
coreTestCheck[command.coreTestCheck],
);
}
if (command.bin) {
return await runCommand({ ...command, bin: command.bin }, timings);
}
return await runPnpm(command, timings);
}
function formatPlanCommand(command: ChangedCheckCommand) {
const argv = command.bin ? [command.bin, ...command.args] : ["pnpm", ...command.args];
return argv.map(formatShellToken).join(" ");
}
function formatShellToken(token: string) {
return /^[A-Za-z0-9_./:@=-]+$/u.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`;
}
export function createPnpmManagedCommand<T extends ChangedCheckCommand>(
command: T,
env: NodeJS.ProcessEnv = process.env,
) {
const commandEnv = command.env ?? resolveLocalCheckEnv(env);
if (isOpenEndedTruthyValue(commandEnv.CI) || isOpenEndedTruthyValue(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: NodeJS.ProcessEnv) {
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: ChangedCheckCommand & { bin: string },
timings: ChangedCheckTiming[],
run?: () => Promise<number>,
) {
const startedAt = performance.now();
console.error(`\n[check:changed] ${command.name}`);
let status = 1;
try {
status = run
? await run()
: await runManagedCommand({
bin: command.bin,
args: command.args,
env: command.env ?? resolveLocalCheckEnv(),
});
} catch (error) {
console.error(error);
}
timings.push({
name: command.name,
durationMs: performance.now() - startedAt,
status,
});
return status;
}
function printSummary(timings: ChangedCheckTiming[], options: ChangedCheckRunOptions) {
printTimingSummary("check:changed", timings, { skipWhenAllOk: !options.timed });
}
function parseArgs(argv: string[]) {
const separatorIndex = argv.indexOf("--");
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const explicitPaths = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
const preservePathTokens = flagArgv.includes("--paths-from-git");
const args: {
base?: string;
head: string;
staged: boolean;
dryRun: boolean;
timed: boolean;
noChanges: boolean;
help: boolean;
paths: string[];
} = {
head: "HEAD",
staged: false,
dryRun: false,
timed: false,
noChanges: false,
help: false,
paths: new Array<string>(),
};
const parsed = parseFlagArgs(
flagArgv.filter((arg) => arg !== "--paths-from-git"),
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(arg);
return "handled";
},
},
);
parsed.paths.push(...explicitPaths);
if (!preservePathTokens) {
parsed.paths = parsed.paths.map((changedPath) => normalizeChangedPath(changedPath));
}
return parsed;
}
function printUsage() {
process.stdout.write(
[
"Usage: node scripts/check-changed.mjs [options] [-- <paths...>]",
"",
"Options:",
" --base <ref> Base ref (default: HEAD with --staged, otherwise 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);
}
async function main() {
const argv = process.argv.slice(2);
let args: ReturnType<typeof parseArgs>;
try {
args = parseArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
return;
}
if (args.help) {
printUsage();
process.exitCode = 0;
} else {
let paths: string[] | undefined;
try {
paths = args.noChanges
? []
: args.paths.length > 0
? args.paths
: args.staged
? listStagedChangedPaths(undefined, args.base)
: listChangedPathsFromGit({ base: args.base ?? "origin/main", 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 ?? (args.staged ? "HEAD" : "origin/main"),
head: args.head,
staged: args.staged,
});
let typecheckResult: ChangedLaneResult | undefined;
if (
!args.staged &&
!args.noChanges &&
args.paths.length === 0 &&
!argv.includes("--") &&
args.head === "HEAD"
) {
const { findTypecheckInertPaths } = await import("./lib/typecheck-inert.mts");
const inert = new Set(findTypecheckInertPaths({ paths, base: args.base ?? "origin/main" }));
if (inert.size) {
typecheckResult = detectChangedLanesForPaths({
paths: paths.filter((file) => !inert.has(file)),
base: args.base ?? "origin/main",
head: args.head,
});
}
}
if (
shouldDelegateChangedCheckToCrabbox(argv, process.env, {
result,
diffRefsReady: result.lanes.releaseMetadata
? args.staged ||
changedCheckDiffRefsReady({
base: args.base ?? "origin/main",
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] the remote backend 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,
typecheckResult,
explicitPaths: args.paths.length > 0,
})
: delegated.exitCode;
} else {
process.exitCode = await runChangedCheck(result, {
...args,
typecheckResult,
explicitPaths: args.paths.length > 0,
});
}
}
}
}
if (isDirectRun()) {
await runWithFailedTrailer("check:changed", main);
}