fix(tooling): harden changed checks for sparse worktrees

This commit is contained in:
Vincent Koc
2026-04-25 00:48:18 -07:00
parent 1ca029e888
commit 73d72204a0
5 changed files with 180 additions and 3 deletions

View File

@@ -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 = {}) {

View File

@@ -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"
? () => {}

View File

@@ -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"]],

View File

@@ -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");
});
});

View File

@@ -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: [],
});
});
});