From 44d888d8e14b206e3fea341e044fffb384403fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roch=C3=A9=20Compaan?= Date: Sat, 29 Aug 2026 13:09:07 +0200 Subject: [PATCH] fix(deps): fail fast on stale runtime dependencies --- bin/patchmill.test.ts | 72 +++++++++++++++++++++++- bin/patchmill.ts | 17 +++++- package.json | 2 + src/pi/pi-subagents-package.ts | 4 +- src/runtime-dependency-preflight.test.ts | 41 ++++++++++++++ src/runtime-dependency-preflight.ts | 12 ++++ 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 src/runtime-dependency-preflight.test.ts create mode 100644 src/runtime-dependency-preflight.ts diff --git a/bin/patchmill.test.ts b/bin/patchmill.test.ts index c96f6af5..b9d588a2 100644 --- a/bin/patchmill.test.ts +++ b/bin/patchmill.test.ts @@ -1,12 +1,56 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { HELP_TEXT } from "../src/cli/main.ts"; +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function createStaleDependencyFixture(repoRoot: string, fixtureDir: string) { + const fixturePaths = [ + "bin/patchmill.ts", + "src/package-root.ts", + "src/runtime-dependency-preflight.ts", + "src/pi/pi-subagents-package.ts", + ]; + for (const relativePath of fixturePaths) { + const destination = join(fixtureDir, relativePath); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(repoRoot, relativePath), destination); + } + + writeJson(join(fixtureDir, "package.json"), { + dependencies: { "pi-subagents": "2.0.0" }, + }); + const dependencyDir = join(fixtureDir, "node_modules", "pi-subagents"); + mkdirSync(dependencyDir, { recursive: true }); + writeJson(join(dependencyDir, "package.json"), { + name: "pi-subagents", + version: "1.0.0", + main: "index.js", + }); + writeFileSync(join(dependencyDir, "index.js"), "export {};\n"); + + const sentinelPath = join(fixtureDir, "src", "cli", "main.ts"); + mkdirSync(dirname(sentinelPath), { recursive: true }); + writeFileSync( + sentinelPath, + 'process.stdout.write("CLI_DISPATCHED");\nexport async function main() { return 23; }\n', + ); +} + test("patchmill sanitizes inherited Pi state before loading the actual executable CLI", () => { const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const registerPath = join( @@ -64,3 +108,29 @@ test("patchmill executes when invoked through a symlink", () => { rmSync(fixtureDir, { recursive: true, force: true }); } }); + +test("patchmill rejects stale runtime dependencies before CLI dispatch", () => { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + const fixtureDir = mkdtempSync(join(tmpdir(), "patchmill-stale-deps-")); + + try { + createStaleDependencyFixture(repoRoot, fixtureDir); + const result = spawnSync( + process.execPath, + [join(fixtureDir, "bin", "patchmill.ts"), "version"], + { cwd: fixtureDir, encoding: "utf8" }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.doesNotMatch(result.stdout, /CLI_DISPATCHED/u); + assert.match( + result.stderr, + /pi-subagents resolved 1\.0\.0 but package\.json pins 2\.0\.0/u, + ); + assert.match(result.stderr, /Run `npm install`/u); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } +}); diff --git a/bin/patchmill.ts b/bin/patchmill.ts index 50216b78..18cc14ee 100755 --- a/bin/patchmill.ts +++ b/bin/patchmill.ts @@ -14,6 +14,19 @@ function isMainModule(metaUrl: string, argv1 = process.argv[1]): boolean { if (isMainModule(import.meta.url)) { delete process.env.PI_PACKAGE_DIR; - const { main } = await import("../src/cli/main.ts"); - process.exitCode = await main(); + let dependenciesReady = false; + try { + const { assertPatchmillRuntimeDependencyPins } = + await import("../src/runtime-dependency-preflight.ts"); + assertPatchmillRuntimeDependencyPins(); + dependenciesReady = true; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } + + if (dependenciesReady) { + const { main } = await import("../src/cli/main.ts"); + process.exitCode = await main(); + } } diff --git a/package.json b/package.json index 6aafa5b5..1da7a0d7 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "patchmill": "node bin/patchmill.ts", "triage": "node bin/patchmill.ts triage", "run-once": "node bin/patchmill.ts run-once", + "check:dependencies": "npm ls --depth=0", "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepack": "npm run build", "lint": "npm run format:check && npm run lint:ts && npm run lint:md", @@ -52,6 +53,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky", + "pretest": "npm run check:dependencies", "test": "node --test \"bin/*.test.ts\" \"src/**/*.test.ts\" \"test-support/*.test.ts\" \"scripts/*.test.mjs\"", "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='bin/**/*.ts' --test-coverage-include='src/**/*.ts' --test-coverage-include='test-support/**/*.ts' --test-coverage-exclude='**/*.test.ts' \"bin/*.test.ts\" \"src/**/*.test.ts\" \"test-support/*.test.ts\"", "check:architecture": "depcruise --config dependency-cruiser.config.mjs bin src extensions", diff --git a/src/pi/pi-subagents-package.ts b/src/pi/pi-subagents-package.ts index 4f797cb7..8fb0f5eb 100644 --- a/src/pi/pi-subagents-package.ts +++ b/src/pi/pi-subagents-package.ts @@ -99,7 +99,9 @@ export function assertInstalledPiSubagentsMatchesRootPin( const manifest = readInstalledPiSubagentsManifest(); if (manifest.version !== expected) { throw new Error( - `${PI_SUBAGENTS_PACKAGE_NAME} resolved ${manifest.version} but package.json pins ${expected}`, + `${PI_SUBAGENTS_PACKAGE_NAME} resolved ${manifest.version} but package.json pins ${expected}. ` + + `Run \`npm install\` in ${dirname(rootPackageJsonPath)} to synchronize node_modules, ` + + "or reinstall Patchmill if this is a packaged installation.", ); } } diff --git a/src/runtime-dependency-preflight.test.ts b/src/runtime-dependency-preflight.test.ts new file mode 100644 index 00000000..8600656a --- /dev/null +++ b/src/runtime-dependency-preflight.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { test } from "node:test"; +import { assertPatchmillRuntimeDependencyPins } from "./runtime-dependency-preflight.ts"; + +test("runtime dependency preflight accepts the installed exact pin", () => { + assert.doesNotThrow(() => assertPatchmillRuntimeDependencyPins()); +}); + +test("runtime dependency preflight rejects a stale installed dependency", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "patchmill-runtime-pins-")); + const sourceDir = join(packageRoot, "src"); + mkdirSync(sourceDir); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ dependencies: { "pi-subagents": "999.0.0" } }), + ); + + try { + assert.throws( + () => + assertPatchmillRuntimeDependencyPins( + pathToFileURL(join(sourceDir, "sentinel.ts")).href, + ), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match( + error.message, + /pi-subagents resolved \S+ but package\.json pins 999\.0\.0/u, + ); + assert.match(error.message, /Run `npm install`/u); + return true; + }, + ); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } +}); diff --git a/src/runtime-dependency-preflight.ts b/src/runtime-dependency-preflight.ts new file mode 100644 index 00000000..ee9a51a2 --- /dev/null +++ b/src/runtime-dependency-preflight.ts @@ -0,0 +1,12 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { findPackageRoot } from "./package-root.ts"; +import { assertInstalledPiSubagentsMatchesRootPin } from "./pi/pi-subagents-package.ts"; + +/** Fails before CLI dispatch when source dependencies do not match Patchmill's pins. */ +export function assertPatchmillRuntimeDependencyPins( + moduleUrl = import.meta.url, +): void { + const packageRoot = findPackageRoot(dirname(fileURLToPath(moduleUrl))); + assertInstalledPiSubagentsMatchesRootPin(join(packageRoot, "package.json")); +}