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
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 20:36:28 +08:00
1 parent 483f53fdef
commit e97b6c5282
4 files changed
+611 -499

No files matched your search

+39
View File
@@ -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<Record<string, unknown>> & {
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;
+374
View File
@@ -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}`),
};
};
+15 -344
View File
@@ -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,
+183 -155
View File
@@ -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<Record<string, unknown>> {
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<Record<string, unknown>> {
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,