diff --git a/docs/plans/2026-07-30-pi-extension-package-root-resolution.md b/docs/plans/2026-07-30-pi-extension-package-root-resolution.md new file mode 100644 index 00000000..a230e36e --- /dev/null +++ b/docs/plans/2026-07-30-pi-extension-package-root-resolution.md @@ -0,0 +1,1025 @@ +# Pi Extension Package-Root Resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve and validate Patchmill-owned resources from one +nearest-ancestor package root in source, compiled/npm-packed, and Nix-installed +layouts. + +**Architecture:** Add a synchronous `findPackageRoot()` utility under `src/`, +migrate the three explicitly approved consumers to it, and keep dependency-owned +`pi-subagents` resolution separate. Probe package boundaries with fail-fast +filesystem semantics, validate the todos extension during profile +initialization, and commit a compiled-layout regression test before adding npm +and Nix installation checks. + +**Tech Stack:** TypeScript ESM, Node.js 22.19+/24, `node:test`, npm package +tarballs, Nix `buildNpmPackage` install checks. + +## Global Constraints + +- The human reviewer explicitly approved migrating resource profiles, + setup-test-repo fixtures, and version lookup during #121 design review. +- The resolver returns the nearest ancestor containing a regular `package.json` + file. +- The resolver suppresses only `ENOENT` and `ENOTDIR`; `EACCES`, `ELOOP`, and + all other filesystem failures propagate unchanged. +- The resolver normalizes the start directory but does not parse `package.json`, + validate its package name, call `realpath`, or cache results. +- `readPackageVersion()` preserves its established + `Could not locate Patchmill package.json` not-found message by translating + only the resolver's typed not-found error and retaining it as `cause`. +- Resource-profile initialization verifies that `extensions/todos.ts` is a + regular file and fails immediately when it is absent, inaccessible, cyclic, or + not a file. +- Preserve run-once extension order: `pi-subagents`, then `extensions/todos.ts`. +- Preserve triage's empty extension list, successful CLI output, fixture + behavior, and public function signatures. +- Do not change dependencies, package metadata, lock files, `pi-subagents`, + lifecycle observers, or progress-reporting behavior. +- Verify source, compiled, npm-packed, and Nix-installed layouts. +- Apply Patchmill's Testing Value Gate: behavior tests cover the resolver and + consumers; Nix expression text is verified by the Nix build rather than a + static-content test. + +--- + +### Task 1: Add fail-fast package-root discovery and migrate fixture/version lookup + +**Files:** + +- Create: `src/package-root.ts` +- Create: `src/package-root.test.ts` +- Modify: `src/cli/commands/setup-test-repo/fixtures.ts:1-33` +- Modify: `src/cli/commands/setup-test-repo/fixtures.test.ts:1-29` +- Modify: `src/cli/commands/version/main.ts:1-62` +- Create: `src/cli/commands/version/main.test.ts` + +**Interfaces:** + +- Produces: `PackageRootNotFoundError` and + `findPackageRoot(startDir: string): string` from `src/package-root.ts`. +- Consumes: Node filesystem/path APIs only; no package-specific metadata. +- Preserves: `resolveFixtureDirectory(startDir?: string): Promise` and + `readPackageVersion(moduleUrl?: string): string`. +- Provides to Task 2: a synchronous resolver safe to call during + resource-profile module initialization. + +- [ ] **Step 1: Write the failing resolver tests** + +Create `src/package-root.test.ts`: + +```ts +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { test } from "node:test"; +import { findPackageRoot, PackageRootNotFoundError } from "./package-root.ts"; + +async function temporaryDirectory(): Promise { + return mkdtemp(join(tmpdir(), "patchmill-package-root-")); +} + +async function writePackageJson(directory: string): Promise { + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "package.json"), "{}\n", "utf8"); +} + +function systemError(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +test("findPackageRoot walks up from source and arbitrarily nested dist layouts", async () => { + const packageRoot = await temporaryDirectory(); + const sourceDirectory = join(packageRoot, "src", "pi"); + const distDirectory = join(packageRoot, "dist", "src", "pi", "nested"); + + try { + await writePackageJson(packageRoot); + await mkdir(sourceDirectory, { recursive: true }); + await mkdir(distDirectory, { recursive: true }); + + assert.equal(findPackageRoot(sourceDirectory), packageRoot); + assert.equal(findPackageRoot(distDirectory), packageRoot); + assert.equal( + findPackageRoot(relative(process.cwd(), distDirectory)), + packageRoot, + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +}); + +test("findPackageRoot returns the nearest package boundary", async () => { + const outerRoot = await temporaryDirectory(); + const innerRoot = join(outerRoot, "packages", "inner"); + const startDirectory = join(innerRoot, "dist", "src", "feature"); + + try { + await writePackageJson(outerRoot); + await writePackageJson(innerRoot); + await mkdir(startDirectory, { recursive: true }); + + assert.equal(findPackageRoot(startDirectory), innerRoot); + } finally { + await rm(outerRoot, { recursive: true, force: true }); + } +}); + +test("findPackageRoot rejects a non-file package boundary", async () => { + const packageRoot = await temporaryDirectory(); + const startDirectory = join(packageRoot, "src", "feature"); + + try { + await mkdir(join(packageRoot, "package.json"), { recursive: true }); + await mkdir(startDirectory, { recursive: true }); + + assert.throws( + () => findPackageRoot(startDirectory), + /package\.json is not a regular file/u, + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +}); + +for (const code of ["ENOENT", "ENOTDIR"] as const) { + test(`findPackageRoot continues after ${code}`, (context) => { + const startDirectory = resolve("virtual", "package", "nested"); + const expectedRoot = dirname(startDirectory); + let calls = 0; + + context.mock.method(fs, "statSync", () => { + calls += 1; + if (calls === 1) throw systemError(code); + return { + isFile: () => true, + } as ReturnType; + }); + + assert.equal(findPackageRoot(startDirectory), expectedRoot); + assert.equal(calls, 2); + }); +} + +for (const code of ["EACCES", "ELOOP"] as const) { + test(`findPackageRoot propagates ${code}`, (context) => { + const expected = systemError(code); + context.mock.method(fs, "statSync", () => { + throw expected; + }); + + assert.throws( + () => findPackageRoot(resolve("virtual", "nested")), + (error: unknown) => error === expected, + ); + }); +} + +test("findPackageRoot throws a typed error with the normalized start", (context) => { + const startDirectory = resolve("virtual", "without-package", "nested"); + context.mock.method(fs, "statSync", () => { + throw systemError("ENOENT"); + }); + + assert.throws( + () => findPackageRoot(startDirectory), + (error: unknown) => { + assert.ok(error instanceof PackageRootNotFoundError); + assert.equal(error.startDir, startDirectory); + assert.equal( + error.message, + `Could not find package root walking up from ${startDirectory}`, + ); + return true; + }, + ); +}); +``` + +The tests use `node:test`'s scoped mock on the mutable `node:fs` default export. +This deterministically proves `EACCES`/`ELOOP` propagation without adding +dependency injection to the production API. + +- [ ] **Step 2: Run the resolver test to verify it fails** + +Run: + +```sh +node --test src/package-root.test.ts +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/package-root.ts`. + +- [ ] **Step 3: Implement the minimal fail-fast resolver** + +Create `src/package-root.ts`: + +```ts +import fs from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function isAbsentPathError(error: unknown): boolean { + return hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR"); +} + +export class PackageRootNotFoundError extends Error { + readonly startDir: string; + + constructor(startDir: string) { + super(`Could not find package root walking up from ${startDir}`); + this.name = "PackageRootNotFoundError"; + this.startDir = startDir; + } +} + +export function findPackageRoot(startDir: string): string { + const normalizedStart = resolve(startDir); + let current = normalizedStart; + + for (;;) { + const packageJsonPath = join(current, "package.json"); + try { + const stats = fs.statSync(packageJsonPath); + if (!stats.isFile()) { + throw new Error(`${packageJsonPath} is not a regular file`); + } + return current; + } catch (error) { + if (!isAbsentPathError(error)) throw error; + } + + const parent = dirname(current); + if (parent === current) { + throw new PackageRootNotFoundError(normalizedStart); + } + current = parent; + } +} +``` + +The catch has one explicit responsibility: translate only stable absence codes +into upward traversal. Access, symlink, validation, and unknown failures are +rethrown unchanged. + +- [ ] **Step 4: Run the resolver test to verify it passes** + +Run: + +```sh +node --test src/package-root.test.ts +``` + +Expected: PASS with 8 tests and 0 failures. + +- [ ] **Step 5: Add consumer tests before migrating the consumers** + +In `src/cli/commands/setup-test-repo/fixtures.test.ts`, replace the existing +`"resolveFixtureDirectory finds fixtures from the package root"` test with: + +```ts +test("resolveFixtureDirectory finds fixtures from a nested package layout", async () => { + const packageRoot = await tempDir(); + const nestedModuleDirectory = join( + packageRoot, + "dist", + "src", + "cli", + "commands", + "setup-test-repo", + ); + + try { + await mkdir(nestedModuleDirectory, { recursive: true }); + await writeFile(join(packageRoot, "package.json"), "{}\n", "utf8"); + + assert.equal( + await resolveFixtureDirectory(nestedModuleDirectory), + join(packageRoot, "fixtures", "patchmill-test-repo"), + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +}); +``` + +Create `src/cli/commands/version/main.test.ts`: + +```ts +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { PackageRootNotFoundError } from "../../../package-root.ts"; +import { readPackageVersion } from "./main.ts"; + +type PackageTree = { + moduleUrl: string; + packageRoot: string; +}; + +async function withPackageJson( + contents: string, + run: (tree: PackageTree) => void, +): Promise { + const packageRoot = await mkdtemp(join(tmpdir(), "patchmill-version-")); + const moduleDirectory = join( + packageRoot, + "dist", + "deep", + "src", + "cli", + "commands", + "version", + ); + + try { + await mkdir(moduleDirectory, { recursive: true }); + await writeFile(join(packageRoot, "package.json"), contents, "utf8"); + run({ + moduleUrl: pathToFileURL(join(moduleDirectory, "main.js")).href, + packageRoot, + }); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +} + +function systemError(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +test("readPackageVersion finds the nearest package from arbitrary nesting", async () => { + await withPackageJson('{"version":"9.8.7"}\n', ({ moduleUrl }) => { + assert.equal(readPackageVersion(moduleUrl), "9.8.7"); + }); +}); + +test("readPackageVersion preserves malformed JSON failures", async () => { + await withPackageJson("{", ({ moduleUrl }) => { + assert.throws(() => readPackageVersion(moduleUrl), SyntaxError); + }); +}); + +test("readPackageVersion rejects a non-string version", async () => { + await withPackageJson('{"version":123}\n', ({ moduleUrl, packageRoot }) => { + assert.throws( + () => readPackageVersion(moduleUrl), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + `package.json at ${join(packageRoot, "package.json")} does not contain a string version`, + ); + return true; + }, + ); + }); +}); + +test("readPackageVersion preserves its public missing-root error", (context) => { + context.mock.method(fs, "statSync", () => { + throw systemError("ENOENT"); + }); + const moduleUrl = pathToFileURL( + resolve("virtual", "without-package", "main.js"), + ).href; + + assert.throws( + () => readPackageVersion(moduleUrl), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Could not locate Patchmill package.json"); + assert.ok(error.cause instanceof PackageRootNotFoundError); + return true; + }, + ); +}); + +test("readPackageVersion propagates package-root access failures", (context) => { + const expected = systemError("EACCES"); + context.mock.method(fs, "statSync", () => { + throw expected; + }); + const moduleUrl = pathToFileURL(resolve("virtual", "main.js")).href; + + assert.throws( + () => readPackageVersion(moduleUrl), + (error: unknown) => error === expected, + ); +}); +``` + +- [ ] **Step 6: Run the consumer tests to expose fixed-depth version lookup** + +Run: + +```sh +node --test \ + src/cli/commands/setup-test-repo/fixtures.test.ts \ + src/cli/commands/version/main.test.ts +``` + +Expected: the fixture tests pass; the arbitrarily nested version cases fail +before reading their temporary `package.json`, and the not-found test fails +because the current error has no typed resolver cause. + +- [ ] **Step 7: Migrate fixture and version lookup to the shared resolver** + +In `src/cli/commands/setup-test-repo/fixtures.ts`: + +1. Replace the path import with: + +```ts +import { dirname, join } from "node:path"; +``` + +1. Add the shared import: + +```ts +import { findPackageRoot } from "../../../package-root.ts"; +``` + +1. Delete the private `findPackageRoot()` function at current lines 18-26. Keep + the asynchronous `exists()` helper because fixture validation still uses it. + +1. Replace `resolveFixtureDirectory()` with: + +```ts +export async function resolveFixtureDirectory( + startDir = dirname(fileURLToPath(import.meta.url)), +): Promise { + const packageRoot = findPackageRoot(startDir); + return join(packageRoot, FIXTURE_RELATIVE_PATH); +} +``` + +In `src/cli/commands/version/main.ts`: + +1. Add: + +```ts +import { + findPackageRoot, + PackageRootNotFoundError, +} from "../../../package-root.ts"; +``` + +1. Delete `hasErrorCode()` and `packageJsonCandidates()`. + +1. Replace `readPackageVersion()` with: + +```ts +export function readPackageVersion(moduleUrl = import.meta.url): string { + let packageRoot: string; + try { + packageRoot = findPackageRoot(dirname(fileURLToPath(moduleUrl))); + } catch (error) { + if (error instanceof PackageRootNotFoundError) { + throw new Error("Could not locate Patchmill package.json", { + cause: error, + }); + } + throw error; + } + + const packageJsonPath = join(packageRoot, "package.json"); + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf8"), + ) as PackageJson; + + if (typeof packageJson.version !== "string") { + throw new Error( + `package.json at ${packageJsonPath} does not contain a string version`, + ); + } + return packageJson.version; +} +``` + +This catch is the explicit compatibility boundary for one expected failure mode: +exhausted root traversal. It rethrows the established public error with the +typed resolver error as `cause`; every other failure propagates unchanged. + +- [ ] **Step 8: Format and run all Task 1 tests** + +Run: + +```sh +npx prettier --write \ + src/package-root.ts \ + src/package-root.test.ts \ + src/cli/commands/setup-test-repo/fixtures.ts \ + src/cli/commands/setup-test-repo/fixtures.test.ts \ + src/cli/commands/version/main.ts \ + src/cli/commands/version/main.test.ts +node --test \ + src/package-root.test.ts \ + src/cli/commands/setup-test-repo/fixtures.test.ts \ + src/cli/commands/version/main.test.ts +``` + +Expected: Prettier exits 0; all focused tests pass with 0 failures. + +- [ ] **Step 9: Commit the shared resolver migration** + +Run: + +```sh +git add \ + src/package-root.ts \ + src/package-root.test.ts \ + src/cli/commands/setup-test-repo/fixtures.ts \ + src/cli/commands/setup-test-repo/fixtures.test.ts \ + src/cli/commands/version/main.ts \ + src/cli/commands/version/main.test.ts +git commit -m "fix(paths): centralize package-root discovery" +``` + +Expected: one commit containing only the shared resolver, its fail-fast tests, +and the fixture/version migrations. + +--- + +### Task 2: Validate Pi extensions in source and compiled layouts + +**Files:** + +- Modify: `src/pi/resource-profiles.ts:1-21` +- Modify: `src/pi/resource-profiles.test.ts:1-56` +- Create: `src/pi/resource-profiles.compiled.test.ts` + +**Interfaces:** + +- Consumes: Task 1's `findPackageRoot(startDir: string): string`. +- Preserves: all exported profile types/functions and the two-item extension + order. +- Produces: a todos-extension path rooted at the nearest package boundary and + validated as a regular file during module initialization. +- Provides to Task 3: committed compiled-layout coverage plus resource-profile + behavior that can be checked in npm-packed and Nix-installed layouts. + +- [ ] **Step 1: Add source-layout extension-existence coverage** + +In `src/pi/resource-profiles.test.ts`, add this import after the assert import: + +```ts +import { existsSync } from "node:fs"; +``` + +In the existing +`"run-once planning profile includes context and Patchmill run-once extensions"` +test, add this block after the todos suffix assertion: + +```ts +for (const extensionPath of profile.additionalExtensionPaths) { + assert.equal( + existsSync(extensionPath), + true, + `missing extension: ${extensionPath}`, + ); +} +``` + +- [ ] **Step 2: Run the source test as a characterization check** + +Run: + +```sh +node --test src/pi/resource-profiles.test.ts +``` + +Expected: PASS. The current fixed-depth code works in the source layout, which +is why a compiled-layout test is required. + +- [ ] **Step 3: Write the failing committed compiled-layout test** + +Create `src/pi/resource-profiles.compiled.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { copyFile, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { findPackageRoot } from "../package-root.ts"; +import type { PatchmillSkillsConfig } from "../workflow/skills.ts"; + +const require = createRequire(import.meta.url); +const sourceRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url))); +const tscPath = require.resolve("typescript/bin/tsc"); + +const skills: PatchmillSkillsConfig = { + triage: "triage", + planning: "planning", + implementation: "implementation", + developmentEnvironment: "development-environment", + toolchain: "toolchain", + review: "review", + visualEvidence: "visual-evidence", + landing: "landing", +}; + +test( + "compiled resource profiles resolve and require package-owned extensions", + { timeout: 60_000 }, + async () => { + const packageRoot = await mkdtemp( + join(tmpdir(), "patchmill-compiled-profile-"), + ); + const compiledProfile = join( + packageRoot, + "dist", + "src", + "pi", + "resource-profiles.js", + ); + const todosExtension = join(packageRoot, "extensions", "todos.ts"); + + try { + await mkdir(dirname(todosExtension), { recursive: true }); + await copyFile( + join(sourceRoot, "package.json"), + join(packageRoot, "package.json"), + ); + await copyFile( + join(sourceRoot, "extensions", "todos.ts"), + todosExtension, + ); + await symlink( + join(sourceRoot, "node_modules"), + join(packageRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + + const build = spawnSync( + process.execPath, + [ + tscPath, + "-p", + join(sourceRoot, "tsconfig.build.json"), + "--rootDir", + sourceRoot, + "--outDir", + join(packageRoot, "dist"), + ], + { + cwd: sourceRoot, + encoding: "utf8", + timeout: 45_000, + }, + ); + assert.equal(build.error, undefined); + assert.equal(build.status, 0, build.stderr || build.stdout); + + const compiled = await import(pathToFileURL(compiledProfile).href); + const profile = compiled.runOncePlanningPiProfile(skills, packageRoot); + assert.deepEqual( + profile.additionalExtensionPaths.map((path: string) => + existsSync(path), + ), + [true, true], + ); + assert.equal( + profile.additionalExtensionPaths[1] + ?.replaceAll("\\", "/") + .endsWith("/extensions/todos.ts"), + true, + ); + + const missingProfile = join( + dirname(compiledProfile), + "resource-profiles-missing.js", + ); + await copyFile(compiledProfile, missingProfile); + await rm(todosExtension); + + await assert.rejects( + import(pathToFileURL(missingProfile).href), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as NodeJS.ErrnoException).code, "ENOENT"); + assert.match(error.message, /extensions[\\/]todos\.ts/u); + return true; + }, + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } + }, +); +``` + +This test compiles the actual production module into an isolated package root. +It does not inspect source text, and it fails if the module derives +`/dist` as its root or if initialization tolerates a missing todos +extension. + +- [ ] **Step 4: Run the compiled test to verify it fails** + +Run: + +```sh +node --test src/pi/resource-profiles.compiled.test.ts +``` + +Expected: FAIL because the first import resolves a missing path ending in +`dist/extensions/todos.ts`. + +- [ ] **Step 5: Replace fixed-depth resolution and validate the extension** + +In `src/pi/resource-profiles.ts`: + +1. Add the filesystem import: + +```ts +import fs from "node:fs"; +``` + +1. Replace the path import with: + +```ts +import { dirname, join } from "node:path"; +``` + +1. Add: + +```ts +import { findPackageRoot } from "../package-root.ts"; +``` + +1. Add this direct validation helper next to the package-root constants: + +```ts +function requireRegularFile(path: string): string { + const stats = fs.statSync(path); + if (!stats.isFile()) { + throw new Error(`Patchmill extension is not a regular file: ${path}`); + } + return path; +} +``` + +1. Replace the current Patchmill root/todos constants with: + +```ts +const PATCHMILL_PACKAGE_ROOT = findPackageRoot( + dirname(fileURLToPath(import.meta.url)), +); +const PATCHMILL_TODOS_EXTENSION = requireRegularFile( + join(PATCHMILL_PACKAGE_ROOT, "extensions", "todos.ts"), +); +``` + +`requireRegularFile()` intentionally has no catch. Missing files, access +failures, and symlink loops retain their stable filesystem errors; a successful +stat of a non-file becomes an explicit validation error. + +Do not change `PI_SUBAGENTS_PACKAGE_ROOT`, `runOnceExtensionPaths()`, or profile +composition. + +- [ ] **Step 6: Format and run Task 2 tests** + +Run: + +```sh +npx prettier --write \ + src/pi/resource-profiles.ts \ + src/pi/resource-profiles.test.ts \ + src/pi/resource-profiles.compiled.test.ts +node --test \ + src/package-root.test.ts \ + src/pi/resource-profiles.test.ts \ + src/pi/resource-profiles.compiled.test.ts +``` + +Expected: Prettier exits 0; source, resolver, compiled-layout, and +missing-extension assertions all pass with 0 failures. + +- [ ] **Step 7: Commit the Pi extension fix** + +Run: + +```sh +git add \ + src/pi/resource-profiles.ts \ + src/pi/resource-profiles.test.ts \ + src/pi/resource-profiles.compiled.test.ts +git commit -m "fix(pi): resolve and validate packaged extensions" +``` + +Expected: one commit containing only the resource-profile fix and +source/compiled behavior tests. + +--- + +### Task 3: Verify npm-packed and Nix-installed layouts + +**Files:** + +- Modify: `nix/package.nix:64-78` +- Verify: all Task 1 and Task 2 files + +**Interfaces:** + +- Consumes: Task 2's compiled/source resource profile and two ordered extension + paths. +- Produces: a Nix install check that imports the installed profile and fails + when any configured extension path is missing. +- Verifies directly: an npm tarball installed into a temporary project resolves + the same existing extension files. + +- [ ] **Step 1: Add the Nix installed-layout assertion** + +In `nix/package.nix`, add the following immediately after the existing fixture +assertion in `installCheckPhase`: + +```nix + test -f "$out/share/${pname}/extensions/todos.ts" + ( + cd "$out/share/${pname}" + ${nodejs_24}/bin/node --input-type=module -e " + import { existsSync } from 'node:fs'; + import { runOncePlanningPiProfile } from './src/pi/resource-profiles.ts'; + const skills = { + triage: 'triage', planning: 'planning', implementation: 'implementation', + developmentEnvironment: 'development-environment', toolchain: 'toolchain', + review: 'review', visualEvidence: 'visual-evidence', landing: 'landing', + }; + const profile = runOncePlanningPiProfile(skills, process.cwd()); + const missing = profile.additionalExtensionPaths.filter( + (extensionPath) => !existsSync(extensionPath), + ); + if (missing.length > 0) { + console.error('missing installed extension paths:', missing.join(', ')); + process.exit(1); + } + console.log('all installed extension paths exist'); + " + ) +``` + +This is direct Nix runtime verification, not a static test of expression text. + +- [ ] **Step 2: Build the Nix package and run its install check** + +Run: + +```sh +nix build .#patchmill --no-link --print-build-logs +``` + +Expected: PASS. The install check prints `all installed extension paths exist`; +any missing shared resolver, dependency extension, or todos extension fails the +build. + +- [ ] **Step 3: Pack, install, and verify the compiled npm artifact** + +Run: + +```sh +set -eu +ROOT="$PWD" +WORK="$(mktemp -d)" +cleanup() { + cd "$ROOT" + rm -rf "$WORK" +} +trap cleanup EXIT + +npm run build +PACK_JSON="$WORK/npm-pack.json" +npm pack --ignore-scripts --json --pack-destination "$WORK" >"$PACK_JSON" +TARBALL="$WORK/$(node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8'))[0].filename" <"$PACK_JSON")" +test -f "$TARBALL" + +INSTALL_DIR="$WORK/install" +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" +npm init -y >/dev/null +npm install --ignore-scripts "$TARBALL" >/dev/null +node --input-type=module <<'NODE' +import { existsSync } from "node:fs"; +import { runOncePlanningPiProfile } from "./node_modules/patchmill/dist/src/pi/resource-profiles.js"; + +const skills = { + triage: "triage", + planning: "planning", + implementation: "implementation", + developmentEnvironment: "development-environment", + toolchain: "toolchain", + review: "review", + visualEvidence: "visual-evidence", + landing: "landing", +}; +const profile = runOncePlanningPiProfile(skills, process.cwd()); +const missing = profile.additionalExtensionPaths.filter( + (extensionPath) => !existsSync(extensionPath), +); +if (missing.length > 0) { + console.error("missing packed extension paths:", missing.join(", ")); + process.exit(1); +} +if ( + !profile.additionalExtensionPaths[1] + ?.replaceAll("\\", "/") + .endsWith("/extensions/todos.ts") +) { + console.error("todos extension order changed"); + process.exit(1); +} +console.log("all extension paths exist in the packed install"); +NODE +``` + +Expected: prints `all extension paths exist in the packed install` and +returns 0. Building before packing supplies `dist/`, while `--ignore-scripts` +keeps `npm pack --json` output parseable by preventing lifecycle scripts from +writing to stdout. `WORK` exists before packing, so the trap removes the tarball +and any partial/corrupt JSON output even when `npm pack` or JSON decoding fails; +decoding remains fail-loud. + +- [ ] **Step 4: Run focused and full project verification** + +Run: + +```sh +node --test \ + src/package-root.test.ts \ + src/pi/resource-profiles.test.ts \ + src/pi/resource-profiles.compiled.test.ts \ + src/cli/commands/setup-test-repo/fixtures.test.ts \ + src/cli/commands/version/main.test.ts +npm test +npm run lint +npm run build +nix build .#patchmill --no-link --print-build-logs +``` + +Expected: all focused tests pass; the full suite has 0 failures; lint has 0 +errors or warnings; TypeScript compilation succeeds; and the Nix package/install +check succeeds. + +- [ ] **Step 5: Check the complete implementation diff** + +Run: + +```sh +BASE_SHA="$(git merge-base main HEAD)" +git diff --check "$BASE_SHA"...HEAD +git diff --check +git diff --stat "$BASE_SHA" +git status --short +``` + +Expected: both diff checks print nothing; the stat contains only the approved +spec/plan plus Task 1-3 files; status shows only `nix/package.nix` modified. + +- [ ] **Step 6: Commit the Nix installed-layout verification** + +Run: + +```sh +git add nix/package.nix +git commit -m "test(nix): verify installed extension paths" +``` + +Expected: one commit containing only the Nix install-check change. + +- [ ] **Step 7: Confirm the final branch is clean** + +Run: + +```sh +git status --short +git log --oneline main..HEAD +``` + +Expected: status prints nothing. The log contains the committed spec and plan +followed by the three implementation commits; no observer, progress-reporting, +dependency, package metadata, or lock-file changes are present. diff --git a/docs/specs/2026-07-30-pi-extension-package-root-resolution-design.md b/docs/specs/2026-07-30-pi-extension-package-root-resolution-design.md new file mode 100644 index 00000000..a572aacf --- /dev/null +++ b/docs/specs/2026-07-30-pi-extension-package-root-resolution-design.md @@ -0,0 +1,298 @@ +# Pi Extension Package-Root Resolution Design + +**Issue:** [#121](https://github.com/rochecompaan/patchmill/issues/121) + +**Parent:** [#116](https://github.com/rochecompaan/patchmill/issues/116) + +## Context + +Patchmill owns Pi extension resources that must load from the source tree, +compiled npm packages, and Nix installations. `src/pi/resource-profiles.ts` +currently derives the Patchmill package root by resolving `../..` from its +module directory. That reaches the repository root when the module runs from +`src/pi/`, but reaches `/dist` when the compiled module runs from +`dist/src/pi/`. The resulting `dist/extensions/todos.ts` path does not exist. + +Issue #117 fixed a different path problem: an outer Pi process could pass a +foreign `PI_PACKAGE_DIR` into Patchmill and redirect bundled Pi's own resource +lookup. Commit `4319009899a96d5e2a58e86a14456f706f3b187f` removes that inherited +override before the CLI loads. It does not change Patchmill-owned extension +resolution and does not supersede this issue. + +Issue #116 is an automation-excluded umbrella for run-once subagent metadata +work. Its concise umbrella specification makes #121 an independent prerequisite +for the later package-owned observer in #124. The prior monolithic specification +is superseded and no longer authoritative. The deprecated monolithic plan at +commit `bcf7798dc6d5a747eb3105de32fac7992df62643` remains reference material +only. This design extracts that plan's package-root fix and packaging checks +without adopting observer or progress-reporting work. + +## Goals + +- Resolve the nearest owning package root without assuming a fixed + source-directory depth. +- Use one shared resolver for Patchmill-owned extension, fixture, and version + resources. +- Preserve existing run-once extension ordering and todos-extension behavior. +- Prove source, compiled/npm-packed, and Nix-installed layouts resolve extension + files that exist. +- Fail clearly when no package boundary can be found, a boundary cannot be + inspected, or a package-owned extension is missing. + +## Non-goals + +- Change `pi-subagents` or its package resolution. +- Add the run-once lifecycle observer owned by #124. +- Add subagent progress streaming, correlation, or rendering. +- Change Pi resource-profile composition, ordering, or CLI arguments beyond + correcting absolute paths. +- Change fixture contents, version output, package metadata, or dependency + versions. +- Validate that an owning `package.json` has the package name `patchmill`. + +## Architecture + +### Shared package-root resolver + +Create `src/package-root.ts` with one synchronous resolver and a distinct +not-found error: + +```ts +export class PackageRootNotFoundError extends Error; +export function findPackageRoot(startDir: string): string; +``` + +The resolver normalizes `startDir` to an absolute path, then walks upward one +directory at a time. It uses `statSync()` to return the nearest directory whose +`package.json` is a regular file. It does not parse that file, validate its +package name, dereference the start path with `realpath`, or cache results. + +Only `ENOENT` and `ENOTDIR` mean the current candidate is absent and permit +traversal to continue. Filesystem failures such as `EACCES` and `ELOOP` +propagate unchanged; a non-file `package.json` fails explicitly rather than +selecting an outer package. If traversal reaches the filesystem root without a +package file, the resolver throws `PackageRootNotFoundError` containing the +normalized starting path. Synchronous behavior is intentional: resource profiles +compute package-owned paths during module initialization, while the other +consumers can call the same API without introducing a second asynchronous +implementation. + +### Pi resource profiles + +`src/pi/resource-profiles.ts` resolves its module directory from +`import.meta.url`, calls `findPackageRoot()`, and joins `extensions/todos.ts` +onto the result. During module initialization it verifies that the resolved +todos extension is a regular file. A missing, inaccessible, cyclic, or non-file +extension fails initialization instead of passing a plausible nonexistent path +to Pi. Dependency-owned `pi-subagents` continues to resolve through +`require.resolve("pi-subagents/package.json")` because its package boundary is +independent of Patchmill's. + +Run-once extension paths remain ordered as: + +1. the `pi-subagents` package root; +2. Patchmill's `extensions/todos.ts`. + +Planning, development-environment, and implementation profiles continue to use +those two paths. Triage continues to load no additional extensions. + +### Setup-test-repo fixtures + +`src/cli/commands/setup-test-repo/fixtures.ts` removes its private asynchronous +root walker and calls the shared synchronous resolver. +`resolveFixtureDirectory()` remains asynchronous and keeps its current signature +and return value; only its internal package-boundary discovery changes. Fixture +validation and copying behavior remain unchanged. + +### Version lookup + +`src/cli/commands/version/main.ts` replaces its two hard-coded `package.json` +candidates with the shared resolver. `readPackageVersion(moduleUrl)` converts +the module URL to a directory, resolves the nearest package root, reads that +root's `package.json`, and returns its string `version`. + +Malformed JSON and non-string version values retain explicit failures. +`readPackageVersion()` is the compatibility boundary for its existing missing- +root contract: it catches only `PackageRootNotFoundError` and rethrows +`Could not locate Patchmill package.json` with the resolver error as its cause. +It does not catch filesystem access errors, symlink loops, JSON failures, or +validation errors. Nearest-boundary selection remains the explicitly approved +semantic change for this consumer. + +## Resolution flow by layout + +### Source tree + +A module under `/src/...` walks upward to `/package.json`. +Package-owned extensions resolve below `/extensions/`. + +### Compiled and npm-packed layout + +A module under `/dist/src/...` walks upward through `dist/` to +`/package.json`. Package-owned extensions resolve below +`/extensions/`, which is already included by npm package metadata. + +### Nix-installed layout + +Patchmill's Nix wrapper executes source under `$out/share/patchmill/`. The +installation copies `package.json`, `src/`, `extensions/`, and the dependency +link into that same package root. Source modules therefore resolve +`$out/share/patchmill/package.json` and +`$out/share/patchmill/extensions/todos.ts` without Nix-specific path logic. + +## Failure behavior + +- A missing package boundary fails with the normalized starting path; version + lookup translates only that typed not-found error to its established public + error and retains the original error as `cause`. +- `ENOENT` and `ENOTDIR` are the only candidate-probe errors treated as absence. + `EACCES`, `ELOOP`, and other filesystem failures propagate immediately. +- Resource-profile initialization verifies `extensions/todos.ts` is a regular + file and does not allow Pi to continue with a missing or invalid extension. +- An unreadable or malformed version `package.json` remains a version-read + failure rather than being treated as a missing package boundary. +- The nearest ancestor wins when nested package boundaries exist. +- Consumers do not infer or search for alternative resource locations after the + resolver succeeds. + +## Testing strategy + +### Resolver behavior + +Create `src/package-root.test.ts` to prove that the shared resolver: + +- finds roots from source-style and arbitrarily nested dist-style directories; +- selects the nearest ancestor when package boundaries are nested; +- normalizes relative starting paths; +- skips only `ENOENT` and `ENOTDIR` candidate probes; +- propagates `EACCES`, `ELOOP`, and comparable filesystem failures; +- rejects a non-file `package.json`; and +- throws the typed not-found error with the starting path when no ancestor + contains `package.json`. + +These tests pass Patchmill's Testing Value Gate because they protect reusable +filesystem behavior and fail for meaningful regressions to fixed-depth lookup. + +### Consumer regressions + +Update focused tests so that: + +- `src/pi/resource-profiles.test.ts` confirms every configured run-once + extension path exists while preserving extension order and triage behavior; +- `src/pi/resource-profiles.compiled.test.ts` compiles the real profile into an + isolated package layout, imports the emitted JavaScript, proves all extension + paths exist, and proves module initialization fails when `extensions/todos.ts` + is absent; +- setup-test-repo fixture tests continue resolving the fixture directory from + nested module-style paths through the shared resolver; and +- version tests prove arbitrary nesting works and preserve malformed JSON and + non-string version failures. + +Tests should assert observable paths and errors, not source imports or +implementation text. + +### npm-packed layout + +After building, create an npm tarball inside a trap-managed temporary directory +with `npm pack --pack-destination`, install it into a temporary project, import +`dist/src/pi/resource-profiles.js`, construct a run-once profile, and assert +that every returned extension path exists. Creating the work directory before +packing guarantees cleanup even if npm's JSON output cannot be decoded; JSON +parsing remains fail-loud. + +`npm pack --dry-run` alone is insufficient because it proves file inclusion but +not runtime path resolution. The existing package-content test remains +responsible for confirming that `extensions/todos.ts` is shipped; no new test +should merely restate the `package.json` file list. + +### Nix-installed layout + +Extend `nix/package.nix`'s `installCheckPhase` to import the installed resource +profile from `$out/share/patchmill`, construct a run-once profile, and fail if +any extension path is missing. Use the Nix build as direct verification rather +than adding a test that only asserts Nix expression text. + +Verification must include: + +```sh +node --test \ + src/package-root.test.ts \ + src/pi/resource-profiles.test.ts \ + src/pi/resource-profiles.compiled.test.ts \ + src/cli/commands/setup-test-repo/fixtures.test.ts \ + src/cli/commands/version/main.test.ts +npm test +npm run lint +npm run build +nix build .#patchmill --no-link --print-build-logs +``` + +The implementation plan will include the exact temporary npm-packed installation +command and its expected output. + +## Compatibility and scope control + +The change introduces no dependency or package-lock updates. Successful public +CLI output, command signatures, and the version command's established missing- +root message remain unchanged. `resolveFixtureDirectory()` remains asynchronous, +and `readPackageVersion()` retains its injectable module URL used by tests. + +The shared migration intentionally covers three existing package-root consumers: +resource profiles, setup-test-repo fixtures, and version lookup. The human +reviewer explicitly approved this expanded scope and nearest-boundary semantics +during #121 design review. It does not search for or refactor unrelated +resource-location code outside those consumers. + +The implementation should remain within three reviewable tasks: + +1. add and test the shared resolver, then migrate fixture and version lookup; +2. migrate Pi resource profiles and add source/dist regression coverage; and +3. verify npm-packed and Nix-installed layouts. + +## Alternatives considered + +### Keep the resolver inside resource profiles + +This was the deprecated monolithic plan's narrow approach. It fixes the +immediate extension bug but leaves duplicate package-root discovery in +setup-test-repo and fixed candidate depths in version lookup. The approved +design instead consolidates those callers behind one contract. + +### Resolve `patchmill/package.json` through Node + +Package self-resolution is concise after npm installation but is less reliable +when executing directly from a source checkout and can depend on package exports +or workspace resolution. Ancestor traversal behaves consistently in every +required layout. + +### Inject the root at build or wrapper time + +Generated constants or environment variables avoid filesystem traversal but +create separate npm and Nix configuration paths. That duplicates layout +knowledge and risks reproducing the divergence this issue removes. + +## Acceptance criteria + +- One shared resolver returns the nearest ancestor containing a regular + `package.json` file from arbitrary nesting. +- Candidate traversal suppresses only `ENOENT` and `ENOTDIR`; access errors, + symlink loops, and comparable filesystem failures propagate. +- Resource profiles, setup-test-repo fixtures, and version lookup use that + resolver. +- Source execution resolves the existing `pi-subagents` and todos extension + paths in their current order. +- Compiled/npm-packed execution resolves the same existing extension files from + the package root. +- Nix-installed execution resolves the same existing extension files from + `$out/share/patchmill`. +- Existing todos-extension loading and triage resource behavior remain + unchanged. +- Resource-profile initialization fails when the todos extension is missing or + not a regular file. +- A committed compiled-layout regression test imports the built profile and + fails if extension resolution depends on source-tree directory depth. +- Missing package roots fail explicitly instead of depending on source-tree + directory depth; version lookup preserves its established public not-found + message. +- Focused tests, the full test suite, lint, build, packed-install verification, + and the Nix package build pass. diff --git a/nix/package.nix b/nix/package.nix index 5f035ffe..f780828d 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -75,6 +75,28 @@ buildNpmPackageNode24 rec { test -f .patchmill/skills/patchmill-issue-triage/SKILL.md ) test -f "$out/share/${pname}/fixtures/patchmill-test-repo/README.md" + test -f "$out/share/${pname}/extensions/todos.ts" + ( + cd "$out/share/${pname}" + ${nodejs_24}/bin/node --input-type=module -e " + import { existsSync } from 'node:fs'; + import { runOncePlanningPiProfile } from './src/pi/resource-profiles.ts'; + const skills = { + triage: 'triage', planning: 'planning', implementation: 'implementation', + developmentEnvironment: 'development-environment', toolchain: 'toolchain', + review: 'review', visualEvidence: 'visual-evidence', landing: 'landing', + }; + const profile = runOncePlanningPiProfile(skills, process.cwd()); + const missing = profile.additionalExtensionPaths.filter( + (extensionPath) => !existsSync(extensionPath), + ); + if (missing.length > 0) { + console.error('missing installed extension paths:', missing.join(', ')); + process.exit(1); + } + console.log('all installed extension paths exist'); + " + ) runHook postInstallCheck ''; diff --git a/src/cli/commands/setup-test-repo/fixtures.test.ts b/src/cli/commands/setup-test-repo/fixtures.test.ts index 951e1e68..b151d2f8 100644 --- a/src/cli/commands/setup-test-repo/fixtures.test.ts +++ b/src/cli/commands/setup-test-repo/fixtures.test.ts @@ -21,9 +21,28 @@ async function tempDir(): Promise { return mkdtemp(join(tmpdir(), "patchmill-fixture-test-")); } -test("resolveFixtureDirectory finds fixtures from the package root", async () => { - const fixtureDir = await resolveFixtureDirectory(process.cwd()); - assert.match(fixtureDir, /fixtures\/patchmill-test-repo$/u); +test("resolveFixtureDirectory finds fixtures from a nested package layout", async () => { + const packageRoot = await tempDir(); + const nestedModuleDirectory = join( + packageRoot, + "dist", + "src", + "cli", + "commands", + "setup-test-repo", + ); + + try { + await mkdir(nestedModuleDirectory, { recursive: true }); + await writeFile(join(packageRoot, "package.json"), "{}\n", "utf8"); + + assert.equal( + await resolveFixtureDirectory(nestedModuleDirectory), + join(packageRoot, "fixtures", "patchmill-test-repo"), + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } }); test("validateFixtureDirectory rejects missing project brief", async () => { diff --git a/src/cli/commands/setup-test-repo/fixtures.ts b/src/cli/commands/setup-test-repo/fixtures.ts index 3a6f49c3..2bc12dba 100644 --- a/src/cli/commands/setup-test-repo/fixtures.ts +++ b/src/cli/commands/setup-test-repo/fixtures.ts @@ -1,7 +1,8 @@ import { constants } from "node:fs"; import { access, chmod, cp, readdir, readFile, stat } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { findPackageRoot } from "../../../package-root.ts"; import { parseIssueFile, type SetupIssue } from "./issue-parser.ts"; const FIXTURE_RELATIVE_PATH = join("fixtures", "patchmill-test-repo"); @@ -15,20 +16,10 @@ async function exists(path: string): Promise { } } -async function findPackageRoot(startDir: string): Promise { - let current = resolve(startDir); - for (;;) { - if (await exists(join(current, "package.json"))) return current; - const parent = dirname(current); - if (parent === current) throw new Error("Could not find package root"); - current = parent; - } -} - export async function resolveFixtureDirectory( startDir = dirname(fileURLToPath(import.meta.url)), ): Promise { - const packageRoot = await findPackageRoot(startDir); + const packageRoot = findPackageRoot(startDir); return join(packageRoot, FIXTURE_RELATIVE_PATH); } diff --git a/src/cli/commands/version/main.test.ts b/src/cli/commands/version/main.test.ts new file mode 100644 index 00000000..d3e3d2ea --- /dev/null +++ b/src/cli/commands/version/main.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { PackageRootNotFoundError } from "../../../package-root.ts"; +import { readPackageVersion } from "./main.ts"; + +type PackageTree = { + moduleUrl: string; + packageRoot: string; +}; + +async function withPackageJson( + contents: string, + run: (tree: PackageTree) => void, +): Promise { + const packageRoot = await mkdtemp(join(tmpdir(), "patchmill-version-")); + const moduleDirectory = join( + packageRoot, + "dist", + "deep", + "src", + "cli", + "commands", + "version", + ); + + try { + await mkdir(moduleDirectory, { recursive: true }); + await writeFile(join(packageRoot, "package.json"), contents, "utf8"); + run({ + moduleUrl: pathToFileURL(join(moduleDirectory, "main.js")).href, + packageRoot, + }); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +} + +function systemError(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +test("readPackageVersion finds the nearest package from arbitrary nesting", async () => { + await withPackageJson('{"version":"9.8.7"}\n', ({ moduleUrl }) => { + assert.equal(readPackageVersion(moduleUrl), "9.8.7"); + }); +}); + +test("readPackageVersion preserves malformed JSON failures", async () => { + await withPackageJson("{", ({ moduleUrl }) => { + assert.throws(() => readPackageVersion(moduleUrl), SyntaxError); + }); +}); + +test("readPackageVersion rejects a non-string version", async () => { + await withPackageJson('{"version":123}\n', ({ moduleUrl, packageRoot }) => { + assert.throws( + () => readPackageVersion(moduleUrl), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + `package.json at ${join(packageRoot, "package.json")} does not contain a string version`, + ); + return true; + }, + ); + }); +}); + +test("readPackageVersion preserves its public missing-root error", (context) => { + context.mock.method(fs, "statSync", () => { + throw systemError("ENOENT"); + }); + const moduleUrl = pathToFileURL( + resolve("virtual", "without-package", "main.js"), + ).href; + + assert.throws( + () => readPackageVersion(moduleUrl), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Could not locate Patchmill package.json"); + assert.ok(error.cause instanceof PackageRootNotFoundError); + return true; + }, + ); +}); + +test("readPackageVersion propagates package-root access failures", (context) => { + const expected = systemError("EACCES"); + context.mock.method(fs, "statSync", () => { + throw expected; + }); + const moduleUrl = pathToFileURL(resolve("virtual", "main.js")).href; + + assert.throws( + () => readPackageVersion(moduleUrl), + (error: unknown) => error === expected, + ); +}); diff --git a/src/cli/commands/version/main.ts b/src/cli/commands/version/main.ts index 3d5516d6..6f3f7acd 100644 --- a/src/cli/commands/version/main.ts +++ b/src/cli/commands/version/main.ts @@ -1,6 +1,10 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL, fileURLToPath } from "node:url"; +import { + findPackageRoot, + PackageRootNotFoundError, +} from "../../../package-root.ts"; export const HELP_TEXT = `Usage: patchmill version @@ -20,44 +24,30 @@ type PackageJson = { version?: unknown; }; -function hasErrorCode(error: unknown, code: string): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === code - ); -} - -function packageJsonCandidates(moduleUrl = import.meta.url): string[] { - const moduleDir = dirname(fileURLToPath(moduleUrl)); - return [ - join(moduleDir, "../../../../package.json"), - join(moduleDir, "../../../../../package.json"), - ]; -} - export function readPackageVersion(moduleUrl = import.meta.url): string { - for (const packageJsonPath of packageJsonCandidates(moduleUrl)) { - let packageJson: PackageJson; - try { - packageJson = JSON.parse( - readFileSync(packageJsonPath, "utf8"), - ) as PackageJson; - } catch (error) { - if (hasErrorCode(error, "ENOENT")) continue; - throw error; + let packageRoot: string; + try { + packageRoot = findPackageRoot(dirname(fileURLToPath(moduleUrl))); + } catch (error) { + if (error instanceof PackageRootNotFoundError) { + throw new Error("Could not locate Patchmill package.json", { + cause: error, + }); } - - if (typeof packageJson.version !== "string") { - throw new Error( - `package.json at ${packageJsonPath} does not contain a string version`, - ); - } - return packageJson.version; + throw error; } - throw new Error("Could not locate Patchmill package.json"); + const packageJsonPath = join(packageRoot, "package.json"); + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf8"), + ) as PackageJson; + + if (typeof packageJson.version !== "string") { + throw new Error( + `package.json at ${packageJsonPath} does not contain a string version`, + ); + } + return packageJson.version; } export function runVersion( diff --git a/src/package-root.test.ts b/src/package-root.test.ts new file mode 100644 index 00000000..bdcb3237 --- /dev/null +++ b/src/package-root.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { test } from "node:test"; +import { findPackageRoot, PackageRootNotFoundError } from "./package-root.ts"; + +async function temporaryDirectory(): Promise { + return mkdtemp(join(tmpdir(), "patchmill-package-root-")); +} + +async function writePackageJson(directory: string): Promise { + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "package.json"), "{}\n", "utf8"); +} + +function systemError(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +test("findPackageRoot walks up from source and arbitrarily nested dist layouts", async () => { + const packageRoot = await temporaryDirectory(); + const sourceDirectory = join(packageRoot, "src", "pi"); + const distDirectory = join(packageRoot, "dist", "src", "pi", "nested"); + + try { + await writePackageJson(packageRoot); + await mkdir(sourceDirectory, { recursive: true }); + await mkdir(distDirectory, { recursive: true }); + + assert.equal(findPackageRoot(sourceDirectory), packageRoot); + assert.equal(findPackageRoot(distDirectory), packageRoot); + assert.equal( + findPackageRoot(relative(process.cwd(), distDirectory)), + packageRoot, + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +}); + +test("findPackageRoot returns the nearest package boundary", async () => { + const outerRoot = await temporaryDirectory(); + const innerRoot = join(outerRoot, "packages", "inner"); + const startDirectory = join(innerRoot, "dist", "src", "feature"); + + try { + await writePackageJson(outerRoot); + await writePackageJson(innerRoot); + await mkdir(startDirectory, { recursive: true }); + + assert.equal(findPackageRoot(startDirectory), innerRoot); + } finally { + await rm(outerRoot, { recursive: true, force: true }); + } +}); + +test("findPackageRoot rejects a non-file package boundary", async () => { + const packageRoot = await temporaryDirectory(); + const startDirectory = join(packageRoot, "src", "feature"); + + try { + await mkdir(join(packageRoot, "package.json"), { recursive: true }); + await mkdir(startDirectory, { recursive: true }); + + assert.throws( + () => findPackageRoot(startDirectory), + /package\.json is not a regular file/u, + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } +}); + +for (const code of ["ENOENT", "ENOTDIR"] as const) { + test(`findPackageRoot continues after ${code}`, (context) => { + const startDirectory = resolve("virtual", "package", "nested"); + const expectedRoot = dirname(startDirectory); + let calls = 0; + + context.mock.method(fs, "statSync", () => { + calls += 1; + if (calls === 1) throw systemError(code); + return { + isFile: () => true, + } as ReturnType; + }); + + assert.equal(findPackageRoot(startDirectory), expectedRoot); + assert.equal(calls, 2); + }); +} + +for (const code of ["EACCES", "ELOOP"] as const) { + test(`findPackageRoot propagates ${code}`, (context) => { + const expected = systemError(code); + context.mock.method(fs, "statSync", () => { + throw expected; + }); + + assert.throws( + () => findPackageRoot(resolve("virtual", "nested")), + (error: unknown) => error === expected, + ); + }); +} + +test("findPackageRoot throws a typed error with the normalized start", (context) => { + const startDirectory = resolve("virtual", "without-package", "nested"); + context.mock.method(fs, "statSync", () => { + throw systemError("ENOENT"); + }); + + assert.throws( + () => findPackageRoot(startDirectory), + (error: unknown) => { + assert.ok(error instanceof PackageRootNotFoundError); + assert.equal(error.startDir, startDirectory); + assert.equal( + error.message, + `Could not find package root walking up from ${startDirectory}`, + ); + return true; + }, + ); +}); diff --git a/src/package-root.ts b/src/package-root.ts new file mode 100644 index 00000000..7009b1cb --- /dev/null +++ b/src/package-root.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function isAbsentPathError(error: unknown): boolean { + return hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR"); +} + +export class PackageRootNotFoundError extends Error { + readonly startDir: string; + + constructor(startDir: string) { + super(`Could not find package root walking up from ${startDir}`); + this.name = "PackageRootNotFoundError"; + this.startDir = startDir; + } +} + +export function findPackageRoot(startDir: string): string { + const normalizedStart = resolve(startDir); + let current = normalizedStart; + + for (;;) { + const packageJsonPath = join(current, "package.json"); + try { + const stats = fs.statSync(packageJsonPath); + if (!stats.isFile()) { + throw new Error(`${packageJsonPath} is not a regular file`); + } + return current; + } catch (error) { + if (!isAbsentPathError(error)) throw error; + } + + const parent = dirname(current); + if (parent === current) { + throw new PackageRootNotFoundError(normalizedStart); + } + current = parent; + } +} diff --git a/src/pi/resource-profiles.compiled.test.ts b/src/pi/resource-profiles.compiled.test.ts new file mode 100644 index 00000000..2da28b55 --- /dev/null +++ b/src/pi/resource-profiles.compiled.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { copyFile, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { findPackageRoot } from "../package-root.ts"; +import type { PatchmillSkillsConfig } from "../workflow/skills.ts"; + +const require = createRequire(import.meta.url); +const sourceRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url))); +const tscPath = require.resolve("typescript/bin/tsc"); + +const skills: PatchmillSkillsConfig = { + triage: "triage", + planning: "planning", + implementation: "implementation", + developmentEnvironment: "development-environment", + toolchain: "toolchain", + review: "review", + visualEvidence: "visual-evidence", + landing: "landing", +}; + +test( + "compiled resource profiles resolve and require package-owned extensions", + { timeout: 60_000 }, + async () => { + const packageRoot = await mkdtemp( + join(tmpdir(), "patchmill-compiled-profile-"), + ); + const compiledProfile = join( + packageRoot, + "dist", + "src", + "pi", + "resource-profiles.js", + ); + const todosExtension = join(packageRoot, "extensions", "todos.ts"); + + try { + await mkdir(dirname(todosExtension), { recursive: true }); + await copyFile( + join(sourceRoot, "package.json"), + join(packageRoot, "package.json"), + ); + await copyFile( + join(sourceRoot, "extensions", "todos.ts"), + todosExtension, + ); + await symlink( + join(sourceRoot, "node_modules"), + join(packageRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + + const build = spawnSync( + process.execPath, + [ + tscPath, + "-p", + join(sourceRoot, "tsconfig.build.json"), + "--rootDir", + sourceRoot, + "--outDir", + join(packageRoot, "dist"), + ], + { + cwd: sourceRoot, + encoding: "utf8", + timeout: 45_000, + }, + ); + assert.equal(build.error, undefined); + assert.equal(build.status, 0, build.stderr || build.stdout); + + const compiled = await import(pathToFileURL(compiledProfile).href); + const profile = compiled.runOncePlanningPiProfile(skills, packageRoot); + assert.deepEqual( + profile.additionalExtensionPaths.map((path: string) => + existsSync(path), + ), + [true, true], + ); + assert.equal( + profile.additionalExtensionPaths[1] + ?.replaceAll("\\", "/") + .endsWith("/extensions/todos.ts"), + true, + ); + + const missingProfile = join( + dirname(compiledProfile), + "resource-profiles-missing.js", + ); + await copyFile(compiledProfile, missingProfile); + await rm(todosExtension); + + await assert.rejects( + import(pathToFileURL(missingProfile).href), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as NodeJS.ErrnoException).code, "ENOENT"); + assert.match(error.message, /extensions[\\/]todos\.ts/u); + return true; + }, + ); + + await mkdir(todosExtension); + const directoryProfile = join( + dirname(compiledProfile), + "resource-profiles-directory.js", + ); + await copyFile(compiledProfile, directoryProfile); + + await assert.rejects( + import(pathToFileURL(directoryProfile).href), + new RegExp( + `Patchmill extension is not a regular file: .*extensions[\\\\/]todos\\.ts`, + "u", + ), + ); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } + }, +); diff --git a/src/pi/resource-profiles.test.ts b/src/pi/resource-profiles.test.ts index 6f727104..1982d7ba 100644 --- a/src/pi/resource-profiles.test.ts +++ b/src/pi/resource-profiles.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -52,6 +53,13 @@ test("run-once planning profile includes context and Patchmill run-once extensio .endsWith("/extensions/todos.ts"), true, ); + for (const extensionPath of profile.additionalExtensionPaths) { + assert.equal( + existsSync(extensionPath), + true, + `missing extension: ${extensionPath}`, + ); + } assert.deepEqual(profile.additionalSkillPaths, [ join(repoRoot, "skills", "planning", "SKILL.md"), ]); diff --git a/src/pi/resource-profiles.ts b/src/pi/resource-profiles.ts index 201d1c12..0239a3e8 100644 --- a/src/pi/resource-profiles.ts +++ b/src/pi/resource-profiles.ts @@ -1,6 +1,8 @@ +import fs from "node:fs"; import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { findPackageRoot } from "../package-root.ts"; import { skillInvocationPaths, type PatchmillSkillsConfig, @@ -10,14 +12,19 @@ const require = createRequire(import.meta.url); const PI_SUBAGENTS_PACKAGE_ROOT = dirname( require.resolve("pi-subagents/package.json"), ); -const PATCHMILL_PACKAGE_ROOT = resolve( +function requireRegularFile(path: string): string { + const stats = fs.statSync(path); + if (!stats.isFile()) { + throw new Error(`Patchmill extension is not a regular file: ${path}`); + } + return path; +} + +const PATCHMILL_PACKAGE_ROOT = findPackageRoot( dirname(fileURLToPath(import.meta.url)), - "../..", ); -const PATCHMILL_TODOS_EXTENSION = join( - PATCHMILL_PACKAGE_ROOT, - "extensions", - "todos.ts", +const PATCHMILL_TODOS_EXTENSION = requireRegularFile( + join(PATCHMILL_PACKAGE_ROOT, "extensions", "todos.ts"), ); export type PatchmillPiResourceProfileId =