mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
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:
1 parent
483f53fdef
commit
e97b6c5282
4 files changed
+611
-499
No files matched your search
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user