From 788b47536c8622bea8fd4481a61f5d13b73e1530 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 15:44:13 +0100 Subject: [PATCH] feat: add changed-lane local gate --- docs/ci.md | 4 + docs/help/testing.md | 3 + docs/reference/test.md | 3 + git-hooks/pre-commit | 4 +- package.json | 2 + scripts/AGENTS.md | 1 + scripts/changed-lanes.mjs | 300 +++++++++++++++++++++++++++++ scripts/check-changed.mjs | 274 ++++++++++++++++++++++++++ test/git-hooks-pre-commit.test.ts | 32 ++- test/scripts/changed-lanes.test.ts | 205 ++++++++++++++++++++ 10 files changed, 823 insertions(+), 5 deletions(-) create mode 100644 scripts/changed-lanes.mjs create mode 100644 scripts/check-changed.mjs create mode 100644 test/scripts/changed-lanes.test.ts diff --git a/docs/ci.md b/docs/ci.md index df00c6504d6..80ad7a774d6 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -44,6 +44,8 @@ Jobs are ordered so cheap checks fail before expensive ones run: Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It computes `run_install_smoke` from the narrower changed-smoke signal, so Docker/install smoke only runs for install, packaging, and container-relevant changes. +Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod typecheck plus core tests, core test-only changes run only core test typecheck/tests, extension production changes run extension prod typecheck plus extension tests, and extension test-only changes run only extension test typecheck/tests. Public Plugin SDK or plugin-contract changes expand to extension validation because extensions depend on those core contracts. Unknown root/config changes fail safe to all lanes. + On pushes, the `checks` matrix adds the push-only `compat-node22` lane. On pull requests, that lane is skipped and the matrix stays focused on the normal test/channel lanes. ## Runners @@ -57,6 +59,8 @@ On pushes, the `checks` matrix adds the push-only `compat-node22` lane. On pull ## Local Equivalents ```bash +pnpm changed:lanes # inspect the local changed-lane classifier for origin/main...HEAD +pnpm check:changed # smart local gate: changed typecheck/lint/tests by boundary lane pnpm check # fast local gate: production tsgo + sharded lint + parallel fast guards pnpm check:test-types pnpm check:timed # same gate with per-stage timings diff --git a/docs/help/testing.md b/docs/help/testing.md index 230b9c3eb69..9edff0f4165 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -277,6 +277,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical. - `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax. - `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun. + - `pnpm check:changed` is the normal smart local gate for narrow work. It classifies the diff into core, core tests, extensions, extension tests, apps, docs, and tooling, then runs the matching typecheck/lint/test lanes. Public Plugin SDK and plugin-contract changes include extension validation because extensions depend on those core contracts. - Import-light unit tests from agents, commands, plugins, auto-reply helpers, `plugin-sdk`, and similar pure utility areas route through the `unit-fast` lane, which skips `test/setup-openclaw-runtime.ts`; stateful/runtime-heavy files stay on the existing lanes. - Selected `plugin-sdk` and `commands` helper source files also map changed-mode runs to explicit sibling tests in those light lanes, so helper edits avoid rerunning the full heavy suite for that directory. - `auto-reply` now has three dedicated buckets: top-level core helpers, top-level `reply.*` integration tests, and the `src/auto-reply/reply/**` subtree. This keeps the heaviest reply harness work off the cheap status/chunk/token tests. @@ -298,6 +299,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Each `pnpm test` shard inherits the same `threads` + `isolate: false` defaults from the shared Vitest config. - The shared `scripts/run-vitest.mjs` launcher now also adds `--no-maglev` for Vitest child Node processes by default to reduce V8 compile churn during big local runs. Set `OPENCLAW_VITEST_ENABLE_MAGLEV=1` if you need to compare against stock V8 behavior. - Fast-local iteration note: + - `pnpm changed:lanes` shows which architectural lanes a diff triggers. + - The pre-commit hook runs `pnpm check:changed --staged` after staged formatting/linting, so core-only commits do not pay extension test cost unless they touch public extension-facing contracts. - `pnpm test:changed` routes through scoped lanes when the changed paths map cleanly to a smaller suite. - `pnpm test:max` and `pnpm test:changed:max` keep the same routing behavior, just with a higher worker cap. - Local worker auto-scaling is intentionally conservative now and also backs off when the host load average is already high, so multiple concurrent Vitest runs do less damage by default. diff --git a/docs/reference/test.md b/docs/reference/test.md index 829de18c930..5c78b81db0b 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -13,6 +13,8 @@ title: "Tests" - `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic. - `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`. - `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed. +- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`. +- `pnpm check:changed`: runs the smart changed gate for the diff against `origin/main`. It runs core work with core test lanes, extension work with extension test lanes, test-only work with test typecheck/tests only, and expands public Plugin SDK or plugin-contract changes to extension validation. - `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eleven sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-ui.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-support-boundary.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-bundled.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process. - Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes. - Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites. @@ -37,6 +39,7 @@ title: "Tests" For local PR land/gate checks, run: +- `pnpm check:changed` - `pnpm check` - `pnpm check:test-types` - `pnpm build` diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index 42e6c415193..48106792874 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -67,13 +67,13 @@ if [[ -f "$ROOT_DIR/package.json" ]] && [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]]; the cd "$ROOT_DIR" case "${FAST_COMMIT:-}" in 1|true|TRUE|yes|YES|on|ON) - echo "FAST_COMMIT enabled: skipping pnpm check in pre-commit hook." + echo "FAST_COMMIT enabled: skipping changed-scope check in pre-commit hook." ;; *) if [[ "$docs_only" == true ]]; then echo "Docs-only staged changes detected: skipping pnpm check in pre-commit hook." else - pnpm check + pnpm check:changed --staged fi ;; esac diff --git a/package.json b/package.json index 47897a0b35f..171c8a045af 100644 --- a/package.json +++ b/package.json @@ -1248,10 +1248,12 @@ "canon:check:json": "node scripts/canon.mjs check --json", "canon:enforce": "node scripts/canon.mjs enforce --json", "canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs", + "changed:lanes": "node scripts/changed-lanes.mjs", "check": "node scripts/check.mjs", "check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles", "check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check", "check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", + "check:changed": "node scripts/check-changed.mjs", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:import-cycles": "node --import tsx scripts/check-import-cycles.ts", diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 258d5bcfda7..fc3b224040f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -7,6 +7,7 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe - Prefer existing wrappers over raw tool entrypoints when the repo already has a curated seam. - For tests, prefer `scripts/run-vitest.mjs` or the root `pnpm test ...` entrypoints over raw `vitest run` calls. - For lint/typecheck flows, prefer `scripts/run-oxlint.mjs` and `scripts/run-tsgo.mjs` when adding or editing package scripts or CI steps that should honor repo-local runtime behavior. +- For changed-file verification, prefer `scripts/check-changed.mjs` and keep lane classification in `scripts/changed-lanes.mjs`. Do not copy path-scope rules into new hooks or ad hoc CI snippets. ## Local Heavy-Check Lock diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs new file mode 100644 index 00000000000..92263af7189 --- /dev/null +++ b/scripts/changed-lanes.mjs @@ -0,0 +1,300 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; + +const DOCS_PATH_RE = /^(?:docs\/|README\.md$|AGENTS\.md$|.*\.mdx?$)/u; +const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u; +const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u; +const CORE_PATH_RE = /^(?:src\/|ui\/|packages\/)/u; +const TOOLING_PATH_RE = + /^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.oxlint.*|\.oxfmt.*)/u; +const ROOT_GLOBAL_PATH_RE = + /^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u; +const TEST_PATH_RE = + /(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|e2e|browser\.test)\.[cm]?[jt]sx?$/u; +const PUBLIC_EXTENSION_CONTRACT_RE = + /^(?:src\/plugin-sdk\/|src\/plugins\/contracts\/|src\/channels\/plugins\/|scripts\/lib\/plugin-sdk-entrypoints\.json$|scripts\/sync-plugin-sdk-exports\.mjs$|scripts\/generate-plugin-sdk-api-baseline\.ts$)/u; + +/** @typedef {"core" | "coreTests" | "extensions" | "extensionTests" | "apps" | "docs" | "tooling" | "all"} ChangedLane */ + +/** + * @typedef {{ + * paths: string[]; + * lanes: Record; + * extensionImpactFromCore: boolean; + * docsOnly: boolean; + * reasons: string[]; + * }} ChangedLaneResult + */ + +export function normalizeChangedPath(inputPath) { + return String(inputPath ?? "") + .trim() + .replaceAll("\\", "/") + .replace(/^\.\/+/u, ""); +} + +export function createEmptyChangedLanes() { + return { + core: false, + coreTests: false, + extensions: false, + extensionTests: false, + apps: false, + docs: false, + tooling: false, + all: false, + }; +} + +/** + * @param {string[]} changedPaths + * @returns {ChangedLaneResult} + */ +export function detectChangedLanes(changedPaths) { + const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))].toSorted( + (left, right) => left.localeCompare(right), + ); + const lanes = createEmptyChangedLanes(); + const reasons = []; + let extensionImpactFromCore = false; + let hasNonDocs = false; + + if (paths.length === 0) { + reasons.push("no changed paths"); + return { paths, lanes, extensionImpactFromCore: false, docsOnly: false, reasons }; + } + + for (const changedPath of paths) { + if (DOCS_PATH_RE.test(changedPath)) { + lanes.docs = true; + continue; + } + + hasNonDocs = true; + + if (ROOT_GLOBAL_PATH_RE.test(changedPath)) { + lanes.all = true; + extensionImpactFromCore = true; + reasons.push(`${changedPath}: root config/package surface`); + continue; + } + + if (PUBLIC_EXTENSION_CONTRACT_RE.test(changedPath)) { + lanes.core = true; + lanes.coreTests = true; + lanes.extensions = true; + lanes.extensionTests = true; + extensionImpactFromCore = true; + reasons.push(`${changedPath}: public core/plugin contract affects extensions`); + continue; + } + + if (EXTENSION_PATH_RE.test(changedPath)) { + if (TEST_PATH_RE.test(changedPath)) { + lanes.extensionTests = true; + reasons.push(`${changedPath}: extension test`); + } else { + lanes.extensions = true; + lanes.extensionTests = true; + reasons.push(`${changedPath}: extension production`); + } + continue; + } + + if (CORE_PATH_RE.test(changedPath)) { + if (TEST_PATH_RE.test(changedPath)) { + lanes.coreTests = true; + reasons.push(`${changedPath}: core test`); + } else { + lanes.core = true; + lanes.coreTests = true; + reasons.push(`${changedPath}: core production`); + } + continue; + } + + if (APP_PATH_RE.test(changedPath)) { + lanes.apps = true; + reasons.push(`${changedPath}: app surface`); + continue; + } + + if (changedPath.startsWith("test/")) { + lanes.tooling = true; + reasons.push(`${changedPath}: root test/support surface`); + continue; + } + + if (TOOLING_PATH_RE.test(changedPath)) { + lanes.tooling = true; + reasons.push(`${changedPath}: tooling surface`); + continue; + } + + lanes.all = true; + extensionImpactFromCore = true; + reasons.push(`${changedPath}: unknown surface; fail-safe all lanes`); + } + + return { + paths, + lanes, + extensionImpactFromCore, + docsOnly: lanes.docs && !hasNonDocs, + reasons, + }; +} + +/** + * @param {{ base: string; head?: string; includeWorktree?: boolean }} params + * @returns {string[]} + */ +export function listChangedPathsFromGit(params) { + const base = params.base; + const head = params.head ?? "HEAD"; + if (!base) { + return []; + } + const rangePaths = runGitNameOnlyDiff([`${base}...${head}`]); + if (params.includeWorktree === false) { + return rangePaths; + } + return [ + ...new Set([ + ...rangePaths, + ...runGitNameOnlyDiff(["--cached", "--diff-filter=ACMR"]), + ...runGitNameOnlyDiff(["--diff-filter=ACMR"]), + ...runGitLsFiles(["--others", "--exclude-standard"]), + ]), + ].toSorted((left, right) => left.localeCompare(right)); +} + +function runGitNameOnlyDiff(extraArgs) { + const output = execFileSync("git", ["diff", "--name-only", ...extraArgs], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + return output.split("\n").map(normalizeChangedPath).filter(Boolean); +} + +function runGitLsFiles(extraArgs) { + const output = execFileSync("git", ["ls-files", ...extraArgs], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + return output.split("\n").map(normalizeChangedPath).filter(Boolean); +} + +export function listStagedChangedPaths() { + const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + return output.split("\n").map(normalizeChangedPath).filter(Boolean); +} + +export function writeChangedLaneGitHubOutput(result, outputPath = process.env.GITHUB_OUTPUT) { + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is required"); + } + for (const [lane, enabled] of Object.entries(result.lanes)) { + appendFileSync(outputPath, `run_${toSnakeCase(lane)}=${String(enabled)}\n`, "utf8"); + } + appendFileSync(outputPath, `docs_only=${result.docsOnly}\n`, "utf8"); + appendFileSync( + outputPath, + `extension_impact_from_core=${result.extensionImpactFromCore}\n`, + "utf8", + ); +} + +function toSnakeCase(value) { + return value.replace(/[A-Z]/gu, (match) => `_${match.toLowerCase()}`); +} + +function parseArgs(argv) { + const args = { + base: "origin/main", + head: "HEAD", + staged: false, + json: false, + githubOutput: false, + paths: [], + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--base") { + args.base = argv[index + 1] ?? args.base; + index += 1; + continue; + } + if (arg === "--head") { + args.head = argv[index + 1] ?? args.head; + index += 1; + continue; + } + if (arg === "--staged") { + args.staged = true; + continue; + } + if (arg === "--json") { + args.json = true; + continue; + } + if (arg === "--github-output") { + args.githubOutput = true; + continue; + } + args.paths.push(arg); + } + return args; +} + +function isDirectRun() { + const direct = process.argv[1]; + return Boolean(direct && import.meta.url.endsWith(direct)); +} + +function printHuman(result) { + const enabled = Object.entries(result.lanes) + .filter(([, value]) => value) + .map(([lane]) => lane); + console.log(`lanes: ${enabled.length > 0 ? enabled.join(", ") : "none"}`); + if (result.docsOnly) { + console.log("docs-only: true"); + } + if (result.extensionImpactFromCore) { + console.log("extension-impact-from-core: true"); + } + if (result.paths.length > 0) { + console.log("paths:"); + for (const changedPath of result.paths) { + console.log(`- ${changedPath}`); + } + } + if (result.reasons.length > 0) { + console.log("reasons:"); + for (const reason of result.reasons) { + console.log(`- ${reason}`); + } + } +} + +if (isDirectRun()) { + const args = parseArgs(process.argv.slice(2)); + const paths = + args.paths.length > 0 + ? args.paths + : args.staged + ? listStagedChangedPaths() + : listChangedPathsFromGit({ base: args.base, head: args.head }); + const result = detectChangedLanes(paths); + if (args.githubOutput) { + writeChangedLaneGitHubOutput(result); + } + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + } else if (!args.githubOutput) { + printHuman(result); + } +} diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs new file mode 100644 index 00000000000..8a8609e0c10 --- /dev/null +++ b/scripts/check-changed.mjs @@ -0,0 +1,274 @@ +import { spawn } from "node:child_process"; +import { performance } from "node:perf_hooks"; +import { + detectChangedLanes, + listChangedPathsFromGit, + listStagedChangedPaths, + normalizeChangedPath, +} from "./changed-lanes.mjs"; + +const ROUTABLE_TEST_PATH_RE = /^(?:src|test|extensions|ui|packages|apps)(?:\/|$)/u; + +export function createChangedCheckPlan(result) { + const commands = []; + const add = (name, args) => { + if (!commands.some((command) => command.name === name && sameArgs(command.args, args))) { + commands.push({ name, args }); + } + }; + + add("conflict markers", ["check:no-conflict-markers"]); + + if (result.docsOnly) { + return { + commands, + testTargets: [], + runFullTests: false, + runExtensionTests: false, + summary: "docs-only", + }; + } + + const lanes = result.lanes; + const runAll = lanes.all; + + if (runAll) { + add("typecheck all", ["tsgo:all"]); + add("lint", ["lint"]); + add("runtime import cycles", ["check:import-cycles"]); + return { + commands, + testTargets: [], + runFullTests: true, + runExtensionTests: false, + summary: "all", + }; + } + + if (lanes.core) { + add("typecheck core", ["tsgo:core"]); + } + if (lanes.coreTests) { + add("typecheck core tests", ["tsgo:core:test"]); + } + if (lanes.extensions) { + add("typecheck extensions", ["tsgo:extensions"]); + } + if (lanes.extensionTests) { + add("typecheck extension tests", ["tsgo:extensions:test"]); + } + + if (lanes.core || lanes.coreTests) { + add("lint core", ["lint:core"]); + } + if (lanes.extensions || lanes.extensionTests) { + add("lint extensions", ["lint:extensions"]); + } + if (lanes.tooling) { + add("lint scripts", ["lint:scripts"]); + } + if (lanes.apps) { + add("lint apps", ["lint:apps"]); + } + + if (lanes.core || lanes.extensions) { + 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"]); + } + + const testTargets = result.paths.filter((changedPath) => ROUTABLE_TEST_PATH_RE.test(changedPath)); + return { + commands, + testTargets, + runFullTests: false, + runExtensionTests: result.extensionImpactFromCore, + summary: Object.entries(lanes) + .filter(([, enabled]) => enabled) + .map(([lane]) => lane) + .join(", "), + }; +} + +export async function runChangedCheck(result, options = {}) { + const plan = createChangedCheckPlan(result); + printPlan(result, plan, options); + + if (options.dryRun) { + return 0; + } + + const timings = []; + for (const command of plan.commands) { + const status = await runPnpm(command, timings); + if (status !== 0) { + printSummary(timings, options); + return status; + } + } + + if (plan.runFullTests) { + const status = await runPnpm({ name: "tests all", args: ["test"] }, timings); + if (status !== 0) { + printSummary(timings, options); + return status; + } + } else if (plan.testTargets.length > 0) { + const status = await runNode( + { + name: "tests changed", + args: ["scripts/test-projects.mjs", ...plan.testTargets], + }, + timings, + ); + if (status !== 0) { + printSummary(timings, options); + return status; + } + } + + if (plan.runExtensionTests) { + const status = await runPnpm({ name: "tests extensions", args: ["test:extensions"] }, timings); + if (status !== 0) { + printSummary(timings, options); + return status; + } + } + + printSummary(timings, options); + return 0; +} + +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} core contract changed; extension tests included`); + } + for (const reason of result.reasons) { + console.error(`${prefix} ${reason}`); + } + if (plan.testTargets.length > 0) { + console.error(`${prefix} test targets=${plan.testTargets.length}`); + } +} + +async function runPnpm(command, timings) { + return await runCommand({ ...command, bin: "pnpm" }, timings); +} + +async function runNode(command, timings) { + return await runCommand({ ...command, bin: process.execPath }, timings); +} + +async function runCommand(command, timings) { + const startedAt = performance.now(); + console.error(`\n[check:changed] ${command.name}`); + const child = spawn(command.bin, command.args, { + stdio: "inherit", + shell: process.platform === "win32", + }); + + return await new Promise((resolve) => { + child.once("error", (error) => { + console.error(error); + timings.push({ + name: command.name, + durationMs: performance.now() - startedAt, + status: 1, + }); + resolve(1); + }); + child.once("close", (status) => { + const resolvedStatus = status ?? 1; + timings.push({ + name: command.name, + durationMs: performance.now() - startedAt, + status: resolvedStatus, + }); + resolve(resolvedStatus); + }); + }); +} + +function printSummary(timings, options) { + if (!options.timed && timings.every((timing) => timing.status === 0)) { + return; + } + console.error("\n[check:changed] summary"); + for (const timing of timings) { + const status = timing.status === 0 ? "ok" : `failed:${timing.status}`; + console.error( + `${formatMs(timing.durationMs).padStart(8)} ${status.padEnd(9)} ${timing.name}`, + ); + } +} + +function formatMs(durationMs) { + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + return `${(durationMs / 1000).toFixed(2)}s`; +} + +function parseArgs(argv) { + const args = { + base: "origin/main", + head: "HEAD", + staged: false, + dryRun: false, + timed: false, + paths: [], + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--base") { + args.base = argv[index + 1] ?? args.base; + index += 1; + continue; + } + if (arg === "--head") { + args.head = argv[index + 1] ?? args.head; + index += 1; + continue; + } + if (arg === "--staged") { + args.staged = true; + continue; + } + if (arg === "--dry-run") { + args.dryRun = true; + continue; + } + if (arg === "--timed") { + args.timed = true; + continue; + } + args.paths.push(normalizeChangedPath(arg)); + } + return args; +} + +function isDirectRun() { + const direct = process.argv[1]; + return Boolean(direct && import.meta.url.endsWith(direct)); +} + +if (isDirectRun()) { + const args = parseArgs(process.argv.slice(2)); + const paths = + args.paths.length > 0 + ? args.paths + : args.staged + ? listStagedChangedPaths() + : listChangedPathsFromGit({ base: args.base, head: args.head }); + const result = detectChangedLanes(paths); + process.exitCode = await runChangedCheck(result, args); +} diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 9bd5f2fd00f..56c57e12d81 100644 --- a/test/git-hooks-pre-commit.test.ts +++ b/test/git-hooks-pre-commit.test.ts @@ -64,7 +64,8 @@ describe("git-hooks/pre-commit (integration)", () => { // Use the real hook script and lightweight helper stubs. const fakeBinDir = installPreCommitFixture(dir); - // The hook ends with `pnpm check`, but this fixture is only exercising staged-file handling. + // The hook can end with `pnpm check:changed --staged`, but this fixture is only + // exercising staged-file handling. // Stub pnpm too so Windows CI does not invoke a real package-manager command in the temp repo. writeExecutable(fakeBinDir, "pnpm", "#!/usr/bin/env bash\nexit 0\n"); @@ -84,7 +85,30 @@ describe("git-hooks/pre-commit (integration)", () => { expect(staged).toEqual(["--all"]); }); - it("skips pnpm check when FAST_COMMIT is enabled", () => { + it("runs changed-scope check for non-doc staged changes", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-check-changed-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + + const fakeBinDir = installPreCommitFixture(dir); + writeFileSync(path.join(dir, "package.json"), '{"name":"tmp"}\n', "utf8"); + writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); + writeExecutable( + fakeBinDir, + "pnpm", + "#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" > pnpm-args.txt\n", + ); + + writeFileSync(path.join(dir, "tracked.txt"), "hello\n", "utf8"); + run(dir, "git", ["add", "--", "tracked.txt"]); + + run(dir, "bash", ["git-hooks/pre-commit"], { + PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, + }); + + expect(run(dir, "cat", ["pnpm-args.txt"])).toBe("check:changed --staged"); + }); + + it("skips changed-scope check when FAST_COMMIT is enabled", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-yolo-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); @@ -106,6 +130,8 @@ describe("git-hooks/pre-commit (integration)", () => { FAST_COMMIT: "1", }); - expect(output).toContain("FAST_COMMIT enabled: skipping pnpm check in pre-commit hook."); + expect(output).toContain( + "FAST_COMMIT enabled: skipping changed-scope check in pre-commit hook.", + ); }); }); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts new file mode 100644 index 00000000000..1e7bc57fea2 --- /dev/null +++ b/test/scripts/changed-lanes.test.ts @@ -0,0 +1,205 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { detectChangedLanes } from "../../scripts/changed-lanes.mjs"; +import { createChangedCheckPlan } from "../../scripts/check-changed.mjs"; +import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js"; + +const tempDirs: string[] = []; +const repoRoot = process.cwd(); + +const git = (cwd: string, args: string[]) => + execFileSync("git", args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + }, + }).trim(); + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +describe("scripts/changed-lanes", () => { + it("includes untracked worktree files in the default local diff", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-"); + git(dir, ["init", "-q", "--initial-branch=main"]); + writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8"); + git(dir, ["add", "README.md"]); + git(dir, [ + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test User", + "commit", + "-q", + "-m", + "initial", + ]); + + mkdirSync(path.join(dir, "scripts"), { recursive: true }); + writeFileSync(path.join(dir, "scripts", "new-check.mjs"), "export {};\n", "utf8"); + + const output = execFileSync( + process.execPath, + [path.join(repoRoot, "scripts", "changed-lanes.mjs"), "--json", "--base", "HEAD"], + { + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + }, + }, + ); + + expect(JSON.parse(output)).toMatchObject({ + paths: ["scripts/new-check.mjs"], + lanes: { tooling: true }, + }); + }); + + it("routes core production changes to core prod and core test lanes", () => { + const result = detectChangedLanes(["src/shared/string-normalization.ts"]); + + expect(result.lanes).toMatchObject({ + core: true, + coreTests: true, + extensions: false, + extensionTests: false, + all: false, + }); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain( + "tsgo:core", + ); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain( + "tsgo:core:test", + ); + }); + + it("routes core test-only changes to core test lanes only", () => { + const result = detectChangedLanes(["src/shared/string-normalization.test.ts"]); + + expect(result.lanes).toMatchObject({ + core: false, + coreTests: true, + extensions: false, + extensionTests: false, + all: false, + }); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain( + "tsgo:core:test", + ); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).not.toContain( + "tsgo:core", + ); + }); + + it("routes extension production changes to extension prod and extension test lanes", () => { + const result = detectChangedLanes(["extensions/discord/src/index.ts"]); + + expect(result.lanes).toMatchObject({ + core: false, + coreTests: false, + extensions: true, + extensionTests: true, + all: false, + }); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain( + "tsgo:extensions", + ); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain( + "tsgo:extensions:test", + ); + }); + + it("routes extension test-only changes to extension test lanes only", () => { + const result = detectChangedLanes(["extensions/discord/src/index.test.ts"]); + + expect(result.lanes).toMatchObject({ + core: false, + coreTests: false, + extensions: false, + extensionTests: true, + all: false, + }); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain( + "tsgo:extensions:test", + ); + expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).not.toContain( + "tsgo:extensions", + ); + }); + + it("expands public core/plugin contracts to extension validation", () => { + const result = detectChangedLanes(["src/plugin-sdk/core.ts"]); + const plan = createChangedCheckPlan(result); + + expect(result.extensionImpactFromCore).toBe(true); + expect(result.lanes).toMatchObject({ + core: true, + coreTests: true, + extensions: true, + extensionTests: true, + all: false, + }); + expect(plan.runExtensionTests).toBe(true); + }); + + it("fails safe for root config changes", () => { + const result = detectChangedLanes(["package.json"]); + const plan = createChangedCheckPlan(result); + + expect(result.lanes.all).toBe(true); + expect(plan.runFullTests).toBe(true); + expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:all"); + }); + + it("routes root test/support changes to the tooling test lane instead of all lanes", () => { + const result = detectChangedLanes(["test/git-hooks-pre-commit.test.ts"]); + const plan = createChangedCheckPlan(result); + + expect(result.lanes).toMatchObject({ + tooling: true, + all: false, + }); + expect(plan.testTargets).toEqual(["test/git-hooks-pre-commit.test.ts"]); + expect(plan.runFullTests).toBe(false); + }); + + it("keeps an empty changed path list as a no-op", () => { + const result = detectChangedLanes([]); + const plan = createChangedCheckPlan(result); + + expect(result.lanes).toEqual({ + core: false, + coreTests: false, + extensions: false, + extensionTests: false, + apps: false, + docs: false, + tooling: false, + all: false, + }); + expect(plan.commands).toEqual([ + { name: "conflict markers", args: ["check:no-conflict-markers"] }, + ]); + expect(plan.runFullTests).toBe(false); + }); + + it("keeps docs-only changes cheap", () => { + const result = detectChangedLanes(["docs/ci.md", "README.md"]); + const plan = createChangedCheckPlan(result); + + expect(result.docsOnly).toBe(true); + expect(plan.commands).toEqual([ + { name: "conflict markers", args: ["check:no-conflict-markers"] }, + ]); + expect(plan.runFullTests).toBe(false); + }); +});