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); }