mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: add changed-lane local gate
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
300
scripts/changed-lanes.mjs
Normal file
300
scripts/changed-lanes.mjs
Normal file
@@ -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<ChangedLane, boolean>;
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
274
scripts/check-changed.mjs
Normal file
274
scripts/check-changed.mjs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
205
test/scripts/changed-lanes.test.ts
Normal file
205
test/scripts/changed-lanes.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user