Skip to content

Instantly share code, notes, and snippets.

@lodekeeper
Created February 20, 2026 10:23
Show Gist options
  • Select an option

  • Save lodekeeper/ee6dfae5453eaaf31a6dc3362bada3ef to your computer and use it in GitHub Desktop.

Select an option

Save lodekeeper/ee6dfae5453eaaf31a6dc3362bada3ef to your computer and use it in GitHub Desktop.
Lodestar pre-push validation script (spec + implementation)

Pre-Push Validation Script for Lodestar — Final Spec (v2.1)

Overview

scripts/pre-validate.mjs — A Node.js ESM script that validates local changes before pushing to avoid CI failures. Zero external dependencies.

CLI Interface

Usage: node scripts/pre-validate.mjs [options]

Options:
  --quick                    Skip build, test only changed packages (no dependents)
  --strict                   Full validation: build + test changed + dependents (default)
  --base <ref>               Git diff base ref (see fallback rules below)
  --all                      Run all checks on all packages
  --dry-run                  Print scope and commands without executing
  --ci-order                 Use CI step order (build first, then lint+types). Implies sequential.
  --max-packages <n>         Threshold for global test fallback (default: 8)
  --no-fetch                 Don't fetch origin before computing scope
  --verbose                  Show detailed output
  --help                     Show help

Modes

Mode Build Tests scope When
--quick Skip Changed packages only Fast local iteration
--strict (default) Yes Changed + transitive dependents Pre-push
--all Yes All packages Full validation

Step Order

Default (fail-fast for local dev)

  1. Fetch origin (unless --no-fetch)
  2. Detect scope — compute affected packages
  3. Lint + Type-check — run in parallel via Promise.all
  4. Build — strict/all mode only
  5. Unit tests — targeted per affected packages

--ci-order (mirrors CI exactly)

  1. Fetch + detect scope
  2. Build
  3. Lint + Type-check (parallel)
  4. Unit tests

Scope Detection

Base ref resolution (ordered fallback)

  1. --base <ref> if provided
  2. Current branch upstream (@{upstream}) if exists
  3. origin/unstable (default)

Changed files

// NUL-delimited for safety, include renames/deletes
execSync('git diff --name-only -z --diff-filter=ACMRD <base>..HEAD')

Path Classification Matrix

Pattern Classification Action
packages/<name>/src/** Source change Test package + transitive dependents (strict)
packages/<name>/test/** Test change Test package only
packages/<name>/package.json Package config Test package + dependents
Root: tsconfig*.json, vitest.config.*, biome.json, pnpm-lock.yaml, package.json Root config ALL packages
scripts/**, docs/**, .github/**, *.md Non-code Lint + types only, skip tests
Anything else at root Unknown Lint + types only

Dependency Graph

// Build from packages/*/package.json
// Include: dependencies, devDependencies, peerDependencies, optionalDependencies
// Filter: workspace:* protocol entries only
// Traverse: transitive dependents (reverse graph)

Global Fallback

If affectedPackages.length > maxPackages (default 8), run global unit tests instead:

pnpm test:unit

This avoids N separate test runs being slower than one global run.

Execution

Running commands

  • Use child_process.spawn with array args (no shell interpolation)
  • Parallel execution via Promise.all for lint + typecheck
  • Sequential for build and tests (deterministic output)

Per-package tests

// Deterministic ordering (sorted by package name)
for (const pkg of affectedPackages.sort()) {
  // Check if package has test:unit script
  if (!pkg.scripts['test:unit']) {
    warn(`⚠️  ${pkg.name} has no test:unit script, skipping`);
    continue;
  }
  exec(`pnpm --filter ${pkg.name} run test:unit`);
}

Output Format

🔍 Scope: 3 changed, 5 affected (with dependents)
   Changed: @lodestar/utils, @lodestar/params, @lodestar/config
   Affected: + @lodestar/beacon-node, @lodestar/validator
   Base: origin/unstable (abc1234)

✅ Lint ........... passed (2.1s)
✅ Type check ..... passed (8.3s)
✅ Build .......... passed (12.4s)
✅ @lodestar/utils  passed (1.2s)
✅ @lodestar/params passed (0.8s)
❌ @lodestar/beacon-node FAILED (3.4s)

💥 Validation failed! Fix errors before pushing.

Edge Cases

Case Behavior
No changed files Print "nothing to validate", exit 0
Only docs/non-code Run lint + types only, skip build + tests
Root config changes Treat as all packages affected
No upstream ref Error with clear message suggesting --base
Package without test:unit Skip with warning
Untracked/unstaged files Ignored (committed changes only)
Build failure Halt immediately, don't run tests
Any step failure Exit non-zero, print summary
NUL bytes in filenames Handled via -z flag

File Structure

scripts/
  pre-validate.mjs    # Main script (single file, zero deps)

Acceptance Criteria

  • Detects changed packages from git diff (merge-base semantics)
  • Classifies file changes per the path matrix
  • Computes transitive dependents from workspace dependency graph
  • Runs lint + type-check in parallel
  • Builds only in strict mode
  • Runs targeted unit tests per affected package (sorted, deterministic)
  • Falls back to global test run if too many packages affected
  • --quick mode for fast iteration (no build, no dependents)
  • --dry-run shows scope and commands without executing
  • --base configurable with smart fallback chain
  • --no-fetch prevents network calls
  • Colored output with timing per step
  • Exits non-zero on any failure with clear summary
  • Handles all edge cases gracefully
  • Zero external dependencies (Node.js built-ins only)
  • Uses spawn with array args (no shell injection)
  • NUL-delimited git output for filename safety
#!/usr/bin/env node
import {spawn} from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const DEFAULT_BASE_FALLBACK = "origin/unstable";
const DEFAULT_MAX_PACKAGES = 8;
const ROOT_DIR = process.cwd();
const PACKAGES_DIR = path.join(ROOT_DIR, "packages");
const COLOR = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m",
bold: "\x1b[1m",
};
const ICON = {
start: "🚀",
step: "🧭",
ok: "✅",
fail: "❌",
warn: "⚠️",
info: "ℹ️",
time: "⏱️",
skip: "⏭️",
};
function paint(color, text) {
return `${COLOR[color]}${text}${COLOR.reset}`;
}
function formatMs(ms) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function parseArgs(argv) {
/** @type {{
* quick: boolean;
* strict: boolean;
* base: string | null;
* all: boolean;
* dryRun: boolean;
* ciOrder: boolean;
* maxPackages: number;
* noFetch: boolean;
* verbose: boolean;
* help: boolean;
* }}
*/
const flags = {
quick: false,
strict: true,
base: null,
all: false,
dryRun: false,
ciOrder: false,
maxPackages: DEFAULT_MAX_PACKAGES,
noFetch: false,
verbose: false,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--quick":
flags.quick = true;
flags.strict = false;
break;
case "--strict":
flags.strict = true;
flags.quick = false;
break;
case "--base":
if (i + 1 >= argv.length) {
throw new Error("Missing value for --base");
}
flags.base = argv[i + 1];
i++;
break;
case "--all":
flags.all = true;
break;
case "--dry-run":
flags.dryRun = true;
break;
case "--ci-order":
flags.ciOrder = true;
break;
case "--max-packages": {
if (i + 1 >= argv.length) {
throw new Error("Missing value for --max-packages");
}
const parsed = Number.parseInt(argv[i + 1], 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid --max-packages value: ${argv[i + 1]}`);
}
flags.maxPackages = parsed;
i++;
break;
}
case "--no-fetch":
flags.noFetch = true;
break;
case "--verbose":
flags.verbose = true;
break;
case "--help":
case "-h":
flags.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return flags;
}
function printHelp() {
console.log(`Usage: node scripts/pre-validate.mjs [options]
Pre-push validation for Lodestar monorepo.
Options:
--quick Fast mode (disables strict build step)
--strict Strict mode (default; includes build step)
--base <ref> Base ref for merge-base (fallback: @{upstream}, origin/unstable)
--all Treat all packages as affected
--dry-run Print commands without executing
--ci-order Run build before lint/typecheck
--max-packages <n> Max targeted packages before global tests fallback (default: ${DEFAULT_MAX_PACKAGES})
--no-fetch Skip git fetch step
--verbose Verbose logs
--help, -h Show this help
`);
}
function runSpawn(cmd, args, options = {}) {
const {cwd = ROOT_DIR, capture = false, dryRun = false, verbose = false, allowFailure = false} = options;
const pretty = [cmd, ...args].join(" ");
if (dryRun) {
console.log(`${ICON.skip} ${paint("gray", "[dry-run]")} ${pretty}`);
return Promise.resolve({code: 0, stdout: "", stderr: ""});
}
if (verbose) {
console.log(`${ICON.info} ${paint("gray", pretty)}`);
}
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
});
let stdout = "";
let stderr = "";
if (capture && child.stdout) {
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
}
if (capture && child.stderr) {
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
}
child.on("error", (error) => {
reject(error);
});
child.on("close", (code) => {
if (code === 0 || allowFailure) {
resolve({code: code ?? 1, stdout, stderr});
return;
}
const msg = stderr.trim() || stdout.trim() || `Command failed (${code}): ${pretty}`;
reject(new Error(msg));
});
});
}
async function step(name, action) {
const started = Date.now();
console.log(`${ICON.step} ${paint("blue", name)}`);
try {
const result = await action();
const elapsed = Date.now() - started;
console.log(`${ICON.ok} ${paint("green", `${name} (${formatMs(elapsed)})`)}`);
return result;
} catch (error) {
const elapsed = Date.now() - started;
console.error(`${ICON.fail} ${paint("red", `${name} failed (${formatMs(elapsed)})`)}`);
throw error;
}
}
function listWorkspacePackages() {
if (!fs.existsSync(PACKAGES_DIR)) {
throw new Error(`Missing packages directory: ${PACKAGES_DIR}`);
}
/** @type {Map<string, {
* dir: string;
* name: string;
* localName: string;
* deps: string[];
* scripts: Record<string, string>;
* }>}
*/
const packages = new Map();
const dirs = fs.readdirSync(PACKAGES_DIR, {withFileTypes: true});
for (const entry of dirs) {
if (!entry.isDirectory()) continue;
const localName = entry.name;
const dir = path.join(PACKAGES_DIR, localName);
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) continue;
const raw = fs.readFileSync(pkgPath, "utf8");
const json = JSON.parse(raw);
if (!json.name || typeof json.name !== "string") {
throw new Error(`Invalid or missing package name in ${pkgPath}`);
}
const depSets = [
json.dependencies ?? {},
json.devDependencies ?? {},
json.peerDependencies ?? {},
json.optionalDependencies ?? {},
];
const deps = [];
for (const depSet of depSets) {
for (const [depName, depVersion] of Object.entries(depSet)) {
if (typeof depVersion === "string" && depVersion.startsWith("workspace:")) {
deps.push(depName);
}
}
}
packages.set(localName, {
dir,
name: json.name,
localName,
deps,
scripts: json.scripts ?? {},
});
}
return packages;
}
function buildDependentsGraph(packagesByLocalName) {
/** @type {Map<string, Set<string>>} */
const dependentsByPackageName = new Map();
const packageNames = new Set();
for (const pkg of packagesByLocalName.values()) {
packageNames.add(pkg.name);
if (!dependentsByPackageName.has(pkg.name)) {
dependentsByPackageName.set(pkg.name, new Set());
}
}
for (const pkg of packagesByLocalName.values()) {
for (const depName of pkg.deps) {
if (!packageNames.has(depName)) continue;
if (!dependentsByPackageName.has(depName)) {
dependentsByPackageName.set(depName, new Set());
}
dependentsByPackageName.get(depName).add(pkg.name);
}
}
return dependentsByPackageName;
}
function transitiveDependents(seedPackageNames, dependentsByPackageName) {
const queue = [...seedPackageNames];
const visited = new Set(seedPackageNames);
while (queue.length > 0) {
const current = queue.shift();
const dependents = dependentsByPackageName.get(current);
if (!dependents) continue;
for (const dependent of dependents) {
if (visited.has(dependent)) continue;
visited.add(dependent);
queue.push(dependent);
}
}
return visited;
}
async function detectBaseRef(flags) {
if (flags.base) return flags.base;
const upstream = await runSpawn("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], {
capture: true,
dryRun: flags.dryRun,
verbose: flags.verbose,
allowFailure: true,
});
if (upstream.code === 0) {
const ref = upstream.stdout.trim();
if (ref) return ref;
}
return DEFAULT_BASE_FALLBACK;
}
async function getChangedFiles(baseRef, flags) {
const mergeBaseResult = await runSpawn("git", ["merge-base", baseRef, "HEAD"], {
capture: true,
dryRun: flags.dryRun,
verbose: flags.verbose,
});
const mergeBase = mergeBaseResult.stdout.trim();
if (!mergeBase && !flags.dryRun) {
throw new Error(`Unable to resolve merge-base for ${baseRef}`);
}
const range = flags.dryRun ? `${baseRef}..HEAD` : `${mergeBase}..HEAD`;
const diffResult = await runSpawn("git", ["diff", "--name-only", "-z", "--diff-filter=ACMRD", range], {
capture: true,
dryRun: flags.dryRun,
verbose: flags.verbose,
});
if (flags.dryRun) return [];
return diffResult.stdout
.split("\0")
.filter(Boolean);
}
function classifyChanges(changedFiles, packagesByLocalName) {
/** @type {Set<string>} */
const sourceChangedLocals = new Set();
/** @type {Set<string>} */
const testChangedLocals = new Set();
let lintOnly = false;
let allPackages = false;
const rootConfigNames = new Set([
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"tsconfig.json",
"tsconfig.base.json",
"tsconfig.build.json",
"vitest.workspace.ts",
"biome.json",
"biome.jsonc",
"lerna.json",
]);
for (const filePath of changedFiles) {
const normalized = filePath.replace(/\\/g, "/");
if (normalized.startsWith("packages/")) {
const parts = normalized.split("/");
const localName = parts[1];
const area = parts[2] ?? "";
if (!packagesByLocalName.has(localName)) {
continue;
}
if (area === "src") {
sourceChangedLocals.add(localName);
} else if (area === "test") {
testChangedLocals.add(localName);
} else {
// Non src/test package files can affect package behavior, treat as source change.
sourceChangedLocals.add(localName);
}
continue;
}
if (normalized.startsWith("docs/") || normalized.startsWith("scripts/")) {
lintOnly = true;
continue;
}
const topLevel = normalized.split("/")[0];
const isTopLevelFile = !normalized.includes("/");
if (isTopLevelFile && rootConfigNames.has(topLevel)) {
allPackages = true;
continue;
}
const isLikelyRootConfig =
isTopLevelFile &&
(topLevel.endsWith(".config.js") ||
topLevel.endsWith(".config.cjs") ||
topLevel.endsWith(".config.mjs") ||
topLevel.endsWith(".config.ts") ||
topLevel.startsWith("."));
if (isLikelyRootConfig) {
allPackages = true;
}
}
return {sourceChangedLocals, testChangedLocals, lintOnly, allPackages};
}
function packageNamesFromLocals(locals, packagesByLocalName) {
const out = new Set();
for (const localName of locals) {
const pkg = packagesByLocalName.get(localName);
if (pkg) out.add(pkg.name);
}
return out;
}
function selectAffectedPackages(scope, flags, packagesByLocalName, dependentsByPackageName) {
if (flags.all || scope.allPackages) {
return new Set([...packagesByLocalName.values()].map((p) => p.name));
}
const sourceNames = packageNamesFromLocals(scope.sourceChangedLocals, packagesByLocalName);
const testNames = packageNamesFromLocals(scope.testChangedLocals, packagesByLocalName);
// In quick mode, only test directly changed packages (no transitive dependents)
if (flags.quick) {
const direct = new Set(sourceNames);
for (const testName of testNames) {
direct.add(testName);
}
return direct;
}
// In strict mode, include transitive dependents of source changes
const fromSource = transitiveDependents(sourceNames, dependentsByPackageName);
for (const name of sourceNames) fromSource.add(name);
for (const testName of testNames) {
fromSource.add(testName);
}
return fromSource;
}
function collectPackageNames(packagesByLocalName) {
return [...packagesByLocalName.values()].map((pkg) => pkg.name);
}
function pnpmRecursiveArgs(scriptName, packageNames) {
const args = ["-r"];
for (const pkgName of packageNames) {
args.push("--filter", pkgName);
}
args.push("run", scriptName);
return args;
}
async function runValidation(flags) {
console.log(`${ICON.start} ${paint("bold", "Lodestar pre-push validation")}`);
const packagesByLocalName = listWorkspacePackages();
const dependentsByPackageName = buildDependentsGraph(packagesByLocalName);
const state = {
baseRef: null,
changedFiles: [],
scope: null,
affectedPackages: new Set(),
lintOnly: false,
runGlobalTests: false,
};
const steps = flags.ciOrder
? ["fetch", "detect", "build", "lintTypecheck", "tests"]
: ["fetch", "detect", "lintTypecheck", "build", "tests"];
for (const stepName of steps) {
if (stepName === "fetch") {
await step("Fetch", async () => {
state.baseRef = await detectBaseRef(flags);
console.log(`${ICON.info} Base ref: ${paint("cyan", state.baseRef)}`);
if (flags.noFetch) {
console.log(`${ICON.skip} Skipping fetch (--no-fetch)`);
return;
}
const remote = state.baseRef.includes("/") ? state.baseRef.split("/")[0] : null;
if (remote && remote !== "@{upstream}") {
await runSpawn("git", ["fetch", "--quiet", remote], {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
return;
}
await runSpawn("git", ["fetch", "--quiet"], {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
});
continue;
}
if (stepName === "detect") {
await step("Detect scope", async () => {
if (flags.all) {
state.affectedPackages = new Set(collectPackageNames(packagesByLocalName));
state.lintOnly = false;
state.scope = {
sourceChangedLocals: new Set(),
testChangedLocals: new Set(),
lintOnly: false,
allPackages: true,
};
console.log(`${ICON.info} --all enabled; all packages affected.`);
return;
}
state.changedFiles = await getChangedFiles(state.baseRef, flags);
state.scope = classifyChanges(state.changedFiles, packagesByLocalName);
state.lintOnly = state.scope.lintOnly && !state.scope.allPackages && state.scope.sourceChangedLocals.size === 0 && state.scope.testChangedLocals.size === 0;
state.affectedPackages = selectAffectedPackages(state.scope, flags, packagesByLocalName, dependentsByPackageName);
if (flags.verbose) {
for (const changed of state.changedFiles) {
console.log(`${ICON.info} changed: ${paint("gray", changed)}`);
}
}
console.log(`${ICON.info} Changed files: ${state.changedFiles.length}`);
console.log(`${ICON.info} Affected packages: ${state.affectedPackages.size}`);
if (state.lintOnly) {
console.log(`${ICON.info} Lint-only scope detected (docs/scripts changes).`);
}
if (state.affectedPackages.size === 0 && !state.lintOnly) {
console.log(`${ICON.info} No package-impacting changes detected.`);
}
});
continue;
}
if (stepName === "lintTypecheck") {
await step("Lint + Typecheck", async () => {
if (state.affectedPackages.size === 0 && !state.lintOnly) {
console.log(`${ICON.skip} Nothing to lint/typecheck.`);
return;
}
// Lint is always global — Lodestar uses `biome check` at root level
const lintTask = runSpawn("pnpm", ["lint"], {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
if (state.lintOnly) {
await lintTask;
return;
}
const typeTask = runSpawn("pnpm", ["check-types"], {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
await Promise.all([lintTask, typeTask]);
});
continue;
}
if (stepName === "build") {
await step("Build", async () => {
if (!flags.strict) {
console.log(`${ICON.skip} Build disabled in --quick mode.`);
return;
}
if (state.lintOnly) {
console.log(`${ICON.skip} Build skipped for lint-only scope.`);
return;
}
if (state.affectedPackages.size === 0) {
console.log(`${ICON.skip} Nothing to build.`);
return;
}
// Build all — pnpm -r build handles dependency order automatically
await runSpawn("pnpm", ["build"], {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
});
continue;
}
if (stepName === "tests") {
await step("Unit tests", async () => {
if (state.lintOnly) {
console.log(`${ICON.skip} Unit tests skipped for lint-only scope.`);
return;
}
if (state.affectedPackages.size === 0) {
console.log(`${ICON.skip} No affected packages to test.`);
return;
}
const pkgs = [...state.affectedPackages].sort();
state.runGlobalTests = pkgs.length > flags.maxPackages;
if (state.runGlobalTests) {
console.log(
`${ICON.warn} Affected packages (${pkgs.length}) exceed --max-packages (${flags.maxPackages}); running global tests.`,
);
await runSpawn("pnpm", ["test:unit"], {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
return;
}
const byName = new Map([...packagesByLocalName.values()].map((pkg) => [pkg.name, pkg]));
const testable = [];
for (const pkgName of pkgs) {
const pkg = byName.get(pkgName);
if (!pkg) continue;
if (!Object.hasOwn(pkg.scripts, "test:unit")) {
console.log(`${ICON.warn} ${pkgName} has no test:unit script; skipping.`);
continue;
}
testable.push(pkgName);
}
if (testable.length === 0) {
console.log(`${ICON.skip} No packages with test:unit script in affected scope.`);
return;
}
await runSpawn("pnpm", pnpmRecursiveArgs("test:unit", testable), {
dryRun: flags.dryRun,
verbose: flags.verbose,
});
});
continue;
}
}
}
async function main() {
const started = Date.now();
try {
const flags = parseArgs(process.argv.slice(2));
if (flags.help) {
printHelp();
return;
}
await runValidation(flags);
const elapsed = Date.now() - started;
console.log(`${ICON.time} ${paint("green", `All validation steps passed in ${formatMs(elapsed)}`)}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${ICON.fail} ${paint("red", message)}`);
process.exitCode = 1;
}
}
await main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment