diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index d44331a549d8..da3d196cd0ed 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -2,18 +2,15 @@ import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; +import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs"; +export { normalizeChangedPath } from "./lib/changed-path-facts.mjs"; const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024; const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200; const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC"; -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\/|packages\/)/u; -const UI_PATH_RE = /^(?:ui\/|tsconfig\.ui\.json$)/u; const SCRIPTS_TYPECHECK_PATH_RE = /^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u; // Keep aligned with tsconfig.strict-ratchet.json includes and its oxlint override. @@ -38,11 +35,6 @@ export const STRICT_RATCHET_PACKAGE_DIRS = [ ]; const TEST_ROOT_TYPECHECK_PATH_RE = /^(?:test\/(?!fixtures\/).*\.(?:[cm]?ts|[cm]?tsx)|test\/tsconfig\/tsconfig\.test\.root\.json)$/u; -const TOOLING_PATH_RE = - /^(?:scripts\/|test\/vitest\/|\.github\/|\.vscode\/|config\/|deploy\/|git-hooks\/|Dockerfile\.sandbox(?:-(?:browser|common))?$|Makefile$|docker-setup\.sh$|setup-podman\.sh$|openclaw\.podman\.env$|skills\/pyproject\.toml$|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.dockerignore$|\.gitignore$|\.jscpd\.json$|\.npmignore$|\.pre-commit-config\.yaml$|\.swiftformat$|\.swiftlint\.yml$|\.oxlint.*|\.oxfmt.*)/u; -const ROOT_GLOBAL_PATH_RE = - /^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u; -const LEGACY_ROOT_ASSET_PATH_RE = /^assets\//u; export const LIVE_DOCKER_AUTH_SHELL_TARGETS = [ "scripts/lib/live-docker-auth.sh", "scripts/test-live-acp-bind-docker.sh", @@ -59,8 +51,6 @@ const LIVE_DOCKER_TOOLING_PATHS = new Set([ "src/gateway/live-agent-probes.test.ts", ]); const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/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; /** @@ -91,16 +81,6 @@ export const RELEASE_METADATA_PATHS = new Set([ * }} ChangedLaneResult */ -/** - * Normalizes a changed file path into repo-relative POSIX form. - */ -export function normalizeChangedPath(inputPath) { - return String(inputPath ?? "") - .trim() - .replaceAll("\\", "/") - .replace(/^\.\/+/u, ""); -} - /** * Creates the default changed-lanes result object. */ @@ -124,7 +104,7 @@ export function createEmptyChangedLanes() { } export function isChangedLaneTestPath(changedPath) { - return TEST_PATH_RE.test(normalizeChangedPath(changedPath)); + return getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest; } /** @@ -160,7 +140,7 @@ export function detectChangedLanes(changedPaths, options = {}) { paths.every((changedPath) => RELEASE_METADATA_PATHS.has(changedPath)) ) { lanes.releaseMetadata = true; - lanes.docs = paths.some((changedPath) => DOCS_PATH_RE.test(changedPath)); + lanes.docs = paths.some((changedPath) => getChangedPathFacts(changedPath).surface === "docs"); for (const changedPath of paths) { reasons.push(`${changedPath}: release metadata`); } @@ -168,6 +148,7 @@ export function detectChangedLanes(changedPaths, options = {}) { } for (const changedPath of paths) { + const facts = getChangedPathFacts(changedPath); if (SCRIPTS_TYPECHECK_PATH_RE.test(changedPath)) { lanes.scripts = true; } @@ -183,7 +164,7 @@ export function detectChangedLanes(changedPaths, options = {}) { lanes.testRoot = true; } - if (DOCS_PATH_RE.test(changedPath)) { + if (facts.surface === "docs") { lanes.docs = true; continue; } @@ -208,7 +189,7 @@ export function detectChangedLanes(changedPaths, options = {}) { continue; } - if (ROOT_GLOBAL_PATH_RE.test(changedPath)) { + if (facts.surface === "rootGlobal") { lanes.all = true; extensionImpactFromCore = true; reasons.push(`${changedPath}: root config/package surface`); @@ -225,8 +206,8 @@ export function detectChangedLanes(changedPaths, options = {}) { continue; } - if (EXTENSION_PATH_RE.test(changedPath)) { - if (isChangedLaneTestPath(changedPath)) { + if (facts.surface === "extension") { + if (facts.isChangedLaneTest) { lanes.extensionTests = true; reasons.push(`${changedPath}: extension test`); } else { @@ -237,8 +218,8 @@ export function detectChangedLanes(changedPaths, options = {}) { continue; } - if (CORE_PATH_RE.test(changedPath)) { - if (isChangedLaneTestPath(changedPath)) { + if (facts.surface === "source" || facts.surface === "package") { + if (facts.isChangedLaneTest) { lanes.coreTests = true; reasons.push(`${changedPath}: core test`); } else { @@ -249,8 +230,8 @@ export function detectChangedLanes(changedPaths, options = {}) { continue; } - if (UI_PATH_RE.test(changedPath)) { - if (isChangedLaneTestPath(changedPath)) { + if (facts.surface === "ui") { + if (facts.isChangedLaneTest) { lanes.coreTests = true; reasons.push(`${changedPath}: UI test`); } else { @@ -261,25 +242,25 @@ export function detectChangedLanes(changedPaths, options = {}) { continue; } - if (APP_PATH_RE.test(changedPath)) { + if (facts.surface === "app") { lanes.apps = true; reasons.push(`${changedPath}: app surface`); continue; } - if (changedPath.startsWith("test/") || changedPath.startsWith("test-fixtures/")) { + if (facts.surface === "rootTest" || facts.surface === "testFixture") { lanes.tooling = true; reasons.push(`${changedPath}: root test/support surface`); continue; } - if (TOOLING_PATH_RE.test(changedPath)) { + if (facts.surface === "rootTooling") { lanes.tooling = true; reasons.push(`${changedPath}: tooling surface`); continue; } - if (LEGACY_ROOT_ASSET_PATH_RE.test(changedPath)) { + if (facts.surface === "legacyRootAsset") { lanes.tooling = true; reasons.push(`${changedPath}: legacy root asset cleanup`); continue; diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 3ed21118fe76..47293f5d36e0 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -14,13 +14,12 @@ import { performance } from "node:perf_hooks"; import { LIVE_DOCKER_AUTH_SHELL_TARGETS, detectChangedLanesForPaths, - isChangedLaneTestPath, listChangedPathsFromGit, listStagedChangedPaths, - normalizeChangedPath, } from "./changed-lanes.mjs"; import { shrinkwrapPackageDirsForChangedPaths } from "./generate-npm-shrinkwrap.mjs"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; +import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs"; import { printTimingSummary } from "./lib/check-timing-summary.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { @@ -220,7 +219,9 @@ export function shouldRunAppcastOwnerTest(paths) { } export function shouldRunTestTempCreationReport(paths) { - return paths.some((changedPath) => isChangedLaneTestPath(changedPath)); + return paths.some( + (changedPath) => getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest, + ); } export function createShrinkwrapGuardCommand(paths) { @@ -439,7 +440,7 @@ export function createChangedCheckPlan(result, options = {}) { } if ( lanes.liveDockerTooling && - result.paths.some((changedPath) => changedPath.startsWith("src/")) + result.paths.some((changedPath) => getChangedPathFacts(changedPath).surface === "source") ) { addTypecheck("typecheck core tests", ["tsgo:core:test"]); addLint("lint core", ["lint:core"]); diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 929c0f151fa5..a32efd29e66b 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -1,6 +1,7 @@ // Determines CI scope from changed paths. import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; +import { getChangedPathFacts } from "./lib/changed-path-facts.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs"; @@ -32,7 +33,6 @@ const EMPTY_SCOPE = { runUiTests: false, }; -const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/; const SKILLS_PYTHON_SCOPE_RE = /^(skills\/|skills\/pyproject\.toml$)/; const INSTALL_SMOKE_WORKFLOW_SCOPE_RE = /^\.github\/workflows\/install-smoke\.yml$/; const NATIVE_PROTOCOL_GEN_RE = /^apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\//; @@ -52,16 +52,12 @@ const WINDOWS_TEST_SCOPE_RE = /^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; const WINDOWS_DAEMON_SCOPE_RE = /^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/; -const TEST_ONLY_PATH_RE = - /(^test\/|\/test\/|\/tests\/|(?:^|\/)[^/]+\.(?:test|spec|test-utils|test-support|test-harness|e2e-harness)\.[cm]?[jt]sx?$)/; const CONTROL_UI_I18N_SCOPE_RE = /^(ui\/src\/i18n\/|scripts\/control-ui-i18n\.ts$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/; const CONTROL_UI_TEST_SCOPE_RE = /^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/; const NATIVE_I18N_SCOPE_RE = /^(?:apps\/\.i18n\/|apps\/android\/app\/src\/main\/|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/; -const NATIVE_ONLY_RE = - /^(apps\/android\/|apps\/ios\/|apps\/macos\/|apps\/macos-mlx-tts\/|apps\/shared\/|apps\/swabble\/|Swabble\/|appcast\.xml$)/; const FAST_INSTALL_SMOKE_SCOPE_RE = /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; const FULL_INSTALL_SMOKE_SCOPE_RE = @@ -71,7 +67,7 @@ const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE = const NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE = /^src\/plugins\/contracts\/(?:inventory\/bundled-capability-metadata|registry|tts-contract-suites)\.ts$/; const NODE_FAST_CI_ROUTING_SCOPE_RE = - /^(scripts\/(?:ci-changed-scope|check-changed|run-vitest|test-projects(?:\.test-support)?)\.mjs$|scripts\/test-projects\.test-support\.d\.mts$|src\/commands\/status\.scan-result\.test\.ts$|src\/scripts\/ci-changed-scope\.test\.ts$|test\/scripts\/(?:changed-lanes|run-vitest|test-projects)\.test\.ts$)/; + /^(scripts\/(?:ci-changed-scope|check-changed|run-vitest|test-projects(?:\.test-support)?)\.mjs$|scripts\/(?:test-projects\.test-support|lib\/changed-path-facts)\.d\.mts$|scripts\/lib\/changed-path-facts\.mjs$|src\/commands\/status\.scan-result\.test\.ts$|src\/scripts\/ci-changed-scope\.test\.ts$|test\/scripts\/(?:changed-lanes|changed-path-facts|run-vitest|test-projects)\.test\.ts$)/; const NODE_FAST_SCOPE_RE = new RegExp( `${NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.source}|${NODE_FAST_CI_ROUTING_SCOPE_RE.source}`, ); @@ -101,14 +97,15 @@ export function detectChangedScope(changedPaths) { let hasNonNativeNonDocs = false; for (const rawPath of changedPaths) { - const path = rawPath.trim(); + const facts = getChangedPathFacts(rawPath); + const { path } = facts; if (!path) { continue; } const isAppleSwiftConfig = APPLE_SWIFT_CONFIG_RE.test(path); - if (DOCS_PATH_RE.test(path)) { + if (facts.surface === "docs") { continue; } @@ -143,9 +140,7 @@ export function detectChangedScope(changedPaths) { if ( (WINDOWS_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path)) && - (!TEST_ONLY_PATH_RE.test(path) || - WINDOWS_TEST_SCOPE_RE.test(path) || - WINDOWS_DAEMON_SCOPE_RE.test(path)) + (!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path)) ) { runWindows = true; } @@ -162,7 +157,7 @@ export function detectChangedScope(changedPaths) { runUiTests = true; } - if (!NATIVE_ONLY_RE.test(path)) { + if (!facts.isNativeOnly) { hasNonNativeNonDocs = true; } } @@ -209,8 +204,9 @@ export function detectNodeFastScope(changedPaths) { let runCiRouting = false; for (const rawPath of changedPaths) { - const path = rawPath.trim(); - if (!path || DOCS_PATH_RE.test(path)) { + const facts = getChangedPathFacts(rawPath); + const { path } = facts; + if (!path || facts.surface === "docs") { continue; } @@ -236,11 +232,12 @@ export function detectNodeFastScope(changedPaths) { * @returns {InstallSmokeScope} */ function detectInstallSmokeScopeForPath(path) { + const facts = getChangedPathFacts(path); const runFullInstallSmoke = FULL_INSTALL_SMOKE_SCOPE_RE.test(path); const runFastInstallSmoke = runFullInstallSmoke || FAST_INSTALL_SMOKE_SCOPE_RE.test(path) || - (FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE.test(path) && !TEST_ONLY_PATH_RE.test(path)); + (FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE.test(path) && !facts.isTestOnly); return { runFastInstallSmoke, runFullInstallSmoke }; } @@ -259,8 +256,9 @@ export function detectInstallSmokeScope(changedPaths) { let runFastInstallSmoke = false; let runFullInstallSmoke = false; for (const rawPath of changedPaths) { - const path = rawPath.trim(); - if (!path || DOCS_PATH_RE.test(path)) { + const facts = getChangedPathFacts(rawPath); + const { path } = facts; + if (!path || facts.surface === "docs") { continue; } const pathScope = detectInstallSmokeScopeForPath(path); diff --git a/scripts/lib/changed-path-facts.d.mts b/scripts/lib/changed-path-facts.d.mts new file mode 100644 index 000000000000..06f33c649e17 --- /dev/null +++ b/scripts/lib/changed-path-facts.d.mts @@ -0,0 +1,24 @@ +export type ChangedPathSurface = + | "docs" + | "source" + | "package" + | "ui" + | "extension" + | "app" + | "rootTest" + | "testFixture" + | "rootTooling" + | "rootGlobal" + | "legacyRootAsset" + | "unknown"; + +export type ChangedPathFacts = { + path: string; + surface: ChangedPathSurface; + isChangedLaneTest: boolean; + isTestOnly: boolean; + isNativeOnly: boolean; +}; + +export function normalizeChangedPath(inputPath: unknown): string; +export function getChangedPathFacts(inputPath: unknown): ChangedPathFacts; diff --git a/scripts/lib/changed-path-facts.mjs b/scripts/lib/changed-path-facts.mjs new file mode 100644 index 000000000000..f39ffe1835fc --- /dev/null +++ b/scripts/lib/changed-path-facts.mjs @@ -0,0 +1,73 @@ +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 SOURCE_PATH_RE = /^src\//u; +const PACKAGE_PATH_RE = /^packages\//u; +const UI_PATH_RE = /^(?:ui\/|tsconfig\.ui\.json$)/u; +const ROOT_TEST_PATH_RE = /^test\//u; +const TEST_FIXTURE_PATH_RE = /^test-fixtures\//u; +const ROOT_TOOLING_PATH_RE = + /^(?:scripts\/|test\/vitest\/|\.github\/|\.vscode\/|config\/|deploy\/|git-hooks\/|Dockerfile\.sandbox(?:-(?:browser|common))?$|Makefile$|docker-setup\.sh$|setup-podman\.sh$|openclaw\.podman\.env$|skills\/pyproject\.toml$|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.dockerignore$|\.gitignore$|\.jscpd\.json$|\.npmignore$|\.pre-commit-config\.yaml$|\.swiftformat$|\.swiftlint\.yml$|\.oxlint.*|\.oxfmt.*)/u; +const ROOT_GLOBAL_PATH_RE = + /^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u; +const LEGACY_ROOT_ASSET_PATH_RE = /^assets\//u; +const CHANGED_LANE_TEST_PATH_RE = + /(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|e2e|browser\.test)\.[cm]?[jt]sx?$/u; +const TEST_ONLY_PATH_RE = + /(^test\/|\/test\/|\/tests\/|(?:^|\/)[^/]+\.(?:test|spec|test-utils|test-support|test-harness|e2e-harness)\.[cm]?[jt]sx?$)/u; +const NATIVE_ONLY_PATH_RE = + /^(?:apps\/android\/|apps\/ios\/|apps\/macos\/|apps\/macos-mlx-tts\/|apps\/shared\/|apps\/swabble\/|Swabble\/|appcast\.xml$)/u; + +/** + * @typedef {"docs" | "source" | "package" | "ui" | "extension" | "app" | "rootTest" | "testFixture" | "rootTooling" | "rootGlobal" | "legacyRootAsset" | "unknown"} ChangedPathSurface + */ + +/** + * Normalizes a changed file path into repo-relative POSIX form. + */ +export function normalizeChangedPath(inputPath) { + return String(inputPath ?? "") + .trim() + .replaceAll("\\", "/") + .replace(/^\.\/+/u, ""); +} + +/** + * Returns shared path facts without imposing a caller's lane-selection policy. + */ +export function getChangedPathFacts(inputPath) { + const path = String(inputPath ?? "").trim(); + let surface = /** @type {ChangedPathSurface} */ ("unknown"); + + if (DOCS_PATH_RE.test(path)) { + surface = "docs"; + } else if (ROOT_GLOBAL_PATH_RE.test(path)) { + surface = "rootGlobal"; + } else if (EXTENSION_PATH_RE.test(path)) { + surface = "extension"; + } else if (SOURCE_PATH_RE.test(path)) { + surface = "source"; + } else if (PACKAGE_PATH_RE.test(path)) { + surface = "package"; + } else if (UI_PATH_RE.test(path)) { + surface = "ui"; + } else if (APP_PATH_RE.test(path)) { + surface = "app"; + } else if (ROOT_TEST_PATH_RE.test(path)) { + surface = "rootTest"; + } else if (TEST_FIXTURE_PATH_RE.test(path)) { + surface = "testFixture"; + } else if (ROOT_TOOLING_PATH_RE.test(path)) { + surface = "rootTooling"; + } else if (LEGACY_ROOT_ASSET_PATH_RE.test(path)) { + surface = "legacyRootAsset"; + } + + return { + path, + surface, + isChangedLaneTest: CHANGED_LANE_TEST_PATH_RE.test(path), + isTestOnly: TEST_ONLY_PATH_RE.test(path), + isNativeOnly: NATIVE_ONLY_PATH_RE.test(path), + }; +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 66aae8dc736e..da19788c4d3c 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -56,6 +56,7 @@ import { detectChangedLanes, listChangedPathsFromGit as listChangedPathsFromGitSource, } from "./changed-lanes.mjs"; +import { getChangedPathFacts } from "./lib/changed-path-facts.mjs"; import { isCiLikeEnv, resolveLocalFullSuiteProfile } from "./lib/vitest-local-scheduling.mjs"; import { DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS, @@ -3469,7 +3470,13 @@ function isRoutableChangedTarget(changedPath) { if (changedPath.endsWith(".live.test.ts")) { return false; } - return /^(?:src|test|extensions|ui|packages)(?:\/|$)/u.test(changedPath); + const surface = getChangedPathFacts(changedPath).surface; + return ( + ["source", "package", "extension", "rootTest"].includes(surface) || + changedPath === "ui" || + changedPath.startsWith("ui/") || + ["src", "test", "extensions", "packages"].includes(changedPath) + ); } function resolveSiblingTestTarget(changedPath, cwd) { @@ -3523,7 +3530,15 @@ function resolvePreciseChangedTestTargets(changedPath, options) { if (options.skipImportGraph === true) { return null; } - if (/^(?:src|test\/helpers|extensions|packages|ui\/src|ui\/config)\//u.test(changedPath)) { + const facts = getChangedPathFacts(changedPath); + if ( + facts.surface === "source" || + facts.surface === "package" || + facts.surface === "extension" || + changedPath.startsWith("test/helpers/") || + changedPath.startsWith("ui/src/") || + changedPath.startsWith("ui/config/") + ) { const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd, { forceFull: options.forceFullImportGraph === true, }); @@ -3671,7 +3686,7 @@ function classifyTarget(arg, cwd) { if (relative === "extensions") { return "extensionFull"; } - if (relative.startsWith("extensions/")) { + if (getChangedPathFacts(relative).surface === "extension") { const extensionRoot = relative.split("/").slice(0, 2).join("/"); const splitChannelShard = resolveSplitChannelExtensionShard(extensionRoot); if (splitChannelShard) { diff --git a/test/scripts/changed-path-facts.test.ts b/test/scripts/changed-path-facts.test.ts new file mode 100644 index 000000000000..fccfd5bb6356 --- /dev/null +++ b/test/scripts/changed-path-facts.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + getChangedPathFacts, + normalizeChangedPath, +} from "../../scripts/lib/changed-path-facts.mjs"; + +describe("changed path facts", () => { + it("preserves the existing surface classifications", () => { + const cases = [ + ["docs/ci.md", "docs"], + ["README.md", "docs"], + ["src/config/defaults.ts", "source"], + ["packages/gateway-client/src/client.ts", "package"], + ["extensions/slack/src/index.ts", "extension"], + ["ui/src/app.ts", "ui"], + ["apps/macos/Sources/OpenClaw/AppDelegate.swift", "app"], + ["test/scripts/changed-lanes.test.ts", "rootTest"], + ["test-fixtures/sample.ts", "testFixture"], + ["scripts/check-changed.mjs", "rootTooling"], + [".github/workflows/ci.yml", "rootTooling"], + ["package.json", "rootGlobal"], + ["assets/legacy.png", "legacyRootAsset"], + [".crabbox.yaml", "unknown"], + ] as const; + + for (const [changedPath, surface] of cases) { + expect(getChangedPathFacts(changedPath).surface, changedPath).toBe(surface); + } + }); + + it("preserves test and native-only predicates independently from surfaces", () => { + expect(getChangedPathFacts("extensions/slack/src/index.test.ts")).toMatchObject({ + surface: "extension", + isChangedLaneTest: true, + isTestOnly: true, + isNativeOnly: false, + }); + expect(getChangedPathFacts("test/helpers/fixture.ts")).toMatchObject({ + surface: "rootTest", + isChangedLaneTest: true, + isTestOnly: true, + isNativeOnly: false, + }); + expect(getChangedPathFacts("apps/shared/OpenClawKit/Sources/Foo.swift")).toMatchObject({ + surface: "app", + isChangedLaneTest: false, + isTestOnly: false, + isNativeOnly: true, + }); + expect(getChangedPathFacts("apps/web/index.ts")).toMatchObject({ + surface: "app", + isNativeOnly: false, + }); + }); + + it("keeps normalization separate from classification", () => { + expect(normalizeChangedPath(" .\\extensions\\slack\\src\\index.test.ts ")).toBe( + "extensions/slack/src/index.test.ts", + ); + expect(getChangedPathFacts("./src/config/defaults.ts").surface).toBe("unknown"); + }); +});