feat: add changed-lane local gate

This commit is contained in:
Peter Steinberger
2026-04-20 15:44:13 +01:00
parent 5bac634abf
commit 788b47536c
10 changed files with 823 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe
- Prefer existing wrappers over raw tool entrypoints when the repo already has a curated seam.
- For tests, prefer `scripts/run-vitest.mjs` or the root `pnpm test ...` entrypoints over raw `vitest run` calls.
- For lint/typecheck flows, prefer `scripts/run-oxlint.mjs` and `scripts/run-tsgo.mjs` when adding or editing package scripts or CI steps that should honor repo-local runtime behavior.
- For changed-file verification, prefer `scripts/check-changed.mjs` and keep lane classification in `scripts/changed-lanes.mjs`. Do not copy path-scope rules into new hooks or ad hoc CI snippets.
## Local Heavy-Check Lock

300
scripts/changed-lanes.mjs Normal file
View File

@@ -0,0 +1,300 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
const DOCS_PATH_RE = /^(?:docs\/|README\.md$|AGENTS\.md$|.*\.mdx?$)/u;
const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u;
const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u;
const CORE_PATH_RE = /^(?:src\/|ui\/|packages\/)/u;
const TOOLING_PATH_RE =
/^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.oxlint.*|\.oxfmt.*)/u;
const ROOT_GLOBAL_PATH_RE =
/^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u;
const TEST_PATH_RE =
/(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|e2e|browser\.test)\.[cm]?[jt]sx?$/u;
const PUBLIC_EXTENSION_CONTRACT_RE =
/^(?:src\/plugin-sdk\/|src\/plugins\/contracts\/|src\/channels\/plugins\/|scripts\/lib\/plugin-sdk-entrypoints\.json$|scripts\/sync-plugin-sdk-exports\.mjs$|scripts\/generate-plugin-sdk-api-baseline\.ts$)/u;
/** @typedef {"core" | "coreTests" | "extensions" | "extensionTests" | "apps" | "docs" | "tooling" | "all"} ChangedLane */
/**
* @typedef {{
* paths: string[];
* lanes: Record<ChangedLane, boolean>;
* extensionImpactFromCore: boolean;
* docsOnly: boolean;
* reasons: string[];
* }} ChangedLaneResult
*/
export function normalizeChangedPath(inputPath) {
return String(inputPath ?? "")
.trim()
.replaceAll("\\", "/")
.replace(/^\.\/+/u, "");
}
export function createEmptyChangedLanes() {
return {
core: false,
coreTests: false,
extensions: false,
extensionTests: false,
apps: false,
docs: false,
tooling: false,
all: false,
};
}
/**
* @param {string[]} changedPaths
* @returns {ChangedLaneResult}
*/
export function detectChangedLanes(changedPaths) {
const paths = [...new Set(changedPaths.map(normalizeChangedPath).filter(Boolean))].toSorted(
(left, right) => left.localeCompare(right),
);
const lanes = createEmptyChangedLanes();
const reasons = [];
let extensionImpactFromCore = false;
let hasNonDocs = false;
if (paths.length === 0) {
reasons.push("no changed paths");
return { paths, lanes, extensionImpactFromCore: false, docsOnly: false, reasons };
}
for (const changedPath of paths) {
if (DOCS_PATH_RE.test(changedPath)) {
lanes.docs = true;
continue;
}
hasNonDocs = true;
if (ROOT_GLOBAL_PATH_RE.test(changedPath)) {
lanes.all = true;
extensionImpactFromCore = true;
reasons.push(`${changedPath}: root config/package surface`);
continue;
}
if (PUBLIC_EXTENSION_CONTRACT_RE.test(changedPath)) {
lanes.core = true;
lanes.coreTests = true;
lanes.extensions = true;
lanes.extensionTests = true;
extensionImpactFromCore = true;
reasons.push(`${changedPath}: public core/plugin contract affects extensions`);
continue;
}
if (EXTENSION_PATH_RE.test(changedPath)) {
if (TEST_PATH_RE.test(changedPath)) {
lanes.extensionTests = true;
reasons.push(`${changedPath}: extension test`);
} else {
lanes.extensions = true;
lanes.extensionTests = true;
reasons.push(`${changedPath}: extension production`);
}
continue;
}
if (CORE_PATH_RE.test(changedPath)) {
if (TEST_PATH_RE.test(changedPath)) {
lanes.coreTests = true;
reasons.push(`${changedPath}: core test`);
} else {
lanes.core = true;
lanes.coreTests = true;
reasons.push(`${changedPath}: core production`);
}
continue;
}
if (APP_PATH_RE.test(changedPath)) {
lanes.apps = true;
reasons.push(`${changedPath}: app surface`);
continue;
}
if (changedPath.startsWith("test/")) {
lanes.tooling = true;
reasons.push(`${changedPath}: root test/support surface`);
continue;
}
if (TOOLING_PATH_RE.test(changedPath)) {
lanes.tooling = true;
reasons.push(`${changedPath}: tooling surface`);
continue;
}
lanes.all = true;
extensionImpactFromCore = true;
reasons.push(`${changedPath}: unknown surface; fail-safe all lanes`);
}
return {
paths,
lanes,
extensionImpactFromCore,
docsOnly: lanes.docs && !hasNonDocs,
reasons,
};
}
/**
* @param {{ base: string; head?: string; includeWorktree?: boolean }} params
* @returns {string[]}
*/
export function listChangedPathsFromGit(params) {
const base = params.base;
const head = params.head ?? "HEAD";
if (!base) {
return [];
}
const rangePaths = runGitNameOnlyDiff([`${base}...${head}`]);
if (params.includeWorktree === false) {
return rangePaths;
}
return [
...new Set([
...rangePaths,
...runGitNameOnlyDiff(["--cached", "--diff-filter=ACMR"]),
...runGitNameOnlyDiff(["--diff-filter=ACMR"]),
...runGitLsFiles(["--others", "--exclude-standard"]),
]),
].toSorted((left, right) => left.localeCompare(right));
}
function runGitNameOnlyDiff(extraArgs) {
const output = execFileSync("git", ["diff", "--name-only", ...extraArgs], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
}
function runGitLsFiles(extraArgs) {
const output = execFileSync("git", ["ls-files", ...extraArgs], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
}
export function listStagedChangedPaths() {
const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
return output.split("\n").map(normalizeChangedPath).filter(Boolean);
}
export function writeChangedLaneGitHubOutput(result, outputPath = process.env.GITHUB_OUTPUT) {
if (!outputPath) {
throw new Error("GITHUB_OUTPUT is required");
}
for (const [lane, enabled] of Object.entries(result.lanes)) {
appendFileSync(outputPath, `run_${toSnakeCase(lane)}=${String(enabled)}\n`, "utf8");
}
appendFileSync(outputPath, `docs_only=${result.docsOnly}\n`, "utf8");
appendFileSync(
outputPath,
`extension_impact_from_core=${result.extensionImpactFromCore}\n`,
"utf8",
);
}
function toSnakeCase(value) {
return value.replace(/[A-Z]/gu, (match) => `_${match.toLowerCase()}`);
}
function parseArgs(argv) {
const args = {
base: "origin/main",
head: "HEAD",
staged: false,
json: false,
githubOutput: false,
paths: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
args.base = argv[index + 1] ?? args.base;
index += 1;
continue;
}
if (arg === "--head") {
args.head = argv[index + 1] ?? args.head;
index += 1;
continue;
}
if (arg === "--staged") {
args.staged = true;
continue;
}
if (arg === "--json") {
args.json = true;
continue;
}
if (arg === "--github-output") {
args.githubOutput = true;
continue;
}
args.paths.push(arg);
}
return args;
}
function isDirectRun() {
const direct = process.argv[1];
return Boolean(direct && import.meta.url.endsWith(direct));
}
function printHuman(result) {
const enabled = Object.entries(result.lanes)
.filter(([, value]) => value)
.map(([lane]) => lane);
console.log(`lanes: ${enabled.length > 0 ? enabled.join(", ") : "none"}`);
if (result.docsOnly) {
console.log("docs-only: true");
}
if (result.extensionImpactFromCore) {
console.log("extension-impact-from-core: true");
}
if (result.paths.length > 0) {
console.log("paths:");
for (const changedPath of result.paths) {
console.log(`- ${changedPath}`);
}
}
if (result.reasons.length > 0) {
console.log("reasons:");
for (const reason of result.reasons) {
console.log(`- ${reason}`);
}
}
}
if (isDirectRun()) {
const args = parseArgs(process.argv.slice(2));
const paths =
args.paths.length > 0
? args.paths
: args.staged
? listStagedChangedPaths()
: listChangedPathsFromGit({ base: args.base, head: args.head });
const result = detectChangedLanes(paths);
if (args.githubOutput) {
writeChangedLaneGitHubOutput(result);
}
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else if (!args.githubOutput) {
printHuman(result);
}
}

274
scripts/check-changed.mjs Normal file
View File

@@ -0,0 +1,274 @@
import { spawn } from "node:child_process";
import { performance } from "node:perf_hooks";
import {
detectChangedLanes,
listChangedPathsFromGit,
listStagedChangedPaths,
normalizeChangedPath,
} from "./changed-lanes.mjs";
const ROUTABLE_TEST_PATH_RE = /^(?:src|test|extensions|ui|packages|apps)(?:\/|$)/u;
export function createChangedCheckPlan(result) {
const commands = [];
const add = (name, args) => {
if (!commands.some((command) => command.name === name && sameArgs(command.args, args))) {
commands.push({ name, args });
}
};
add("conflict markers", ["check:no-conflict-markers"]);
if (result.docsOnly) {
return {
commands,
testTargets: [],
runFullTests: false,
runExtensionTests: false,
summary: "docs-only",
};
}
const lanes = result.lanes;
const runAll = lanes.all;
if (runAll) {
add("typecheck all", ["tsgo:all"]);
add("lint", ["lint"]);
add("runtime import cycles", ["check:import-cycles"]);
return {
commands,
testTargets: [],
runFullTests: true,
runExtensionTests: false,
summary: "all",
};
}
if (lanes.core) {
add("typecheck core", ["tsgo:core"]);
}
if (lanes.coreTests) {
add("typecheck core tests", ["tsgo:core:test"]);
}
if (lanes.extensions) {
add("typecheck extensions", ["tsgo:extensions"]);
}
if (lanes.extensionTests) {
add("typecheck extension tests", ["tsgo:extensions:test"]);
}
if (lanes.core || lanes.coreTests) {
add("lint core", ["lint:core"]);
}
if (lanes.extensions || lanes.extensionTests) {
add("lint extensions", ["lint:extensions"]);
}
if (lanes.tooling) {
add("lint scripts", ["lint:scripts"]);
}
if (lanes.apps) {
add("lint apps", ["lint:apps"]);
}
if (lanes.core || lanes.extensions) {
add("runtime import cycles", ["check:import-cycles"]);
}
if (lanes.core) {
add("webhook body guard", ["lint:webhook:no-low-level-body-read"]);
add("pairing store guard", ["lint:auth:no-pairing-store-group"]);
add("pairing account guard", ["lint:auth:pairing-account-scope"]);
}
const testTargets = result.paths.filter((changedPath) => ROUTABLE_TEST_PATH_RE.test(changedPath));
return {
commands,
testTargets,
runFullTests: false,
runExtensionTests: result.extensionImpactFromCore,
summary: Object.entries(lanes)
.filter(([, enabled]) => enabled)
.map(([lane]) => lane)
.join(", "),
};
}
export async function runChangedCheck(result, options = {}) {
const plan = createChangedCheckPlan(result);
printPlan(result, plan, options);
if (options.dryRun) {
return 0;
}
const timings = [];
for (const command of plan.commands) {
const status = await runPnpm(command, timings);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
if (plan.runFullTests) {
const status = await runPnpm({ name: "tests all", args: ["test"] }, timings);
if (status !== 0) {
printSummary(timings, options);
return status;
}
} else if (plan.testTargets.length > 0) {
const status = await runNode(
{
name: "tests changed",
args: ["scripts/test-projects.mjs", ...plan.testTargets],
},
timings,
);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
if (plan.runExtensionTests) {
const status = await runPnpm({ name: "tests extensions", args: ["test:extensions"] }, timings);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
printSummary(timings, options);
return 0;
}
function sameArgs(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function printPlan(result, plan, options) {
const prefix = options.dryRun ? "[check:changed:dry-run]" : "[check:changed]";
console.error(`${prefix} lanes=${plan.summary || "none"}`);
if (result.extensionImpactFromCore) {
console.error(`${prefix} core contract changed; extension tests included`);
}
for (const reason of result.reasons) {
console.error(`${prefix} ${reason}`);
}
if (plan.testTargets.length > 0) {
console.error(`${prefix} test targets=${plan.testTargets.length}`);
}
}
async function runPnpm(command, timings) {
return await runCommand({ ...command, bin: "pnpm" }, timings);
}
async function runNode(command, timings) {
return await runCommand({ ...command, bin: process.execPath }, timings);
}
async function runCommand(command, timings) {
const startedAt = performance.now();
console.error(`\n[check:changed] ${command.name}`);
const child = spawn(command.bin, command.args, {
stdio: "inherit",
shell: process.platform === "win32",
});
return await new Promise((resolve) => {
child.once("error", (error) => {
console.error(error);
timings.push({
name: command.name,
durationMs: performance.now() - startedAt,
status: 1,
});
resolve(1);
});
child.once("close", (status) => {
const resolvedStatus = status ?? 1;
timings.push({
name: command.name,
durationMs: performance.now() - startedAt,
status: resolvedStatus,
});
resolve(resolvedStatus);
});
});
}
function printSummary(timings, options) {
if (!options.timed && timings.every((timing) => timing.status === 0)) {
return;
}
console.error("\n[check:changed] summary");
for (const timing of timings) {
const status = timing.status === 0 ? "ok" : `failed:${timing.status}`;
console.error(
`${formatMs(timing.durationMs).padStart(8)} ${status.padEnd(9)} ${timing.name}`,
);
}
}
function formatMs(durationMs) {
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
}
return `${(durationMs / 1000).toFixed(2)}s`;
}
function parseArgs(argv) {
const args = {
base: "origin/main",
head: "HEAD",
staged: false,
dryRun: false,
timed: false,
paths: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
args.base = argv[index + 1] ?? args.base;
index += 1;
continue;
}
if (arg === "--head") {
args.head = argv[index + 1] ?? args.head;
index += 1;
continue;
}
if (arg === "--staged") {
args.staged = true;
continue;
}
if (arg === "--dry-run") {
args.dryRun = true;
continue;
}
if (arg === "--timed") {
args.timed = true;
continue;
}
args.paths.push(normalizeChangedPath(arg));
}
return args;
}
function isDirectRun() {
const direct = process.argv[1];
return Boolean(direct && import.meta.url.endsWith(direct));
}
if (isDirectRun()) {
const args = parseArgs(process.argv.slice(2));
const paths =
args.paths.length > 0
? args.paths
: args.staged
? listStagedChangedPaths()
: listChangedPathsFromGit({ base: args.base, head: args.head });
const result = detectChangedLanes(paths);
process.exitCode = await runChangedCheck(result, args);
}