Files
oxc-project__oxc/.github/scripts/check-changes.js
leaysgur dad42879c7 chore(ci): run measurement jobs when Cargo.lock or rust-toolchain changes (#24199)
### Summary

The include-mode filter in `check-changes.js` short-circuits to "skip" when no changed files are under `crates/`, so it never reaches the cargo tree dependency check for `Cargo.lock`-only dependency updates or `rust-toolchain.toml` bumps. Conformance, Minsize, Allocations, and Linter timings are all gated on this filter.

This is how #24192 (`num-bigint` 0.4.6 → 0.5.1) landed with a stale parser allocation snapshot: the update changed BigInt-literal parsing allocation counts, the Allocations job never re-measured on main, and the mismatch surfaced on an unrelated PR (#24098). The snapshot itself is fixed in #24198.

This PR treats `Cargo.lock` and `rust-toolchain.toml` changes as affecting every crate, so measurement jobs always re-run on dependency and toolchain updates. The cost is that renovate dependency PRs now run these four jobs; dependency updates are exactly the class of change that can shift measured behavior, so that is the intended behavior. Exclude-mode and paths-only jobs are unaffected (exclude mode already runs on such changes by construction).

### Trade-off

For Conformance specifically this closes a correctness gap, not just a snapshot-staleness one: a dependency update that changes parser/transformer behavior currently skips conformance entirely.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-06 07:49:06 +00:00

220 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
// oxlint-disable no-console
/**
* Generic change detection script for CI jobs.
*
* Include mode: checks if changes affect specified crates or their transitive dependencies.
* node check-changes.js --packages oxc_minifier --paths tasks/minsize/
*
* Exclude mode: skips only if ALL changed files belong to excluded crate directories.
* node check-changes.js --exclude oxc_linter,oxc_language_server --paths napi/,npm/
*
* Paths-only mode: run if any changed file matches the given paths (no cargo tree).
* node check-changes.js --paths apps/oxlint/,npm/oxlint/,napi/oxlint/
*/
const { getChangedFiles } = require("./get-changed-files.js");
const { getCrateDependencies, checkFilesAffectCrates } = require("./utils.js");
/**
* Parse CLI arguments into structured options.
* @returns {{ packages: string[], exclude: string[], paths: string[] }}
*/
function parseArgs() {
const args = process.argv.slice(2);
const result = { packages: [], exclude: [], paths: [] };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--packages" && args[i + 1]) {
result.packages = args[++i].split(",").filter(Boolean);
} else if (arg === "--exclude" && args[i + 1]) {
result.exclude = args[++i].split(",").filter(Boolean);
} else if (arg === "--paths" && args[i + 1]) {
result.paths = args[++i].split(",").filter(Boolean);
}
}
return result;
}
/**
* Include mode: run if changes affect any of the specified crates (+ transitive deps) or paths.
* @param {string[] | null} changedFiles
* @param {string[]} packages - Root crate names
* @param {string[]} paths - Additional trigger paths
* @returns {boolean}
*/
function shouldRunInclude(changedFiles, packages, paths) {
if (changedFiles === null) {
console.error("No changed files list (manual trigger or error) - will run");
return true;
}
if (changedFiles.length === 0) {
console.error("No files changed - will skip");
return false;
}
// Check additional trigger paths first (no cargo tree needed)
for (const file of changedFiles) {
for (const p of paths) {
if (file.startsWith(p)) {
console.error(`File ${file} matches trigger path ${p} - will run`);
return true;
}
}
}
// Dependency or toolchain updates can change any crate's behavior
// (e.g. num-bigint 0.5 changed parser allocation counts) — always run.
if (
changedFiles.some(
(file) => file === "Cargo.lock" || file === "rust-toolchain.toml" || file === "Cargo.toml",
)
) {
console.error("Cargo.lock or rust-toolchain.toml changed - will run");
return true;
}
// If no changed files are in crates/, cargo tree check cannot match — skip it
if (!changedFiles.some((file) => file.startsWith("crates/"))) {
console.error("No files changed in crates/ - will skip");
return false;
}
// Resolve transitive dependencies via cargo tree
let allCrates;
try {
const deps = getCrateDependencies(packages);
allCrates = [...packages, ...deps];
console.error(`Checking crates (${allCrates.length}): ${allCrates.join(", ")}`);
} catch (error) {
console.error(`cargo tree failed: ${error.message} - will run as fallback`);
return true;
}
if (allCrates.length === 0) {
console.error("Warning: No crates resolved - will run as fallback");
return true;
}
return checkFilesAffectCrates(changedFiles, allCrates);
}
/**
* Exclude mode: skip only if ALL changed files belong to excluded crate directories.
* Files matching `paths` always trigger the job. No cargo tree — exclusion is intentionally shallow.
* @param {string[] | null} changedFiles
* @param {string[]} excludeCrates - Crate names to exclude
* @param {string[]} paths - Additional paths that always trigger the job
* @returns {boolean}
*/
function shouldRunExclude(changedFiles, excludeCrates, paths) {
if (changedFiles === null) {
console.error("No changed files list (manual trigger or error) - will run");
return true;
}
if (changedFiles.length === 0) {
console.error("No files changed - will skip");
return false;
}
// Check always-trigger paths first
for (const file of changedFiles) {
for (const p of paths) {
if (file.startsWith(p)) {
console.error(`File ${file} matches always-trigger path ${p} - will run`);
return true;
}
}
}
const excludePaths = excludeCrates.map((c) => `crates/${c}/`);
// Skip only if EVERY changed file is inside an excluded crate directory
const allExcluded = changedFiles.every((file) => excludePaths.some((ep) => file.startsWith(ep)));
if (allExcluded) {
console.error(
`All ${changedFiles.length} changed files are in excluded crates (${excludeCrates.join(", ")}) - will skip`,
);
return false;
}
console.error("Changes found outside excluded crates - will run");
return true;
}
/**
* Paths-only mode: run if any changed file matches any of the given paths.
* Used by jobs that don't need cargo tree dependency checking.
*/
function shouldRunPathsOnly(changedFiles, paths) {
if (changedFiles === null) {
console.error("No changed files list (manual trigger or error) - will run");
return true;
}
if (changedFiles.length === 0) {
console.error("No files changed - will skip");
return false;
}
for (const file of changedFiles) {
for (const p of paths) {
if (file.startsWith(p)) {
console.error(`File ${file} matches trigger path ${p} - will run`);
return true;
}
}
}
console.error("No files match trigger paths - will skip");
return false;
}
async function main() {
try {
const opts = parseArgs();
if (opts.packages.length === 0 && opts.exclude.length === 0 && opts.paths.length === 0) {
console.error("Error: must specify --packages, --exclude, or --paths");
console.log("true");
process.exit(0);
}
const changedFiles = await getChangedFiles();
let shouldRun;
if (opts.exclude.length > 0) {
shouldRun = shouldRunExclude(changedFiles, opts.exclude, opts.paths);
} else if (opts.packages.length > 0) {
shouldRun = shouldRunInclude(changedFiles, opts.packages, opts.paths);
} else {
shouldRun = shouldRunPathsOnly(changedFiles, opts.paths);
}
console.log(shouldRun ? "true" : "false");
if (shouldRun) {
console.error("::notice title=Change detection::Will run this job");
} else {
console.error("::notice title=Change detection::Will skip this job");
}
process.exit(0);
} catch (error) {
console.error("Error in change detection:", error);
// On error, run as a fallback
console.log("true");
console.error("::warning title=Change detection error::Error occurred, running as fallback");
process.exit(0);
}
}
void main();