From f9532f549b4589afc558a7516c36d4077dafb232 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 15 May 2026 15:49:39 +0200 Subject: [PATCH] perf(cli): cache pnpm run ktx builds against a stamp file (#113) The staleness check compared source mtimes against packages/cli/dist/bin.js, but tsc only rewrites outputs whose source actually changed. Editing any non-bin source (e.g. setup.ts) left bin.js untouched, so its mtime stayed older than the sources forever and every `pnpm run ktx` invocation rebuilt the whole workspace. Write a dedicated .ktx-build-stamp after a successful build and check sources against that instead. --- scripts/run-ktx.mjs | 24 ++++-- scripts/run-ktx.test.mjs | 157 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 166 insertions(+), 15 deletions(-) diff --git a/scripts/run-ktx.mjs b/scripts/run-ktx.mjs index a283dcae..1a6ba735 100644 --- a/scripts/run-ktx.mjs +++ b/scripts/run-ktx.mjs @@ -2,7 +2,12 @@ import { spawn } from 'node:child_process'; import { constants } from 'node:fs'; -import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from 'node:fs/promises'; +import { + access as fsAccess, + readdir as fsReaddir, + stat as fsStat, + writeFile as fsWriteFile, +} from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -14,6 +19,10 @@ function cliBinPath(rootDir) { return resolve(rootDir, 'packages', 'cli', 'dist', 'bin.js'); } +function buildStampPath(rootDir) { + return resolve(rootDir, 'packages', 'cli', 'dist', '.ktx-build-stamp'); +} + async function fileExists(path, access) { try { await access(path, constants.R_OK); @@ -66,17 +75,17 @@ async function newestMtimeMs(path, fs) { return newest; } -async function isBuildStale(rootDir, binPath, fs) { - let binStats; +async function isBuildStale(rootDir, stampPath, fs) { + let stampStats; try { - binStats = await fs.stat(binPath); + stampStats = await fs.stat(stampPath); } catch { return true; } const inputPaths = await packageBuildInputPaths(rootDir, fs.readdir); for (const inputPath of inputPaths) { - if ((await newestMtimeMs(inputPath, fs)) > binStats.mtimeMs) { + if ((await newestMtimeMs(inputPath, fs)) > stampStats.mtimeMs) { return true; } } @@ -137,7 +146,9 @@ export async function runWorkspaceKtx(argv, options = {}) { stat: options.stat ?? fsStat, readdir: options.readdir ?? fsReaddir, }; + const writeFile = options.writeFile ?? fsWriteFile; const binPath = cliBinPath(rootDir); + const stampPath = buildStampPath(rootDir); const runCommand = options.runCommand ?? (options.execFile @@ -146,7 +157,7 @@ export async function runWorkspaceKtx(argv, options = {}) { const commandEnv = options.env; const binExists = await fileExists(binPath, access); - const needsBuild = !binExists || (await isBuildStale(rootDir, binPath, fs)); + const needsBuild = !binExists || (await isBuildStale(rootDir, stampPath, fs)); if (needsBuild) { stderr.write( binExists @@ -160,6 +171,7 @@ export async function runWorkspaceKtx(argv, options = {}) { ); return buildExitCode; } + await writeFile(stampPath, ''); } return await runCommand(process.execPath, [binPath, ...cliArgv], { cwd: rootDir, env: commandEnv }); diff --git a/scripts/run-ktx.test.mjs b/scripts/run-ktx.test.mjs index 1533b67c..98035aef 100644 --- a/scripts/run-ktx.test.mjs +++ b/scripts/run-ktx.test.mjs @@ -4,10 +4,18 @@ import { runWorkspaceKtx } from './run-ktx.mjs'; function freshBuildFs() { return { - stat: async (path) => ({ - mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : 1000, - isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), - }), + stat: async (path) => { + if (path.endsWith('/.ktx-build-stamp')) { + return { mtimeMs: 2000, isDirectory: () => false }; + } + if (path.endsWith('/packages/cli/dist/bin.js')) { + return { mtimeMs: 2000, isDirectory: () => false }; + } + return { + mtimeMs: 1000, + isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), + }; + }, readdir: async (path) => { if (path.endsWith('/packages')) { return [{ name: 'cli', isDirectory: () => true }]; @@ -108,6 +116,7 @@ test('runWorkspaceKtx drops a leading npm argument separator', async () => { test('runWorkspaceKtx builds the workspace CLI before running it when dist is missing', async () => { const calls = []; const logs = []; + const writes = []; let binExists = false; const exitCode = await runWorkspaceKtx(['setup', 'demo', '--mode', 'replay', '--no-input', '--viz'], { @@ -125,6 +134,9 @@ test('runWorkspaceKtx builds the workspace CLI before running it when dist is mi } return { stdout: 'Replay complete\n', stderr: '' }; }, + writeFile: async (path, contents) => { + writes.push({ path, contents }); + }, stdout: { write: (chunk) => logs.push(['stdout', chunk]) }, stderr: { write: (chunk) => logs.push(['stderr', chunk]) }, }); @@ -145,20 +157,32 @@ test('runWorkspaceKtx builds the workspace CLI before running it when dist is mi ['stdout', 'build ok\n'], ['stdout', 'Replay complete\n'], ]); + assert.deepEqual(writes, [ + { path: '/workspace/ktx/packages/cli/dist/.ktx-build-stamp', contents: '' }, + ]); }); -test('runWorkspaceKtx rebuilds before running when workspace sources are newer than dist', async () => { +test('runWorkspaceKtx rebuilds before running when workspace sources are newer than the build stamp', async () => { const calls = []; const logs = []; + const writes = []; let sourceMtimeMs = 3000; const exitCode = await runWorkspaceKtx(['status', '--json', '--no-input'], { rootDir: '/workspace/ktx', access: async () => undefined, - stat: async (path) => ({ - mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : sourceMtimeMs, - isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), - }), + stat: async (path) => { + if (path.endsWith('/.ktx-build-stamp')) { + return { mtimeMs: 2000, isDirectory: () => false }; + } + if (path.endsWith('/packages/cli/dist/bin.js')) { + return { mtimeMs: 2000, isDirectory: () => false }; + } + return { + mtimeMs: sourceMtimeMs, + isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), + }; + }, readdir: async (path) => { if (path.endsWith('/packages')) { return [{ name: 'context', isDirectory: () => true }]; @@ -176,6 +200,9 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t } return { stdout: '{"status":"ready"}\n', stderr: '' }; }, + writeFile: async (path, contents) => { + writes.push({ path, contents }); + }, stdout: { write: (chunk) => logs.push(['stdout', chunk]) }, stderr: { write: (chunk) => logs.push(['stderr', chunk]) }, }); @@ -193,4 +220,116 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t ['stdout', 'build ok\n'], ['stdout', '{"status":"ready"}\n'], ]); + assert.deepEqual(writes, [ + { path: '/workspace/ktx/packages/cli/dist/.ktx-build-stamp', contents: '' }, + ]); +}); + +test('runWorkspaceKtx skips rebuild when only bin.js is older than sources but stamp is fresh', async () => { + const calls = []; + const logs = []; + const writes = []; + + const exitCode = await runWorkspaceKtx(['status'], { + rootDir: '/workspace/ktx', + access: async () => undefined, + stat: async (path) => { + if (path.endsWith('/.ktx-build-stamp')) { + return { mtimeMs: 5000, isDirectory: () => false }; + } + if (path.endsWith('/packages/cli/dist/bin.js')) { + return { mtimeMs: 1000, isDirectory: () => false }; + } + return { + mtimeMs: 3000, + isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), + }; + }, + readdir: async (path) => { + if (path.endsWith('/packages')) { + return [{ name: 'cli', isDirectory: () => true }]; + } + if (path.endsWith('/src')) { + return [{ name: 'setup.ts', isDirectory: () => false }]; + } + return []; + }, + execFile: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + return { stdout: 'KTX status\n', stderr: '' }; + }, + writeFile: async (path, contents) => { + writes.push({ path, contents }); + }, + stdout: { write: (chunk) => logs.push(['stdout', chunk]) }, + stderr: { write: (chunk) => logs.push(['stderr', chunk]) }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual( + calls.map((call) => [call.command, call.args]), + [[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'status']]], + ); + assert.deepEqual(writes, []); + assert.deepEqual(logs, [['stdout', 'KTX status\n']]); +}); + +test('runWorkspaceKtx rebuilds when stamp is missing even if bin.js exists', async () => { + const calls = []; + const logs = []; + const writes = []; + + const exitCode = await runWorkspaceKtx(['status'], { + rootDir: '/workspace/ktx', + access: async () => undefined, + stat: async (path) => { + if (path.endsWith('/.ktx-build-stamp')) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + } + if (path.endsWith('/packages/cli/dist/bin.js')) { + return { mtimeMs: 2000, isDirectory: () => false }; + } + return { + mtimeMs: 1000, + isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), + }; + }, + readdir: async (path) => { + if (path.endsWith('/packages')) { + return [{ name: 'cli', isDirectory: () => true }]; + } + if (path.endsWith('/src')) { + return [{ name: 'bin.ts', isDirectory: () => false }]; + } + return []; + }, + execFile: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (command === 'pnpm') { + return { stdout: 'build ok\n', stderr: '' }; + } + return { stdout: 'KTX status\n', stderr: '' }; + }, + writeFile: async (path, contents) => { + writes.push({ path, contents }); + }, + stdout: { write: (chunk) => logs.push(['stdout', chunk]) }, + stderr: { write: (chunk) => logs.push(['stderr', chunk]) }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual( + calls.map((call) => [call.command, call.args]), + [ + ['pnpm', ['run', 'build']], + [process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'status']], + ], + ); + assert.deepEqual(logs[0], [ + 'stderr', + 'KTX CLI build output is stale. Rebuilding it now with `pnpm run build`...\n', + ]); + assert.deepEqual(writes, [ + { path: '/workspace/ktx/packages/cli/dist/.ktx-build-stamp', contents: '' }, + ]); });