#!/usr/bin/env node/** * `pnpm gate:changed`: the local check before pushing a change to src/ * (docs/agent-validation.md). CI stays the full suite; this runs the stages * whose failures a change most often sends to CI, scoped to what the branch * changed against `origin/main`: * * lint oxlint over src and tools with the warning cap, as `pnpm lint` * runs it: a new warning anywhere breaks the cap * knip unused files, exports and dependencies, always: a moved or * deleted import strands a file anywhere in the tree * typecheck tsc over the app and the Node config, as `pnpm build` runs it * i18n `pnpm i18n:check`, when a source or catalog changed * perf `pnpm perf:evidence`, when docs/performance* changed * notices `pnpm notices:check`, when a lockfile, manifest or the notices * themselves changed * rustfmt `cargo fmt --check`, when a .rs file changed; it builds nothing * vitest the tests `vitest related` finds from the changed files, plus * every test in src/platform/invariant-tests.json, which read * stylesheets and fixtures, or run scripts, no import graph leads * to (the React Compiler guard among them) * * Without it, agents assembled their own lists of test files, took 9–20 * minutes a run, and still missed both real CI failures of a round: each was * an invariant test reading a stylesheet the change had edited. * * Every stage runs, so one pass shows every red. Each prints one line, and a * failed one prints a bounded excerpt of its log, which is kept whole under * .tmp/gate-changed/. A stage that outlives its timeout is killed and fails. * Nothing here opens a browser or builds Rust. When a change reaches the Rust * crate — src-tauri/, or a file elsewhere that Rust `include_str!`s — it says * so, and CI's `rust` job is the check. * * Options: * --base REF compare against REF instead of origin/main (its merge base) */import { spawn, execFileSync } from "node:child_process";import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";import path from "node:path";import { fileURLToPath } from "node:url";import { invariantTests } from "./test-invariants.mjs"; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");const logDir = path.join(repo, ".tmp/gate-changed"); function parseArgs(argv) { const options = { base: "origin/main" }; for (let index = 0; index < argv.length; index += 1) { if (argv[index] === "--base") options.base = argv[++index]; else throw new Error(`Unknown option ${argv[index]}`); } return options;} const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }); /** * Files the branch adds or modifies against the merge base, committed or not, * plus untracked ones. A deleted file has nothing to check by itself; the * tests that imported it fail in `vitest related` through their own edits, or * in CI. */function changedFiles(base) { const mergeBase = git("merge-base", "HEAD", base).trim(); const lines = [ ...git("-c", "core.quotePath=false", "diff", "--name-only", "--no-renames", mergeBase).split("\n"), ...git("-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard").split("\n"), ]; return { mergeBase, files: [...new Set(lines.filter(Boolean))].filter((file) => existsSync(path.join(repo, file))) };} // The inputs mise.toml's `notices` task lists: the closures are read from the// installed trees, so the lockfiles and manifests are what moves them.const NOTICE_INPUTS = /^(package\.json|pnpm-lock\.yaml|src-tauri\/Cargo\.(toml|lock)|scripts\/generate-notices\.mjs|scripts\/lib\/(util|package-notices|license-classification|license-grouping|dependency-closures|upstream-licenses)\.mjs|(public\/)?licenses\/.*|THIRD_PARTY_NOTICES\.md)$/;const PERF_EVIDENCE_INPUTS = /^(docs\/performance(\.md|-data\/.*)|scripts\/check-perf-evidence\.mjs)$/; /** * Files outside src-tauri/ that the Rust crate compiles in through * `include_str!`/`include_bytes!`, mapped to the .rs files that do: editing * one changes what `cargo test` asserts (traffic_lights.rs pins the title-bar * stylesheets this way). A `concat!` path keeps only its literal prefix, * which then names a directory. */function rustIncludes() { const includes = []; const sources = git("ls-files", "-z", "src-tauri/*.rs").split("\0").filter(Boolean); for (const source of sources) { const text = readFileSync(path.join(repo, source), "utf8"); for (const match of text.matchAll(/\binclude_(?:str|bytes)!\(\s*(?:concat!\(\s*)?"([^"]+)"/g)) { const target = path.posix.normalize(path.posix.join(path.posix.dirname(source), match[1])); if (!target.startsWith("src-tauri/")) includes.push({ target, prefix: match[1].endsWith("/"), source }); } } return includes;} /** * Runs one stage in its own process group, so a timeout kills everything it * started (pnpm, then Vitest's workers), and resolves with its status and log. */function run(name, command, args, timeoutMs) { return new Promise((resolve) => { const started = Date.now(); const child = spawn(command, args, { cwd: repo, detached: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, FORCE_COLOR: "0" } }); running.add(child); let log = ""; child.stdout.on("data", (chunk) => (log += chunk)); child.stderr.on("data", (chunk) => (log += chunk)); let timedOut = false; const timer = setTimeout(() => { timedOut = true; killGroup(child, "SIGTERM"); setTimeout(() => killGroup(child, "SIGKILL"), 5_000).unref(); }, timeoutMs); child.on("close", (code) => { clearTimeout(timer); running.delete(child); const file = path.join(logDir, `${name}.log`); writeFileSync(file, log); resolve({ ok: code === 0 && !timedOut, timedOut, seconds: (Date.now() - started) / 1000, log, file }); }); });} const running = new Set();function killGroup(child, signal) { try { process.kill(-child.pid, signal); } catch { // Already gone. }}for (const signal of ["SIGINT", "SIGTERM"]) { process.on(signal, () => { for (const child of running) killGroup(child, "SIGTERM"); process.exit(130); });} /** * The head and tail of a long log: compilers report first, test runners last. * Oxlint's standing warnings (under the cap) would bury the one error, so a * warning line is dropped, and any line is cut to a readable length. */function excerpt(log) { const lines = log .trimEnd() .split("\n") .filter((line) => !/^\S+:\d+:\d+: warning /.test(line)) .map((line) => (line.length > 400 ? `${line.slice(0, 400)}…` : line)); if (lines.length <= 60) return lines; return [...lines.slice(0, 30), `… ${lines.length - 60} lines omitted …`, ...lines.slice(-30)];} async function main() { const options = parseArgs(process.argv.slice(2)); const { mergeBase, files } = changedFiles(options.base); mkdirSync(logDir, { recursive: true }); console.log(`gate:changed: ${files.length} changed file(s) against ${options.base} (${mergeBase.slice(0, 9)})`); const code = files.filter((file) => /^(src|tools|scripts)\/.*\.(ts|tsx|mts|js|jsx|mjs|css|json)$/.test(file)); const sources = files.filter((file) => /^src\/.*\.tsx?$/.test(file) || file.startsWith("src/locales/")); const rust = files.filter((file) => file.endsWith(".rs")); const stages = [ { name: "lint", why: "oxlint, src and tools", command: "pnpm", args: ["lint"], timeout: 180_000 }, { name: "knip", why: "unused files, exports and dependencies", command: "pnpm", args: ["knip"], timeout: 180_000 }, { name: "typecheck", why: "tsc, app and Node config", command: "sh", args: ["-c", "pnpm exec tsc && pnpm exec tsc -p tsconfig.node.json --noEmit"], timeout: 300_000, }, sources.length ? { name: "i18n", why: "catalog extraction and coverage", command: "pnpm", args: ["i18n:check"], timeout: 300_000 } : { name: "i18n", skip: "no source or catalog changed" }, files.some((file) => PERF_EVIDENCE_INPUTS.test(file)) ? { name: "perf", why: "benchmark tables against their evidence", command: "pnpm", args: ["perf:evidence"], timeout: 60_000 } : { name: "perf", skip: "docs/performance* unchanged" }, files.some((file) => NOTICE_INPUTS.test(file)) ? { name: "notices", why: "THIRD_PARTY_NOTICES.md against the installed trees", command: "pnpm", args: ["notices:check"], timeout: 120_000 } : { name: "notices", skip: "no lockfile, manifest or notice changed" }, rust.length ? { name: "rustfmt", why: `${rust.length} changed .rs file(s)`, command: "cargo", args: ["fmt", "--manifest-path", "src-tauri/Cargo.toml", "--all", "--", "--check"], timeout: 120_000, } : { name: "rustfmt", skip: "no .rs file changed" }, { name: "vitest", why: `related to ${code.length} changed file(s), plus ${invariantTests().length} invariant tests`, command: "pnpm", args: ["exec", "vitest", "related", "--run", "--passWithNoTests", ...code, ...invariantTests()], timeout: 900_000, }, ]; let failed = 0; for (const stage of stages) { if (stage.skip) { console.log(` ${stage.name.padEnd(10)} skipped (${stage.skip})`); continue; } const result = await run(stage.name, stage.command, stage.args, stage.timeout); const verdict = result.ok ? "ok" : result.timedOut ? `TIMED OUT after ${stage.timeout / 1000} s` : "FAILED"; console.log(` ${stage.name.padEnd(10)} ${verdict.padEnd(8)} ${result.seconds.toFixed(1).padStart(6)} s (${stage.why})`); if (!result.ok) { failed += 1; console.log(excerpt(result.log).map((line) => ` | ${line}`).join("\n")); console.log(` full log: ${path.relative(repo, result.file)}`); } } const included = rustIncludes().filter(({ target, prefix }) => files.some((file) => (prefix ? file.startsWith(target) : file === target))); if (files.some((file) => file.startsWith("src-tauri/")) || included.length) { const reach = [ files.some((file) => file.startsWith("src-tauri/")) && "src-tauri/ changed", included.length && `${[...new Set(included.map(({ source }) => source))].join(", ")} compiles in ${[...new Set(included.map(({ target }) => target))].join(", ")}`, ].filter(Boolean).join("; "); console.log(` rust not run (${reach}: run the touched module's \`cargo test\` through pueue; CI's \`rust\` job runs the rest)`); } console.log(failed ? `gate:changed: ${failed} stage(s) failed` : "gate:changed: ok; CI runs everything else"); process.exitCode = failed ? 1 : 0;} await main().catch((error) => { console.error(`gate:changed: ${error.message}`); process.exit(1);});
#!/usr/bin/env node
/**
* `pnpm gate:changed`: the local check before pushing a change to src/
* (docs/agent-validation.md). CI stays the full suite; this runs the stages
* whose failures a change most often sends to CI, scoped to what the branch
* changed against `origin/main`:
*
* lint oxlint over src and tools with the warning cap, as `pnpm lint`
* runs it: a new warning anywhere breaks the cap
* knip unused files, exports and dependencies, always: a moved or
* deleted import strands a file anywhere in the tree
* typecheck tsc over the app and the Node config, as `pnpm build` runs it
* i18n `pnpm i18n:check`, when a source or catalog changed
* perf `pnpm perf:evidence`, when docs/performance* changed
* notices `pnpm notices:check`, when a lockfile, manifest or the notices
* themselves changed
* rustfmt `cargo fmt --check`, when a .rs file changed; it builds nothing
* vitest the tests `vitest related` finds from the changed files, plus
* every test in src/platform/invariant-tests.json, which read
* stylesheets and fixtures, or run scripts, no import graph leads
* to (the React Compiler guard among them)
*
* Without it, agents assembled their own lists of test files, took 9–20
* minutes a run, and still missed both real CI failures of a round: each was
* an invariant test reading a stylesheet the change had edited.
*
* Every stage runs, so one pass shows every red. Each prints one line, and a
* failed one prints a bounded excerpt of its log, which is kept whole under
* .tmp/gate-changed/. A stage that outlives its timeout is killed and fails.
* Nothing here opens a browser or builds Rust. When a change reaches the Rust
* crate — src-tauri/, or a file elsewhere that Rust `include_str!`s — it says
* so, and CI's `rust` job is the check.
*
* Options:
* --base REF compare against REF instead of origin/main (its merge base)
*/
import { spawn, execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { invariantTests } from "./test-invariants.mjs";
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const logDir = path.join(repo, ".tmp/gate-changed");
function parseArgs(argv) {
const options = { base: "origin/main" };
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--base") options.base = argv[++index];
else throw new Error(`Unknown option ${argv[index]}`);
}
return options;
}
const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" });
/**
* Files the branch adds or modifies against the merge base, committed or not,
* plus untracked ones. A deleted file has nothing to check by itself; the
* tests that imported it fail in `vitest related` through their own edits, or
* in CI.
*/
function changedFiles(base) {
const mergeBase = git("merge-base", "HEAD", base).trim();
const lines = [
...git("-c", "core.quotePath=false", "diff", "--name-only", "--no-renames", mergeBase).split("\n"),
...git("-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard").split("\n"),
];
return { mergeBase, files: [...new Set(lines.filter(Boolean))].filter((file) => existsSync(path.join(repo, file))) };
}
// The inputs mise.toml's `notices` task lists: the closures are read from the
// installed trees, so the lockfiles and manifests are what moves them.
const NOTICE_INPUTS = /^(package\.json|pnpm-lock\.yaml|src-tauri\/Cargo\.(toml|lock)|scripts\/generate-notices\.mjs|scripts\/lib\/(util|package-notices|license-classification|license-grouping|dependency-closures|upstream-licenses)\.mjs|(public\/)?licenses\/.*|THIRD_PARTY_NOTICES\.md)$/;
const PERF_EVIDENCE_INPUTS = /^(docs\/performance(\.md|-data\/.*)|scripts\/check-perf-evidence\.mjs)$/;
/**
* Files outside src-tauri/ that the Rust crate compiles in through
* `include_str!`/`include_bytes!`, mapped to the .rs files that do: editing
* one changes what `cargo test` asserts (traffic_lights.rs pins the title-bar
* stylesheets this way). A `concat!` path keeps only its literal prefix,
* which then names a directory.
*/
function rustIncludes() {
const includes = [];
const sources = git("ls-files", "-z", "src-tauri/*.rs").split("\0").filter(Boolean);
for (const source of sources) {
const text = readFileSync(path.join(repo, source), "utf8");
for (const match of text.matchAll(/\binclude_(?:str|bytes)!\(\s*(?:concat!\(\s*)?"([^"]+)"/g)) {
const target = path.posix.normalize(path.posix.join(path.posix.dirname(source), match[1]));
if (!target.startsWith("src-tauri/")) includes.push({ target, prefix: match[1].endsWith("/"), source });
}
}
return includes;
}
/**
* Runs one stage in its own process group, so a timeout kills everything it
* started (pnpm, then Vitest's workers), and resolves with its status and log.
*/
function run(name, command, args, timeoutMs) {
return new Promise((resolve) => {
const started = Date.now();
const child = spawn(command, args, { cwd: repo, detached: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, FORCE_COLOR: "0" } });
running.add(child);
let log = "";
child.stdout.on("data", (chunk) => (log += chunk));
child.stderr.on("data", (chunk) => (log += chunk));
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
killGroup(child, "SIGTERM");
setTimeout(() => killGroup(child, "SIGKILL"), 5_000).unref();
}, timeoutMs);
child.on("close", (code) => {
clearTimeout(timer);
running.delete(child);
const file = path.join(logDir, `${name}.log`);
writeFileSync(file, log);
resolve({ ok: code === 0 && !timedOut, timedOut, seconds: (Date.now() - started) / 1000, log, file });
});
});
}
const running = new Set();
function killGroup(child, signal) {
try {
process.kill(-child.pid, signal);
} catch {
// Already gone.
}
}
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
for (const child of running) killGroup(child, "SIGTERM");
process.exit(130);
});
}
/**
* The head and tail of a long log: compilers report first, test runners last.
* Oxlint's standing warnings (under the cap) would bury the one error, so a
* warning line is dropped, and any line is cut to a readable length.
*/
function excerpt(log) {
const lines = log
.trimEnd()
.split("\n")
.filter((line) => !/^\S+:\d+:\d+: warning /.test(line))
.map((line) => (line.length > 400 ? `${line.slice(0, 400)}…` : line));
if (lines.length <= 60) return lines;
return [...lines.slice(0, 30), `… ${lines.length - 60} lines omitted …`, ...lines.slice(-30)];
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const { mergeBase, files } = changedFiles(options.base);
mkdirSync(logDir, { recursive: true });
console.log(`gate:changed: ${files.length} changed file(s) against ${options.base} (${mergeBase.slice(0, 9)})`);
const code = files.filter((file) => /^(src|tools|scripts)\/.*\.(ts|tsx|mts|js|jsx|mjs|css|json)$/.test(file));
const sources = files.filter((file) => /^src\/.*\.tsx?$/.test(file) || file.startsWith("src/locales/"));
const rust = files.filter((file) => file.endsWith(".rs"));
const stages = [
{ name: "lint", why: "oxlint, src and tools", command: "pnpm", args: ["lint"], timeout: 180_000 },
{ name: "knip", why: "unused files, exports and dependencies", command: "pnpm", args: ["knip"], timeout: 180_000 },
{
name: "typecheck",
why: "tsc, app and Node config",
command: "sh",
args: ["-c", "pnpm exec tsc && pnpm exec tsc -p tsconfig.node.json --noEmit"],
timeout: 300_000,
},
sources.length
? { name: "i18n", why: "catalog extraction and coverage", command: "pnpm", args: ["i18n:check"], timeout: 300_000 }
: { name: "i18n", skip: "no source or catalog changed" },
files.some((file) => PERF_EVIDENCE_INPUTS.test(file))
? { name: "perf", why: "benchmark tables against their evidence", command: "pnpm", args: ["perf:evidence"], timeout: 60_000 }
: { name: "perf", skip: "docs/performance* unchanged" },
files.some((file) => NOTICE_INPUTS.test(file))
? { name: "notices", why: "THIRD_PARTY_NOTICES.md against the installed trees", command: "pnpm", args: ["notices:check"], timeout: 120_000 }
: { name: "notices", skip: "no lockfile, manifest or notice changed" },
rust.length
? {
name: "rustfmt",
why: `${rust.length} changed .rs file(s)`,
command: "cargo",
args: ["fmt", "--manifest-path", "src-tauri/Cargo.toml", "--all", "--", "--check"],
timeout: 120_000,
}
: { name: "rustfmt", skip: "no .rs file changed" },
{
name: "vitest",
why: `related to ${code.length} changed file(s), plus ${invariantTests().length} invariant tests`,
command: "pnpm",
args: ["exec", "vitest", "related", "--run", "--passWithNoTests", ...code, ...invariantTests()],
timeout: 900_000,
},
];
let failed = 0;
for (const stage of stages) {
if (stage.skip) {
console.log(` ${stage.name.padEnd(10)} skipped (${stage.skip})`);
continue;
}
const result = await run(stage.name, stage.command, stage.args, stage.timeout);
const verdict = result.ok ? "ok" : result.timedOut ? `TIMED OUT after ${stage.timeout / 1000} s` : "FAILED";
console.log(` ${stage.name.padEnd(10)} ${verdict.padEnd(8)} ${result.seconds.toFixed(1).padStart(6)} s (${stage.why})`);
if (!result.ok) {
failed += 1;
console.log(excerpt(result.log).map((line) => ` | ${line}`).join("\n"));
console.log(` full log: ${path.relative(repo, result.file)}`);
}
}
const included = rustIncludes().filter(({ target, prefix }) => files.some((file) => (prefix ? file.startsWith(target) : file === target)));
if (files.some((file) => file.startsWith("src-tauri/")) || included.length) {
const reach = [
files.some((file) => file.startsWith("src-tauri/")) && "src-tauri/ changed",
included.length && `${[...new Set(included.map(({ source }) => source))].join(", ")} compiles in ${[...new Set(included.map(({ target }) => target))].join(", ")}`,
].filter(Boolean).join("; ");
console.log(` rust not run (${reach}: run the touched module's \`cargo test\` through pueue; CI's \`rust\` job runs the rest)`);
}
console.log(failed ? `gate:changed: ${failed} stage(s) failed` : "gate:changed: ok; CI runs everything else");
process.exitCode = failed ? 1 : 0;
}
await main().catch((error) => {
console.error(`gate:changed: ${error.message}`);
process.exit(1);
});