diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 5bf1b3b0033..ecbf6470149 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -8,6 +8,7 @@ import { import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; import { printTimingSummary } from "./lib/check-timing-summary.mjs"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; +import { isCiLikeEnv } from "./lib/vitest-local-scheduling.mjs"; import { resolveChangedTestTargetPlan } from "./test-projects.test-support.mjs"; export const CHANGED_CHECK_VITEST_NO_OUTPUT_TIMEOUT_MS = "600000"; @@ -15,13 +16,30 @@ const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS"; const VITEST_NO_OUTPUT_RETRY_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_RETRY"; export function createChangedCheckVitestEnv(baseEnv = process.env) { - return { + const env = { ...baseEnv, [VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY]: baseEnv[VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY]?.trim() || CHANGED_CHECK_VITEST_NO_OUTPUT_TIMEOUT_MS, [VITEST_NO_OUTPUT_RETRY_ENV_KEY]: baseEnv[VITEST_NO_OUTPUT_RETRY_ENV_KEY]?.trim() || "0", }; + + const hasWorkerOverride = Boolean( + (baseEnv.OPENCLAW_VITEST_MAX_WORKERS ?? baseEnv.OPENCLAW_TEST_WORKERS)?.trim(), + ); + const hasParallelOverride = Boolean(baseEnv.OPENCLAW_TEST_PROJECTS_PARALLEL?.trim()); + const serialOverride = baseEnv.OPENCLAW_TEST_PROJECTS_SERIAL?.trim(); + if ( + !isCiLikeEnv(baseEnv) && + !hasWorkerOverride && + !hasParallelOverride && + serialOverride !== "0" + ) { + env.OPENCLAW_TEST_PROJECTS_SERIAL = serialOverride || "1"; + env.OPENCLAW_VITEST_MAX_WORKERS = "1"; + } + + return env; } export function createChangedCheckPlan(result, options = {}) { diff --git a/scripts/run-oxlint.mjs b/scripts/run-oxlint.mjs index cfbdf9cdc02..fb400bea29e 100644 --- a/scripts/run-oxlint.mjs +++ b/scripts/run-oxlint.mjs @@ -1,3 +1,5 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { acquireLocalHeavyCheckLockSync, @@ -20,10 +22,105 @@ const OXLINT_PREPARE_SKIP_FLAGS = new Set([ "--init", "--lsp", ]); +const OXLINT_VALUE_FLAGS = new Set([ + "--config", + "--deny", + "--env", + "--format", + "--globals", + "--ignore-path", + "--max-warnings", + "--output-file", + "--plugin", + "--rules", + "--tsconfig", + "--warn", +]); + export function shouldPrepareExtensionPackageBoundaryArtifacts(args) { return !args.some((arg) => OXLINT_PREPARE_SKIP_FLAGS.has(arg)); } +export function filterSparseMissingOxlintTargets( + args, + { + cwd = process.cwd(), + fileExists = fs.existsSync, + isSparseCheckoutEnabled = getSparseCheckoutEnabled, + isTrackedPath = hasTrackedPath, + } = {}, +) { + if (!isSparseCheckoutEnabled({ cwd })) { + return { args, hadExplicitTargets: false, remainingExplicitTargets: 0, skippedTargets: [] }; + } + + const filteredArgs = []; + const skippedTargets = []; + let hadExplicitTargets = false; + let remainingExplicitTargets = 0; + let consumeNextValue = false; + + for (const arg of args) { + if (consumeNextValue) { + filteredArgs.push(arg); + consumeNextValue = false; + continue; + } + + if (arg === "--") { + filteredArgs.push(arg); + continue; + } + + if (arg.startsWith("--")) { + filteredArgs.push(arg); + if (!arg.includes("=") && OXLINT_VALUE_FLAGS.has(arg)) { + consumeNextValue = true; + } + continue; + } + + if (arg.startsWith("-")) { + filteredArgs.push(arg); + continue; + } + + hadExplicitTargets = true; + const absoluteTarget = path.resolve(cwd, arg); + if (!fileExists(absoluteTarget) && isTrackedPath({ cwd, target: arg })) { + skippedTargets.push(arg); + continue; + } + + remainingExplicitTargets += 1; + filteredArgs.push(arg); + } + + return { args: filteredArgs, hadExplicitTargets, remainingExplicitTargets, skippedTargets }; +} + +function getSparseCheckoutEnabled({ cwd }) { + const result = spawnSync("git", ["config", "--get", "--bool", "core.sparseCheckout"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + return result.status === 0 && result.stdout.trim() === "true"; +} + +function hasTrackedPath({ cwd, target }) { + const result = spawnSync("git", ["ls-files", "--", target], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + return result.status === 0 && result.stdout.trim().length > 0; +} + async function prepareExtensionPackageBoundaryArtifacts(env) { const releaseArtifactsLock = acquireLocalHeavyCheckLockSync({ cwd: process.cwd(), @@ -50,7 +147,19 @@ async function prepareExtensionPackageBoundaryArtifacts(env) { } export async function main(argv = process.argv.slice(2), runtimeEnv = process.env) { - const { args: finalArgs, env } = applyLocalOxlintPolicy(argv, runtimeEnv); + const { args: policyArgs, env } = applyLocalOxlintPolicy(argv, runtimeEnv); + const sparseTargets = filterSparseMissingOxlintTargets(policyArgs); + const finalArgs = sparseTargets.args; + if (sparseTargets.skippedTargets.length > 0) { + console.error( + `[oxlint] sparse checkout is missing tracked target(s); skipping ${sparseTargets.skippedTargets.join(", ")}`, + ); + } + if (sparseTargets.hadExplicitTargets && sparseTargets.remainingExplicitTargets === 0) { + console.error("[oxlint] no present sparse-checkout targets remain; skipping oxlint."); + return; + } + const releaseLock = env.OPENCLAW_OXLINT_SKIP_LOCK === "1" ? () => {} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index bf1289a6945..e8baedd4d3a 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -232,6 +232,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/run-vitest.mjs", ["test/scripts/test-projects.test.ts", "test/scripts/vitest-local-scheduling.test.ts"], ], + ["scripts/run-oxlint.mjs", ["test/scripts/run-oxlint.test.ts"]], ["scripts/test-extension-batch.mjs", ["test/scripts/test-extension.test.ts"]], ["scripts/lib/extension-test-plan.mjs", ["test/scripts/test-extension.test.ts"]], ["scripts/lib/vitest-batch-runner.mjs", ["test/scripts/test-extension.test.ts"]], diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 0eb0e2acf8d..618ab07a932 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -370,6 +370,8 @@ describe("scripts/changed-lanes", () => { PATH: "/usr/bin", OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: CHANGED_CHECK_VITEST_NO_OUTPUT_TIMEOUT_MS, OPENCLAW_VITEST_NO_OUTPUT_RETRY: "0", + OPENCLAW_TEST_PROJECTS_SERIAL: "1", + OPENCLAW_VITEST_MAX_WORKERS: "1", }); expect( @@ -382,4 +384,16 @@ describe("scripts/changed-lanes", () => { OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1", }); }); + + it("does not force serial changed-check tests in CI or when workers are explicit", () => { + expect(createChangedCheckVitestEnv({ CI: "true" })).not.toHaveProperty( + "OPENCLAW_VITEST_MAX_WORKERS", + ); + expect(createChangedCheckVitestEnv({ OPENCLAW_VITEST_MAX_WORKERS: "4" })).toMatchObject({ + OPENCLAW_VITEST_MAX_WORKERS: "4", + }); + expect( + createChangedCheckVitestEnv({ OPENCLAW_TEST_PROJECTS_PARALLEL: "4" }), + ).not.toHaveProperty("OPENCLAW_TEST_PROJECTS_SERIAL"); + }); }); diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index 354d4d40056..e8292f200ee 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -1,6 +1,9 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { shouldPrepareExtensionPackageBoundaryArtifacts } from "../../scripts/run-oxlint.mjs"; +import { + filterSparseMissingOxlintTargets, + shouldPrepareExtensionPackageBoundaryArtifacts, +} from "../../scripts/run-oxlint.mjs"; describe("run-oxlint", () => { it("prepares extension package boundary artifacts for normal lint runs", () => { @@ -30,4 +33,36 @@ describe("run-oxlint", () => { expect(shardedLintRunner).toContain("prepare-extension-package-boundary-artifacts.mjs"); expect(shardedLintRunner).toContain('OPENCLAW_OXLINT_SKIP_PREPARE: "1"'); }); + + it("filters tracked targets missing from sparse checkouts", () => { + const result = filterSparseMissingOxlintTargets( + ["--tsconfig", "tsconfig.oxlint.core.json", "src", "ui", "packages", "--threads=1"], + { + fileExists: (target: string) => target.endsWith("/src"), + isSparseCheckoutEnabled: () => true, + isTrackedPath: ({ target }: { target: string }) => target === "ui" || target === "packages", + }, + ); + + expect(result).toEqual({ + args: ["--tsconfig", "tsconfig.oxlint.core.json", "src", "--threads=1"], + hadExplicitTargets: true, + remainingExplicitTargets: 1, + skippedTargets: ["ui", "packages"], + }); + }); + + it("keeps missing untracked oxlint targets so typos still fail", () => { + const result = filterSparseMissingOxlintTargets(["src", "typo"], { + fileExists: (target: string) => target.endsWith("/src"), + isSparseCheckoutEnabled: () => true, + isTrackedPath: () => false, + }); + + expect(result).toMatchObject({ + args: ["src", "typo"], + remainingExplicitTargets: 2, + skippedTargets: [], + }); + }); });