From e97b6c5282a5f809db31c1ccf6891c974f3deff4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 20:36:28 +0800 Subject: [PATCH] refactor(ci): plan change scopes through an injectable Git owner (#10804) * refactor(ci): plan change scopes through an injectable Git owner ciChangeScope.test.ts spent about five processes per case (copy the seed repository, git add, commit and rev-parse, then node plan.mjs with its own merge-base and diff) to ask which lanes a path selects. Move the planner into change-plan.mjs as planChanges(request, repository), keep plan.mjs as the workflow entrypoint over gitRepository(), and answer the path and package-script tables from an in-memory commit. History, renames, deletions, push baselines, malformed metadata and GITHUB_OUTPUT keep the Git fixture or the spawned entrypoint. 378 tests: 30.6 s elapsed and 32.6 s CPU before, 4.0 s and 4.0 s after. * test(ci): cover workflow environment wiring through the plan entrypoint --- scripts/ci/change-plan.d.mts | 39 ++ scripts/ci/change-plan.mjs | 374 ++++++++++++++++++ scripts/ci/plan.mjs | 359 +---------------- .../boundary/filesystem/ciChangeScope.test.ts | 338 ++++++++-------- 4 files changed, 611 insertions(+), 499 deletions(-) create mode 100644 scripts/ci/change-plan.d.mts create mode 100644 scripts/ci/change-plan.mjs diff --git a/scripts/ci/change-plan.d.mts b/scripts/ci/change-plan.d.mts new file mode 100644 index 0000000000..c3d2f48a76 --- /dev/null +++ b/scripts/ci/change-plan.d.mts @@ -0,0 +1,39 @@ +import type { TestSelectionPlan } from "./test-selection.d.mts"; + +export interface ChangePlanRequest { + readonly event: string | undefined; + readonly full: boolean; + readonly skipFull: boolean; + readonly base: string | undefined; + readonly head: string | undefined; + readonly baselineReason?: string | undefined; +} + +/** The Git questions a plan asks; a failure propagates as a planning failure. */ +export interface ChangePlanRepository { + readonly mergeBase: (base: string, head: string) => string; + readonly isAncestor: (ancestor: string, head: string) => boolean; + readonly changedFiles: (base: string, head: string) => readonly string[]; + readonly readFile: (revision: string, path: string) => string; +} + +export type ChangePlan = Readonly> & { + readonly tier: "full" | "fast" | "reused"; + readonly matrix: { + readonly include: readonly { + readonly os: string; + readonly platform: string; + readonly architecture: string; + }[]; + }; + readonly files: readonly string[]; + readonly reasons: readonly string[]; + readonly testSelection: TestSelectionPlan; +}; + +export function planChanges( + request: ChangePlanRequest, + repository: ChangePlanRepository, +): ChangePlan; + +export function gitRepository(cwd?: string): ChangePlanRepository; diff --git a/scripts/ci/change-plan.mjs b/scripts/ci/change-plan.mjs new file mode 100644 index 0000000000..393c352d70 --- /dev/null +++ b/scripts/ci/change-plan.mjs @@ -0,0 +1,374 @@ +import { execFileSync } from "node:child_process"; +import { matchesGlob } from "node:path"; +import { isLivingProsePath } from "../lib/docs-facts.mjs"; +import { + CI_FLAGS as flags, + MANUAL_VERIFIERS, + CI_LANES, + WINDOWS_EXECUTABLE_SELECTION_INPUTS, +} from "./scopes.mjs"; +import { planTestSelection } from "./test-selection.mjs"; + +// Actionlint validates workflow syntax only. The semantic suite asserts these +// workflows' release, publication and self-hosted runner safeguards. +const GUARDED_WORKFLOWS = new Set([ + ".github/workflows/codeql.yml", + ".github/workflows/pr-title.yml", + ".github/workflows/pr-readiness.yml", + ".github/workflows/ci-full-pr.yml", + ".github/workflows/release.yml", + ".github/workflows/release-recovery.yml", + ".github/workflows/real-ghidra-windows.yml", + ".github/workflows/sqlite-runtime.yml", + ".github/workflows/ci-timings.yml", + ".github/workflows/mutation.yml", +]); +// Sources compiled by build-conformance-fixtures.mjs. The readiness journey +// analyzes the built C fixture as well as its own applications. +const FIXTURE_SOURCES = [ + "scripts/build-conformance-fixtures.mjs", + "scripts/verify-conformance-fixtures.mjs", + "scripts/lib/conformance-fixtures.mjs", + "tests/conformance/c/**", + "tests/conformance/napi/**", + "tests/conformance/objc/**", + "tests/conformance/swift/**", + "tests/conformance/versions/**", +]; +// These reporters affect CI output only; they do not select a host or provider +// verification lane when changed on their own. +const PORTABLE_CI_REPORTERS = new Set([ + "scripts/ci/job-timings.mjs", + "scripts/ci/job-timings-batch.mjs", + "scripts/ci/test-report.mjs", + "scripts/ci/test-timing-reporter.mjs", + "scripts/ci/test-history.mjs", +]); +const matchesAny = (file, patterns) => + patterns.some((pattern) => matchesGlob(file, pattern)); + +/** + * Plan the CI lanes for one event. `repository` answers the Git questions the + * plan asks, so the classification runs without a process per decision. + * + * @param {import("./change-plan.d.mts").ChangePlanRequest} request + * @param {import("./change-plan.d.mts").ChangePlanRepository} repository + * @returns {import("./change-plan.d.mts").ChangePlan} + */ +export function planChanges(request, repository) { + const selected = Object.fromEntries(flags.map((flag) => [flag, false])); + const reasons = []; + // Shared process, contract, CLI, and CI-script edits are not a specific provider. + // Fast qualification keeps the portable suite and the Linux x64 runtime + // baseline. Full qualification still selects every lane. + const SHARED_BOUNDARY = + /^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/; + const full = (reason) => { + if (tier === "full") { + for (const flag of flags) selected[flag] = true; + } else { + selected.code = true; + selected.runtime = true; + } + reasons.push(reason); + }; + function classifyPackage(base, head) { + const before = JSON.parse(repository.readFile(base, "package.json")); + const after = JSON.parse(repository.readFile(head, "package.json")); + const { scripts: beforeScripts, ...beforeRest } = before; + const { scripts: afterScripts, ...afterRest } = after; + const compilerDependencies = (metadata) => ({ + dependencies: { + typescript: metadata.dependencies?.typescript, + tsrs: metadata.dependencies?.tsrs, + }, + devDependencies: { + typescript: metadata.devDependencies?.typescript, + tsrs: metadata.devDependencies?.tsrs, + }, + }); + const compilerScripts = [ + "compile", + "typecheck", + "build", + "build:cached", + "test:prepare", + "docs:prepare", + "verify:frida:bindings", + ].flatMap((name) => [name, `pre${name}`, `post${name}`]); + selected.package ||= + JSON.stringify(compilerDependencies(before)) !== + JSON.stringify(compilerDependencies(after)) || + compilerScripts.some( + (name) => beforeScripts?.[name] !== afterScripts?.[name], + ); + if (JSON.stringify(beforeRest) !== JSON.stringify(afterRest)) { + full("Package metadata or dependencies changed"); + return; + } + selected.code = true; + const changed = [ + ...new Set([ + ...Object.keys(beforeScripts ?? {}), + ...Object.keys(afterScripts ?? {}), + ]), + ].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]); + for (const script of changed) { + if (/^test:(coverage|mutation):reverse-tools$/.test(script)) { + selected.reverse_tools = true; + continue; + } + if (script === "test:python") { + selected.python = true; + continue; + } + if (script === "test:sqlite" || script === "test:sqlite:run") { + selected.sqlite = true; + continue; + } + if ( + /^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip(?::|$)|jscpd$)/.test( + script, + ) + ) + continue; + if ( + ["compile:termux", "build:termux", "build:termux:run"].includes(script) + ) { + selected.workflows = true; + reasons.push( + `Android build script changed; real Android verification remains manual: ${script}`, + ); + continue; + } + // Verify scripts own provider setup as well as execution. Reuse their + // existing source-file ownership rather than maintaining a second map. + const entrypoint = (command) => + /^(?:npm run build:cached && )?node (scripts\/[\w/-]+\.mjs)(?: [\w./=-]+)*$/.exec( + command ?? "", + )?.[1]; + const beforeEntry = entrypoint(beforeScripts?.[script]); + const afterEntry = entrypoint(afterScripts?.[script]); + const entries = [beforeEntry, afterEntry].filter(Boolean); + const simple = + (!beforeScripts?.[script] || beforeEntry) && + (!afterScripts?.[script] || afterEntry); + const scriptOwners = + simple && + entries.every((entry) => + Object.values(CI_LANES).some(({ paths }) => matchesAny(entry, paths)), + ) + ? Object.entries(CI_LANES) + .filter(([, { paths }]) => + entries.some((entry) => matchesAny(entry, paths)), + ) + .map(([owner]) => owner) + : []; + if (scriptOwners.length) + for (const owner of scriptOwners) selected[owner] = true; + else full(`Shared or unknown package script: ${script}`); + } + } + + const { event } = request; + if ( + !["pull_request", "push", "schedule", "workflow_dispatch"].includes(event) + ) + throw new Error(`Unsupported CI event: ${event}`); + const requestedFull = + event === "schedule" || event === "workflow_dispatch" || request.full; + const tier = + event === "schedule" && request.skipFull && !request.full + ? "reused" + : requestedFull + ? "full" + : "fast"; + /** Select the lanes one changed path owns. */ + const classifyFile = (file, base, head) => { + selected.package ||= matchesAny(file, WINDOWS_EXECUTABLE_SELECTION_INPUTS); + for (const [owner, { paths, earlyAll, earlyInputs = [] }] of Object.entries( + CI_LANES, + )) + if (matchesAny(file, earlyAll ? paths : earlyInputs)) + selected[owner] = true; + // The documentation check scans living prose outside docs/ too. + if (isLivingProsePath(file)) selected.docs = true; + if ( + /^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test( + file, + ) || + file.startsWith("docs/") || + file === "scripts/verify-documentation-routes.mjs" || + file.startsWith(".github/ISSUE_TEMPLATE/") + ) { + selected.docs = true; + // Authored code and its Node package scopes belong to static checkers. + selected.code ||= + file.startsWith("docs/") && + (/\.(?:[cm]?[jt]s|[jt]sx)$/u.test(file) || + file.endsWith("/package.json")); + selected.website ||= matchesAny(file, CI_LANES.website.paths); + return; + } + if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) { + selected.code = true; + for (const [owner, { paths, testInputs }] of Object.entries(CI_LANES)) + if (testInputs && matchesAny(file, paths)) selected[owner] = true; + return; + } + if (matchesAny(file, MANUAL_VERIFIERS)) { + selected.code = true; + return; + } + // Timing and report helpers stay on the portable suite. They are not a + // runtime or provider change. + if (PORTABLE_CI_REPORTERS.has(file)) { + selected.code = true; + return; + } + // A shared boundary is not evidence that every provider or host changed. + // One matching owner is path evidence. Several owners means the same + // registration file is listed by many lanes; fast qualification does not + // fan those out or start the multi-host package matrix. + if (tier !== "full" && SHARED_BOUNDARY.test(file)) { + selected.code = true; + selected.runtime = true; + const owners = Object.entries(CI_LANES) + .filter(([, { paths }]) => matchesAny(file, paths)) + .map(([owner]) => owner); + if (owners.length === 1) selected[owners[0]] = true; + reasons.push(`Shared boundary on fast tier: ${file}`); + return; + } + const owners = Object.entries(CI_LANES) + .filter(([, { paths }]) => matchesAny(file, paths)) + .map(([owner]) => owner); + for (const owner of owners) selected[owner] = true; + if ( + file.startsWith(".github/workflows/") || + file.startsWith(".github/codeql/") || + file === ".github/actionlint.yaml" + ) { + selected.workflows = true; + if (GUARDED_WORKFLOWS.has(file) || file.startsWith(".github/codeql/")) + selected.code = true; + if (file !== ".github/workflows/ci.yml") return; + } + // The Notes example is also the source-owned agent evaluation fixture. + if (file.startsWith("website/public/examples/notes-electron/")) + selected.code = true; + if (owners.length === 1 && owners[0] === "website") return; + if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true; + if (file === "package.json") { + classifyPackage(base, head); + return; + } + selected.code = true; + if (matchesAny(file, FIXTURE_SOURCES)) { + selected.fixtures = true; + selected.readiness = true; + } + if (file.startsWith("tests/")) return; + if ( + /^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test( + file, + ) + ) { + full(`Shared boundary: ${file}`); + } else if (owners.length) { + if (file.startsWith("src/") || file.startsWith("bridge/")) + selected.runtime = true; + } else if (/^(src|bridge)\//.test(file)) { + // Unowned runtime sources keep the Linux x64 installed-package baseline; + // full qualification retains every native host. + selected.runtime = true; + reasons.push(`Unowned runtime source: ${file}`); + } else if (file.startsWith(".agents/skills/")) { + selected.runtime = true; + selected.docs = true; + } else { + full(`Unclassified input: ${file}`); + } + }; + let files = []; + if (tier === "reused") { + reasons.push( + "Recent exact-SHA full qualification reused; no fresh provider observation", + ); + } else if (tier === "full") { + full("Full baseline requested by event or ci:full label"); + } else if (event === "pull_request" || event === "push") { + const { head } = request; + let base; + if (event === "pull_request") { + base = repository.mergeBase(request.base, head); + } else { + const candidate = request.base; + const { baselineReason } = request; + try { + if (!candidate || !/^[0-9a-f]{40}$/i.test(candidate)) + throw new Error( + baselineReason || "No valid successful push baseline was found", + ); + if (!repository.isAncestor(candidate, head)) + throw new Error("Push baseline is not an ancestor"); + base = candidate; + } catch { + full( + baselineReason || + "No valid successful push baseline ancestor; selecting all lanes", + ); + } + } + if (base) { + // Disable rename detection so both the removed and added paths participate. + files = repository.changedFiles(base, head); + for (const file of files) classifyFile(file, base, head); + } + } + selected.runtime ||= selected.package; + const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" }; + // macOS x64 is the slowest package host. Fast qualification omits it. + const intel = + tier === "full" + ? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }] + : []; + const matrix = { + include: selected.package + ? [ + linux, + { os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" }, + { os: "macos-15", platform: "darwin", architecture: "arm64" }, + ...intel, + ] + : [linux], + }; + const testSelection = planTestSelection(files, event, tier); + return { ...selected, tier, matrix, files, reasons, testSelection }; +} + +/** Answer the plan's Git questions from the repository in `cwd`. */ +export const gitRepository = (cwd = process.cwd()) => { + const git = (...args) => + execFileSync("git", args, { cwd, encoding: "utf8" }).trimEnd(); + return { + mergeBase: (base, head) => git("merge-base", base, head), + isAncestor: (ancestor, head) => { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ancestor, head], { + cwd, + stdio: "ignore", + }); + return true; + } catch { + return false; + } + }, + // Disable rename detection so both the removed and added paths participate. + changedFiles: (base, head) => + git("diff", "--name-only", "--no-renames", "-z", base, head) + .split("\0") + .filter(Boolean), + readFile: (revision, path) => git("show", `${revision}:${path}`), + }; +}; diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index 8e355f0a60..b2128a509c 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -1,350 +1,21 @@ -import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; -import { matchesGlob } from "node:path"; -import { isLivingProsePath } from "../lib/docs-facts.mjs"; -import { - CI_FLAGS as flags, - MANUAL_VERIFIERS, - CI_LANES, - WINDOWS_EXECUTABLE_SELECTION_INPUTS, -} from "./scopes.mjs"; -import { planTestSelection } from "./test-selection.mjs"; -// Actionlint validates workflow syntax only. The semantic suite asserts these -// workflows' release, publication and self-hosted runner safeguards. -const GUARDED_WORKFLOWS = new Set([ - ".github/workflows/codeql.yml", - ".github/workflows/pr-title.yml", - ".github/workflows/pr-readiness.yml", - ".github/workflows/ci-full-pr.yml", - ".github/workflows/release.yml", - ".github/workflows/release-recovery.yml", - ".github/workflows/real-ghidra-windows.yml", - ".github/workflows/sqlite-runtime.yml", - ".github/workflows/ci-timings.yml", - ".github/workflows/mutation.yml", -]); -// Sources compiled by build-conformance-fixtures.mjs. The readiness journey -// analyzes the built C fixture as well as its own applications. -const FIXTURE_SOURCES = [ - "scripts/build-conformance-fixtures.mjs", - "scripts/verify-conformance-fixtures.mjs", - "scripts/lib/conformance-fixtures.mjs", - "tests/conformance/c/**", - "tests/conformance/napi/**", - "tests/conformance/objc/**", - "tests/conformance/swift/**", - "tests/conformance/versions/**", -]; -// These reporters affect CI output only; they do not select a host or provider -// verification lane when changed on their own. -const PORTABLE_CI_REPORTERS = new Set([ - "scripts/ci/job-timings.mjs", - "scripts/ci/job-timings-batch.mjs", - "scripts/ci/test-report.mjs", - "scripts/ci/test-timing-reporter.mjs", - "scripts/ci/test-history.mjs", -]); -const matchesAny = (file, patterns) => - patterns.some((pattern) => matchesGlob(file, pattern)); -const selected = Object.fromEntries(flags.map((flag) => [flag, false])); -const reasons = []; -// Shared process, contract, CLI, and CI-script edits are not a specific provider. -// Fast qualification keeps the portable suite and the Linux x64 runtime -// baseline. Full qualification still selects every lane. -const SHARED_BOUNDARY = - /^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/; -const full = (reason) => { - if (tier === "full") { - for (const flag of flags) selected[flag] = true; - } else { - selected.code = true; - selected.runtime = true; - } - reasons.push(reason); -}; -const git = (...args) => - execFileSync("git", args, { encoding: "utf8" }).trimEnd(); -function classifyPackage(base, head) { - const before = JSON.parse(git("show", `${base}:package.json`)); - const after = JSON.parse(git("show", `${head}:package.json`)); - const { scripts: beforeScripts, ...beforeRest } = before; - const { scripts: afterScripts, ...afterRest } = after; - const compilerDependencies = (metadata) => ({ - dependencies: { - typescript: metadata.dependencies?.typescript, - tsrs: metadata.dependencies?.tsrs, - }, - devDependencies: { - typescript: metadata.devDependencies?.typescript, - tsrs: metadata.devDependencies?.tsrs, - }, - }); - const compilerScripts = [ - "compile", - "typecheck", - "build", - "build:cached", - "test:prepare", - "docs:prepare", - "verify:frida:bindings", - ].flatMap((name) => [name, `pre${name}`, `post${name}`]); - selected.package ||= - JSON.stringify(compilerDependencies(before)) !== - JSON.stringify(compilerDependencies(after)) || - compilerScripts.some( - (name) => beforeScripts?.[name] !== afterScripts?.[name], - ); - if (JSON.stringify(beforeRest) !== JSON.stringify(afterRest)) { - full("Package metadata or dependencies changed"); - return; - } - selected.code = true; - const changed = [ - ...new Set([ - ...Object.keys(beforeScripts ?? {}), - ...Object.keys(afterScripts ?? {}), - ]), - ].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]); - for (const script of changed) { - if (/^test:(coverage|mutation):reverse-tools$/.test(script)) { - selected.reverse_tools = true; - continue; - } - if (script === "test:python") { - selected.python = true; - continue; - } - if (script === "test:sqlite" || script === "test:sqlite:run") { - selected.sqlite = true; - continue; - } - if ( - /^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip(?::|$)|jscpd$)/.test( - script, - ) - ) - continue; - if ( - ["compile:termux", "build:termux", "build:termux:run"].includes(script) - ) { - selected.workflows = true; - reasons.push( - `Android build script changed; real Android verification remains manual: ${script}`, - ); - continue; - } - // Verify scripts own provider setup as well as execution. Reuse their - // existing source-file ownership rather than maintaining a second map. - const entrypoint = (command) => - /^(?:npm run build:cached && )?node (scripts\/[\w/-]+\.mjs)(?: [\w./=-]+)*$/.exec( - command ?? "", - )?.[1]; - const beforeEntry = entrypoint(beforeScripts?.[script]); - const afterEntry = entrypoint(afterScripts?.[script]); - const entries = [beforeEntry, afterEntry].filter(Boolean); - const simple = - (!beforeScripts?.[script] || beforeEntry) && - (!afterScripts?.[script] || afterEntry); - const scriptOwners = - simple && - entries.every((entry) => - Object.values(CI_LANES).some(({ paths }) => matchesAny(entry, paths)), - ) - ? Object.entries(CI_LANES) - .filter(([, { paths }]) => - entries.some((entry) => matchesAny(entry, paths)), - ) - .map(([owner]) => owner) - : []; - if (scriptOwners.length) - for (const owner of scriptOwners) selected[owner] = true; - else full(`Shared or unknown package script: ${script}`); - } -} +import { gitRepository, planChanges } from "./change-plan.mjs"; +import { CI_FLAGS as flags } from "./scopes.mjs"; -const event = process.env.GITHUB_EVENT_NAME; -if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event)) - throw new Error(`Unsupported CI event: ${event}`); -const requestedFull = - event === "schedule" || - event === "workflow_dispatch" || - process.env.CI_FULL === "true"; -const tier = - event === "schedule" && - process.env.CI_SKIP_FULL === "true" && - process.env.CI_FULL !== "true" - ? "reused" - : requestedFull - ? "full" - : "fast"; -let files = []; -if (tier === "reused") { - reasons.push( - "Recent exact-SHA full qualification reused; no fresh provider observation", - ); -} else if (tier === "full") { - full("Full baseline requested by event or ci:full label"); -} else if (event === "pull_request" || event === "push") { - const head = process.env.HEAD_SHA; - let base; - if (event === "pull_request") { - base = git("merge-base", process.env.BASE_SHA, head); - } else { - const candidate = process.env.BASE_SHA; - const baselineReason = process.env.BASELINE_REASON; - try { - if (!candidate || !/^[0-9a-f]{40}$/i.test(candidate)) - throw new Error( - baselineReason || "No valid successful push baseline was found", - ); - execFileSync("git", ["merge-base", "--is-ancestor", candidate, head], { - stdio: "ignore", - }); - base = candidate; - } catch { - full( - baselineReason || - "No valid successful push baseline ancestor; selecting all lanes", - ); - } - } - if (base) { - // Disable rename detection so both the removed and added paths participate. - files = git("diff", "--name-only", "--no-renames", "-z", base, head) - .split("\0") - .filter(Boolean); - for (const file of files) { - selected.package ||= matchesAny( - file, - WINDOWS_EXECUTABLE_SELECTION_INPUTS, - ); - for (const [ - owner, - { paths, earlyAll, earlyInputs = [] }, - ] of Object.entries(CI_LANES)) - if (matchesAny(file, earlyAll ? paths : earlyInputs)) - selected[owner] = true; - // The documentation check scans living prose outside docs/ too. - if (isLivingProsePath(file)) selected.docs = true; - if ( - /^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test( - file, - ) || - file.startsWith("docs/") || - file === "scripts/verify-documentation-routes.mjs" || - file.startsWith(".github/ISSUE_TEMPLATE/") - ) { - selected.docs = true; - // Authored code and its Node package scopes belong to static checkers. - selected.code ||= - file.startsWith("docs/") && - (/\.(?:[cm]?[jt]s|[jt]sx)$/u.test(file) || - file.endsWith("/package.json")); - selected.website ||= matchesAny(file, CI_LANES.website.paths); - continue; - } - if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) { - selected.code = true; - for (const [owner, { paths, testInputs }] of Object.entries(CI_LANES)) - if (testInputs && matchesAny(file, paths)) selected[owner] = true; - continue; - } - if (matchesAny(file, MANUAL_VERIFIERS)) { - selected.code = true; - continue; - } - // Timing and report helpers stay on the portable suite. They are not a - // runtime or provider change. - if (PORTABLE_CI_REPORTERS.has(file)) { - selected.code = true; - continue; - } - // A shared boundary is not evidence that every provider or host changed. - // One matching owner is path evidence. Several owners means the same - // registration file is listed by many lanes; fast qualification does not - // fan those out or start the multi-host package matrix. - if (tier !== "full" && SHARED_BOUNDARY.test(file)) { - selected.code = true; - selected.runtime = true; - const owners = Object.entries(CI_LANES) - .filter(([, { paths }]) => matchesAny(file, paths)) - .map(([owner]) => owner); - if (owners.length === 1) selected[owners[0]] = true; - reasons.push(`Shared boundary on fast tier: ${file}`); - continue; - } - const owners = Object.entries(CI_LANES) - .filter(([, { paths }]) => matchesAny(file, paths)) - .map(([owner]) => owner); - for (const owner of owners) selected[owner] = true; - if ( - file.startsWith(".github/workflows/") || - file.startsWith(".github/codeql/") || - file === ".github/actionlint.yaml" - ) { - selected.workflows = true; - if (GUARDED_WORKFLOWS.has(file) || file.startsWith(".github/codeql/")) - selected.code = true; - if (file !== ".github/workflows/ci.yml") continue; - } - // The Notes example is also the source-owned agent evaluation fixture. - if (file.startsWith("website/public/examples/notes-electron/")) - selected.code = true; - if (owners.length === 1 && owners[0] === "website") continue; - if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true; - if (file === "package.json") { - classifyPackage(base, head); - continue; - } - selected.code = true; - if (matchesAny(file, FIXTURE_SOURCES)) { - selected.fixtures = true; - selected.readiness = true; - } - if (file.startsWith("tests/")) continue; - if ( - /^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test( - file, - ) - ) { - full(`Shared boundary: ${file}`); - } else if (owners.length) { - if (file.startsWith("src/") || file.startsWith("bridge/")) - selected.runtime = true; - } else if (/^(src|bridge)\//.test(file)) { - // Unowned runtime sources keep the Linux x64 installed-package baseline; - // full qualification retains every native host. - selected.runtime = true; - reasons.push(`Unowned runtime source: ${file}`); - } else if (file.startsWith(".agents/skills/")) { - selected.runtime = true; - selected.docs = true; - } else { - full(`Unclassified input: ${file}`); - } - } - } -} -selected.runtime ||= selected.package; -const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" }; -// macOS x64 is the slowest package host. Fast qualification omits it. -const intel = - tier === "full" - ? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }] - : []; -const matrix = { - include: selected.package - ? [ - linux, - { os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" }, - { os: "macos-15", platform: "darwin", architecture: "arm64" }, - ...intel, - ] - : [linux], -}; -const testSelection = planTestSelection(files, event, tier); -const plan = { ...selected, tier, matrix, files, reasons, testSelection }; +const plan = planChanges( + { + event: process.env.GITHUB_EVENT_NAME, + full: process.env.CI_FULL === "true", + skipFull: process.env.CI_SKIP_FULL === "true", + base: process.env.BASE_SHA, + head: process.env.HEAD_SHA, + baselineReason: process.env.BASELINE_REASON, + }, + gitRepository(), +); +const { tier, matrix, reasons, testSelection } = plan; +const selected = Object.fromEntries(flags.map((flag) => [flag, plan[flag]])); if (process.env.GITHUB_OUTPUT) appendFileSync( process.env.GITHUB_OUTPUT, diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index f3e6a1b9a6..31698d8ff0 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -19,6 +19,10 @@ import { createTestWorkspace, removeTestWorkspace, } from "../../support/workspace/workspaceFixture.js"; +import { + gitRepository, + planChanges, +} from "../../../scripts/ci/change-plan.mjs"; import { executeWorkflowFixture as execFileAsync, workflowGit as git, @@ -95,7 +99,8 @@ async function fixture() { return { directory, base: initial.base }; } -async function classify( +/** Run the CI entrypoint as the workflow does, writing GITHUB_OUTPUT. */ +async function runPlanScript( directory: string, base: string, head: string, @@ -124,6 +129,66 @@ async function classify( return z.record(z.string(), z.unknown()).parse(JSON.parse(result.stdout)); } +/** Plan against the fixture repository's real Git history, in process. */ +function classify( + directory: string, + base: string, + head: string, + event = "pull_request", + full = false, + skipFull = false, +): Promise> { + return Promise.resolve().then(() => + planChanges( + { event, full, skipFull, base, head }, + gitRepository(directory), + ), + ); +} + +const PATH_BASE = "1".repeat(40); +const PATH_HEAD = "2".repeat(40); + +/** + * Plan a commit that adds or edits only `paths`. Git's diff for such a commit + * is exactly those paths, so these cases skip the repository; `packageJson` + * holds package.json at the base and head when the commit edits it. + * Deletions, renames, malformed metadata and history keep the Git fixture. + */ +function classifyCommit( + paths: readonly string[], + event = "pull_request", + full = false, + packageJson?: { readonly before: string; readonly after: string }, +): Promise> { + return Promise.resolve().then(() => + planChanges( + { event, full, skipFull: false, base: PATH_BASE, head: PATH_HEAD }, + { + mergeBase: (base) => base, + isAncestor: () => true, + changedFiles: () => paths, + readFile: (revision, path) => { + if (path === "package.json" && packageJson !== undefined) + return revision === PATH_BASE + ? packageJson.before + : packageJson.after; + throw new Error(`${path} at ${revision} needs the Git fixture`); + }, + }, + ), + ); +} + +const classifyPaths = ( + paths: readonly string[], + event?: string, + full?: boolean, +) => classifyCommit(paths, event, full); + +const classifyPackage = (before: string, after: string) => + classifyCommit(["package.json"], "pull_request", false, { before, after }); + it.each([ ["bridge/native/rea_lldb_tracer.py", "python"], ["bridge/pwndbg/test_core_context.py", "python"], @@ -160,9 +225,7 @@ it.each([ ["scripts/ci/require-provider-tests.mjs", "packet_capture"], ["scripts/ci/require-provider-tests.mjs", "signal_recording"], ])("selects %s for %s", async (path, scope) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject({ + expect(await classifyPaths([path])).toMatchObject({ [scope]: true, }); }); @@ -170,9 +233,7 @@ it.each([ it.each(["src/domain/webCaptureDiffSchemas.ts", "src/domain/webScreenshot.ts"])( "selects real browser verification for %s without capture lanes", async (path) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject({ + expect(await classifyPaths([path])).toMatchObject({ browser: true, packet_capture: false, signal_recording: false, @@ -222,11 +283,9 @@ it("keeps every translated README and authored guide on the documentation lane", }); it("runs documentation verification when only its published-route verifier changes", async () => { - const { directory, base } = await fixture(); - const head = await change(directory, [ - "scripts/verify-documentation-routes.mjs", - ]); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPaths(["scripts/verify-documentation-routes.mjs"]), + ).toMatchObject({ docs: true, }); }); @@ -234,11 +293,9 @@ it("runs documentation verification when only its published-route verifier chang it.each(["cjs", "cts", "js", "jsx", "mjs", "mts", "ts", "tsx"])( "selects static checks for authored documentation code (%s) without provider lanes", async (extension) => { - const { directory, base } = await fixture(); - const head = await change(directory, [ + const plan = await classifyPaths([ `docs/.vitepress/helpers/navigation.${extension}`, ]); - const plan = await classify(directory, base, head); const { CI_FLAGS } = await loadScopes(); expect(plan).toMatchObject({ tier: "fast", code: true, docs: true }); for (const flag of CI_FLAGS) @@ -264,11 +321,9 @@ it("retains code checks when documentation code is removed or renamed to Markdow }); it("selects code checks for documentation filenames containing line separators", async () => { - const { directory, base } = await fixture(); - const head = await change(directory, [ - "docs/.vitepress/navigation\u2028menu.ts", - ]); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPaths(["docs/.vitepress/navigation\u2028menu.ts"]), + ).toMatchObject({ code: true, docs: true, runtime: false, @@ -285,9 +340,7 @@ it.each([ ])( "keeps portable CI reporter %s on static checks and portable tests", async (name) => { - const { directory, base } = await fixture(); - const head = await change(directory, [`scripts/ci/${name}`]); - const plan = await classify(directory, base, head); + const plan = await classifyPaths([`scripts/ci/${name}`]); const { CI_FLAGS } = await loadScopes(); expect(plan.code).toBe(true); for (const flag of CI_FLAGS) @@ -296,12 +349,9 @@ it.each([ ); it("preserves a provider lane when a portable CI reporter changes with provider source", async () => { - const { directory, base } = await fixture(); - const head = await change(directory, [ - "scripts/ci/test-report.mjs", - "src/evm/Example.ts", - ]); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPaths(["scripts/ci/test-report.mjs", "src/evm/Example.ts"]), + ).toMatchObject({ code: true, evm: true, browser: false, @@ -310,9 +360,7 @@ it("preserves a provider lane when a portable CI reporter changes with provider }); it("keeps unknown CI scripts on the fast Linux baseline", async () => { - const { directory, base } = await fixture(); - const head = await change(directory, ["scripts/ci/new-qualification.mjs"]); - const plan = await classify(directory, base, head); + const plan = await classifyPaths(["scripts/ci/new-qualification.mjs"]); const { CI_FLAGS } = await loadScopes(); expect(plan).toMatchObject({ tier: "fast", code: true, runtime: true }); for (const flag of CI_FLAGS) @@ -320,7 +368,11 @@ it("keeps unknown CI scripts on the fast Linux baseline", async () => { expect(plan.matrix).toEqual({ include: [{ os: "ubuntu-latest", platform: "linux", architecture: "x64" }], }); - const full = await classify(directory, base, head, "pull_request", true); + const full = await classifyPaths( + ["scripts/ci/new-qualification.mjs"], + "pull_request", + true, + ); for (const flag of CI_FLAGS) expect(full[flag]).toBe(true); }); @@ -332,7 +384,7 @@ it("emits an observational owner proposal without changing ordinary or full port testSelection: { mode: "candidate", owners: ["evm"] }, }); expect( - await classify(directory, base, head, "pull_request", true), + await runPlanScript(directory, base, head, "pull_request", true), ).toMatchObject({ code: true, testSelection: { mode: "full", owners: [] }, @@ -341,6 +393,36 @@ it("emits an observational owner proposal without changing ordinary or full port expect(output).toContain('test_selection={"version":1,"mode":"full"'); }); +it("passes the workflow environment through the entrypoint unchanged", async () => { + const { directory, base } = await fixture(); + const head = await change(directory, ["src/evm/Example.ts"]); + const { CI_FLAGS } = await loadScopes(); + // Fast and reused plans exercise BASE_SHA/HEAD_SHA and CI_SKIP_FULL; the + // entrypoint must answer exactly as the in-process planner does. + for (const [event, skipFull, tier] of [ + ["pull_request", false, "fast"], + ["push", false, "fast"], + ["schedule", true, "reused"], + ] as const) { + const expected = await classify( + directory, + base, + head, + event, + false, + skipFull, + ); + expect(expected.tier).toBe(tier); + expect( + await runPlanScript(directory, base, head, event, false, skipFull), + ).toEqual(expected); + const output = await readFile(join(directory, "scope-output"), "utf8"); + for (const flag of CI_FLAGS) + expect(output).toContain(`${flag}=${String(expected[flag])}\n`); + expect(output).toContain(`tier=${tier}\n`); + } +}); + it("does not charge a documentation PR for unrelated implementation updates on main", async () => { const { directory, base } = await fixture(); await git(directory, ["switch", "-c", "docs/change"]); @@ -387,9 +469,7 @@ it.each([ ])( "selects the reverse-tool coverage and mutation gate for %s", async (path) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject({ + expect(await classifyPaths([path])).toMatchObject({ code: true, reverse_tools: true, }); @@ -423,9 +503,7 @@ const reverseToolsInputs = [ it.each(reverseToolsInputs)( "selects the reverse-tool quality gate for configured input %s", async (path) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject({ + expect(await classifyPaths([path])).toMatchObject({ code: true, reverse_tools: true, }); @@ -442,9 +520,9 @@ it.each([ ])( "retains complete checks when documentation is mixed with %s", async (path) => { - const { directory, base } = await fixture(); - const head = await change(directory, ["README.md", path]); - expect(await classify(directory, base, head)).toMatchObject({ code: true }); + expect(await classifyPaths(["README.md", path])).toMatchObject({ + code: true, + }); }, ); @@ -463,9 +541,9 @@ it("retains complete checks when implementation is renamed into documentation", }); it("keeps main pushes scoped when a successful ancestor baseline is available", async () => { - const { directory, base } = await fixture(); - const head = await change(directory, ["docs/readme/README_ja.md"]); - expect(await classify(directory, base, head, "push")).toMatchObject({ + expect( + await classifyPaths(["docs/readme/README_ja.md"], "push"), + ).toMatchObject({ tier: "fast", code: false, package: false, @@ -629,9 +707,9 @@ it("fails closed when the successful push baseline is not an ancestor", async () it("fails classification rather than treating an invalid Git comparison as a scope", async () => { const { directory, base } = await fixture(); - await expect(classify(directory, "missing-ref", base)).rejects.toMatchObject({ - code: 1, - }); + await expect( + runPlanScript(directory, "missing-ref", base), + ).rejects.toMatchObject({ code: 1 }); expect(await readFile(join(directory, "scope-output"), "utf8")).toBe(""); }); @@ -1235,9 +1313,7 @@ it.each([ { code: true, sqlite: false, ghidra_bridge: false, agentenv: false }, ], ])("selects the appropriate lanes for %s", async (path, expected) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject(expected); + expect(await classifyPaths([path])).toMatchObject(expected); }); it("scopes package script edits while retaining broad dependency checks", async () => { @@ -1281,20 +1357,14 @@ it("scopes package script edits while retaining broad dependency checks", async it.each(["test:sqlite", "test:sqlite:run"])( "routes %s script changes to the SQLite runtime matrix", async (script) => { - const { directory } = await fixture(); - const base = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [script]: "vitest run" } }), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ - scripts: { [script]: "vitest run --reporter=default" }, - }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify({ scripts: { [script]: "vitest run" } }), + JSON.stringify({ + scripts: { [script]: "vitest run --reporter=default" }, + }), + ), + ).toMatchObject({ code: true, sqlite: true, evm: false, @@ -1304,20 +1374,14 @@ it.each(["test:sqlite", "test:sqlite:run"])( ); it("keeps unrelated test script changes off the SQLite runtime matrix", async () => { - const { directory } = await fixture(); - const base = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { "test:domain": "vitest run src/domain" } }), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ - scripts: { "test:domain": "vitest run src/domain --reporter=default" }, - }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify({ scripts: { "test:domain": "vitest run src/domain" } }), + JSON.stringify({ + scripts: { "test:domain": "vitest run src/domain --reporter=default" }, + }), + ), + ).toMatchObject({ code: true, sqlite: false, evm: false, @@ -1329,9 +1393,7 @@ it.each([ "src/domain/apkSignature/apkSignatureSchemes.ts", "scripts/verify-apk-signature.mjs", ])("selects the real APK signing lane for %s", async (path) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject({ + expect(await classifyPaths([path])).toMatchObject({ apk_signature: true, }); }); @@ -1609,9 +1671,7 @@ it.each([ { code: true, fixtures: false, readiness: false, package: false }, ], ])("preserves scope boundaries for %s", async (path, expected) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - expect(await classify(directory, base, head)).toMatchObject(expected); + expect(await classifyPaths([path])).toMatchObject(expected); }); it("unions provider scopes across mixed changes and deletions", async () => { @@ -1631,7 +1691,7 @@ it("fails without emitting a plan when package metadata is malformed", async () const { directory } = await fixture(); const base = await change(directory, ["package.json"], '{"scripts":{}}'); const head = await change(directory, ["package.json"], "{broken"); - await expect(classify(directory, base, head)).rejects.toMatchObject({ + await expect(runPlanScript(directory, base, head)).rejects.toMatchObject({ code: 1, }); expect(await readFile(join(directory, "scope-output"), "utf8")).toBe(""); @@ -1656,18 +1716,12 @@ it.each([ ])( "routes the %s script to its glob-owned lane", async (script, command, expected) => { - const { directory } = await fixture(); - const base = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [script]: command } }), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [script]: `${command} --verbose` } }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify({ scripts: { [script]: command } }), + JSON.stringify({ scripts: { [script]: `${command} --verbose` } }), + ), + ).toMatchObject({ code: true, ...expected, }); @@ -1677,18 +1731,12 @@ it.each([ it.each(["knip:production", "lint:typed"])( "keeps the %s analysis command within static checks", async (script) => { - const { directory } = await fixture(); - const base = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [script]: "node scripts/old.mjs" } }), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [script]: "node scripts/new.mjs" } }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify({ scripts: { [script]: "node scripts/old.mjs" } }), + JSON.stringify({ scripts: { [script]: "node scripts/new.mjs" } }), + ), + ).toMatchObject({ code: true, runtime: false, package: false, @@ -1759,23 +1807,17 @@ it("honors ci:full for successive PR revisions", async () => { it.each(["compile:termux", "build:termux", "build:termux:run"])( "does not expand %s edits into unrelated providers", async (script) => { - const { directory } = await fixture(); - const base = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [script]: "npm run compile:termux" } }), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ - scripts: { - [script]: - "node scripts/run-exclusive.mjs artifacts npm run compile:termux", - }, - }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify({ scripts: { [script]: "npm run compile:termux" } }), + JSON.stringify({ + scripts: { + [script]: + "node scripts/run-exclusive.mjs artifacts npm run compile:termux", + }, + }), + ), + ).toMatchObject({ code: true, workflows: true, runtime: false, @@ -1929,9 +1971,7 @@ it.each([ ".nvmrc", ".npmrc", ])("selects package hosts for compiler input %s", async (path) => { - const { directory, base } = await fixture(); - const head = await change(directory, [path]); - const plan = await classify(directory, base, head); + const plan = await classifyPaths([path]); expect(plan).toMatchObject({ tier: "fast", code: true, @@ -1950,19 +1990,13 @@ it.each([ it.each(["typescript", "tsrs"])( "selects fallback host checks when compiler dependency %s changes", async (name) => { - const { directory } = await fixture(); const before = { name: "fixture", devDependencies: { [name]: "1.0.0" } }; - const base = await change( - directory, - ["package.json"], - JSON.stringify(before), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ ...before, devDependencies: { [name]: "2.0.0" } }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify(before), + JSON.stringify({ ...before, devDependencies: { [name]: "2.0.0" } }), + ), + ).toMatchObject({ code: true, runtime: true, package: true, @@ -1984,18 +2018,12 @@ it.each([ ])( "selects fallback host checks when compiler command %s changes", async (name) => { - const { directory } = await fixture(); - const base = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [name]: "node scripts/old.mjs" } }), - ); - const head = await change( - directory, - ["package.json"], - JSON.stringify({ scripts: { [name]: "node scripts/new.mjs" } }), - ); - expect(await classify(directory, base, head)).toMatchObject({ + expect( + await classifyPackage( + JSON.stringify({ scripts: { [name]: "node scripts/old.mjs" } }), + JSON.stringify({ scripts: { [name]: "node scripts/new.mjs" } }), + ), + ).toMatchObject({ code: true, runtime: true, package: true,