From 65b75f6afc5b667f3b263f582c82a3e93b2afd46 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Mon, 19 Oct 2026 08:39:20 -0700 Subject: [PATCH] fix(fix): discover independent nested projects (UPT-120) Signed-off-by: Lucas Braum --- .changeset/tidy-nested-projects.md | 5 ++ README.md | 2 +- packages/cli/src/cli.ts | 16 +++- packages/cli/src/failures.ts | 7 +- packages/cli/src/nested-project.test.ts | 31 ++++++++ packages/core/src/fix/isolate.test.ts | 35 +++++++++ packages/core/src/fix/isolate.ts | 9 +++ packages/core/src/fix/process.ts | 5 +- packages/core/src/index.ts | 1 + packages/core/src/projects.test.ts | 97 +++++++++++++++++++++++++ packages/core/src/projects.ts | 95 ++++++++++++++++++++++++ 11 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 .changeset/tidy-nested-projects.md create mode 100644 packages/cli/src/nested-project.test.ts create mode 100644 packages/core/src/projects.test.ts create mode 100644 packages/core/src/projects.ts diff --git a/.changeset/tidy-nested-projects.md b/.changeset/tidy-nested-projects.md new file mode 100644 index 0000000..dfad7d0 --- /dev/null +++ b/.changeset/tidy-nested-projects.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Discover independent nested projects when fixing a dependency absent from the root project. Run installation, migration and verification in the selected project's directory, preserving its lockfile and package manager. When several projects declare the dependency, name their manifests and require an explicit directory. diff --git a/README.md b/README.md index 2738372..8bb3f5b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ npx uptide pr --branch uptide/zod-4.6.5 # prints the plan; add --yes to push preserves workspace and companion ranges that already accept the target, reports them as **already compatible**, and widens rejecting peer ranges without dropping existing alternatives. What the target requires moves within its major, else is a blocker; a deprecated `@types` stub is removed, not -upgraded. Lockfile, target and companions must match the planned versions, or `fix` and `verify` fail. +upgraded. Lockfile, target and companions must match the planned versions, or `fix` and `verify` fail. `fix` discovers a unique nested project with its own lockfile when the root does not declare the dependency, and scopes installation, verification and migration to it. If several projects match, select one with `--cwd frontend`. Draft PR runs update conflicting root Yarn resolutions and npm/pnpm overrides and put them under **Root pins changed: review**. Set `updatePins: false` in `uptide.yml` to opt out. Local runs require `--update-pins`; unrelated pins stay unchanged. Everything else: [getting started](docs/getting-started.md). ## Why Uptide diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 83f5ffb..42d7448 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -15,6 +15,7 @@ import { packageSelected, packLeading, priceFor, + projectForDependency, publicationFailureReason, ReportedError, registeredPacks, @@ -1013,7 +1014,6 @@ ${runnable(PRIVACY)}`, { next: 'uptide fix --help' }, ); const quiet = !flags.verbose && !flags.debug; - const loaded = settings(cwd, flags.config); if (packageName && flags.only && packageName !== flags.only) throw new CliError('choose one package for fix'); const requested = packageName ?? flags.only; @@ -1025,10 +1025,18 @@ ${runnable(PRIVACY)}`, throw new CliError(`--only ${requested}: fix upgrades one dependency at a time`, { next: `uptide fix ${requested.split(',')[0]}`, }); + // Select an independent install root before reading its settings or lockfile. + const packs = activePacks(flags.allowCandidate); + const only = leadName(requested, packs); + const pack = packs.find((candidate) => candidate.name === only); + cwd = projectForDependency(cwd, [ + requested, + only, + ...(pack?.alternateLeads?.map((lead) => lead.name) ?? []), + ]); + const loaded = settings(cwd, flags.config); namedOutside(loaded, [requested]); - // An alternate lead's name (`react-router-dom`) fixes its pack's lead, and a target - // named after the alternate is the lead's. - const only = leadName(requested, activePacks(flags.allowCandidate)); + // An alternate lead's target names the pack's lead too. const target = only === requested ? flags.target diff --git a/packages/cli/src/failures.ts b/packages/cli/src/failures.ts index b28dac2..8b80d1b 100644 --- a/packages/cli/src/failures.ts +++ b/packages/cli/src/failures.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { ACCEPTED_KEYS, type CheckResult, errorCode } from '@uptide/core'; +import { ACCEPTED_KEYS, type CheckResult, errorCode, isIndependentProject } from '@uptide/core'; import type { PackageManager, Repo } from './detect.js'; import { CliError } from './errors.js'; import { invocation } from './invocation.js'; @@ -199,7 +199,10 @@ export function requireFixable(repo: Repo, only: string, target?: string): void dir.startsWith(realpathSync(top)); dir = dirname(dir) ) { - if (existsSync(join(dir, 'package.json'))) { + if ( + existsSync(join(dir, 'package.json')) && + !isIndependentProject(realpathSync(repo.root), dir) + ) { const cwd = /^[\w./-]+$/.test(dir) ? dir : `'${dir.replaceAll("'", "'\"'\"'")}'`; throw new CliError('fix has to run at the project root', { why: 'Every workspace declaring the dependency is upgraded together.', diff --git a/packages/cli/src/nested-project.test.ts b/packages/cli/src/nested-project.test.ts new file mode 100644 index 0000000..b3038ea --- /dev/null +++ b/packages/cli/src/nested-project.test.ts @@ -0,0 +1,31 @@ +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { run } from './cli.js'; +import { fakeEngine, memoryIo, pnpmGitRepo } from './test-utils.js'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +it('fix selects the nested project before dependency, manager and config preflight (UPT-120)', async () => { + const root = pnpmGitRepo({ + 'package.json': '{"name":"repo-tools","devDependencies":{"commitlint":"1.0.0"}}', + 'frontend/package.json': + '{"name":"frontend","packageManager":"npm@10.0.0","devDependencies":{"zod":"3.23.8"}}', + 'frontend/package-lock.json': '{"lockfileVersion":3}', + 'frontend/node_modules/zod/package.json': '{"name":"zod","version":"3.23.8"}', + 'frontend/uptide.yml': 'version: 1\nmaxCost: 2\n', + }); + roots.push(root); + const engine = fakeEngine(); + const io = memoryIo({ cwd: root }); + expect(await run(['fix', 'zod', '--no-llm'], io, engine), io.stderr()).toBe(0); + expect(engine.calls[0]).toMatchObject({ + cwd: join(root, 'frontend'), + only: 'zod', + maxCostUsd: 2, + }); + expect(io.stderr()).toContain('frontend'); +}); diff --git a/packages/core/src/fix/isolate.test.ts b/packages/core/src/fix/isolate.test.ts index f7fbc33..e40bb8c 100644 --- a/packages/core/src/fix/isolate.test.ts +++ b/packages/core/src/fix/isolate.test.ts @@ -230,6 +230,41 @@ describe('fix runs in a private clone', () => { ).rejects.toThrow(`run fix at the project root, ${project}`); }, 30000); + it('discovers an independent nested project and scopes the entire migration to it (UPT-120)', async () => { + const { root: fixture, services } = zodFixture(scratch); + rmSync(join(fixture, '.git'), { recursive: true, force: true }); + const top = realpathSync(mkdtempSync(join(scratch, 'nested-'))); + const project = join(top, 'frontend'); + renameSync(fixture, project); + const manifest = JSON.stringify({ + name: 'repo-tools', + devDependencies: { commitlint: '1.0.0' }, + }); + writeFileSync(join(top, 'package.json'), manifest); + writeFileSync(join(top, 'package-lock.json'), '{}'); + git(top, 'init'); + git(top, 'config', 'user.email', 'test@example.test'); + git(top, 'config', 'user.name', 'Test'); + git(top, 'add', '.'); + git(top, 'commit', '-m', 'baseline'); + const before = snapshot(top); + const install = vi.spyOn(services, 'install'); + const tests = vi.spyOn(services, 'tests'); + const report = await isolatedFix({ cwd: top, only: 'zod', tool: TOOL }, services); + expect(report.verification.passed).toBe(true); + expect(report.source).toBe(top); + expect(snapshot(top)).toEqual(before); + expect(install.mock.calls.length).toBeGreaterThan(0); + expect(tests.mock.calls.length).toBeGreaterThan(0); + for (const [dir] of [...install.mock.calls, ...tests.mock.calls]) + expect(dir).toMatch(/\/frontend$/); + expect(git(top, 'show', `${report.branch}:frontend/package.json`)).toContain('4.6.5'); + expect(git(top, 'show', `${report.branch}:package.json`)).toBe(manifest); + expect(git(top, 'show', `${report.branch}:package-lock.json`)).toBe('{}'); + const changed = git(top, 'diff', '--name-only', `HEAD...${report.branch}`).split('\n'); + expect(changed.every((file) => file.startsWith('frontend/'))).toBe(true); + }, 30000); + it('reports a checkout that changed during the run', async () => { const { root, services } = zodFixture(scratch); const tests = services.tests; diff --git a/packages/core/src/fix/isolate.ts b/packages/core/src/fix/isolate.ts index 2424862..c32f90e 100644 --- a/packages/core/src/fix/isolate.ts +++ b/packages/core/src/fix/isolate.ts @@ -19,6 +19,7 @@ import { workspacePackagesOf } from '../adapters/typescript/repo.js'; import { errorCode, SafetyRefusal, UptideError } from '../errors.js'; import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js'; import { activePack, assertCandidateLocal } from '../packs/index.js'; +import { projectForDependency } from '../projects.js'; import { uptideVersionInfo } from '../version.js'; import { assistedPeerPreflight, assistedRootPins } from './feedback.js'; import { assertNpmLockSync } from './managers/npm-sync.js'; @@ -533,6 +534,14 @@ export async function isolatedFix( services?: FixServices, ): Promise { assertCandidateLocal(options); + const pack = options.pack ?? activePack(options.only, options.allowCandidate); + options = { + ...options, + cwd: projectForDependency(options.cwd, [ + options.only, + ...(pack?.alternateLeads?.map((lead) => lead.name) ?? []), + ]), + }; const selected = selectLlm(options.cwd, options); if ( options.maxCostUsd !== undefined && diff --git a/packages/core/src/fix/process.ts b/packages/core/src/fix/process.ts index 1102cb2..3effb17 100644 --- a/packages/core/src/fix/process.ts +++ b/packages/core/src/fix/process.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { UptideError } from '../errors.js'; +import { isIndependentProject } from '../projects.js'; /** * An empty directory to point `core.hooksPath` at: no hook of the repository can run from it. @@ -109,7 +110,7 @@ export function command( } /** - * The project a run belongs to: the top-most directory with a package.json between `cwd` + * The project a run belongs to: an independent install root between `cwd` * and the git top level. A JavaScript project may live below the repository root (a * `frontend/` next to a Python backend); a run from inside one of its workspaces is refused, * since every workspace declaring the dependency is upgraded together. @@ -123,7 +124,7 @@ export function projectRoot(cwd: string): { root: string; top: string; project: `${root}: no package.json; run fix at the project root`, ); for (let dir = dirname(root); dir.startsWith(top) && dir !== root; dir = dirname(dir)) { - if (existsSync(join(dir, 'package.json'))) + if (existsSync(join(dir, 'package.json')) && !isIndependentProject(root, dir)) throw new UptideError( 'NOT_REPOSITORY_ROOT', `run fix at the project root, ${dir} (workspace versions are upgraded together)`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1145664..2867f15 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -274,6 +274,7 @@ export { type UpgradePlan, type UpgradeStep, } from './plan/plan.js'; +export { isIndependentProject, projectForDependency } from './projects.js'; export { diffRuntime, probeRuntime } from './runtime/runtime.js'; export { UPTIDE_COMMAND, diff --git a/packages/core/src/projects.test.ts b/packages/core/src/projects.test.ts new file mode 100644 index 0000000..333856c --- /dev/null +++ b/packages/core/src/projects.test.ts @@ -0,0 +1,97 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { projectForDependency } from './projects.js'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function fixture(files: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'uptide-projects-')); + roots.push(root); + for (const [file, contents] of Object.entries({ 'package.json': '{"name":"tools"}', ...files })) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), contents); + } + return root; +} +const manifest = '{"devDependencies":{"vitest":"^4.0.0"}}'; + +it.each(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock'])( + 'finds a nested install root using %s without installed dependencies', + (lock) => { + const root = fixture({ 'apps/frontend/package.json': manifest, [`apps/frontend/${lock}`]: '' }); + expect(projectForDependency(root, ['vitest'])).toBe(join(root, 'apps/frontend')); + }, +); +it('preserves root and declared workspace selection', () => { + const cases: Record[] = [ + { 'package.json': manifest }, + { 'package.json': '{"workspaces":["packages/*"]}', 'packages/ui/package.json': manifest }, + ]; + for (const files of cases) { + const root = fixture({ + ...files, + 'frontend/package.json': manifest, + 'frontend/package-lock.json': '', + }); + expect(projectForDependency(root, ['vitest'])).toBe(root); + } +}); +it('selects a nested workspace root rather than its member with a stray lockfile', () => { + const root = fixture({ + 'frontend/package.json': '{"workspaces":["packages/*"]}', + 'frontend/package-lock.json': '', + 'frontend/packages/ui/package.json': manifest, + 'frontend/packages/ui/package-lock.json': '', + }); + expect(projectForDependency(root, ['vitest'])).toBe(join(root, 'frontend')); +}); +it('names every candidate instead of choosing between independent projects', () => { + const root = fixture({ + 'frontend/package.json': manifest, + 'frontend/package-lock.json': '', + 'backend/package.json': manifest, + 'backend/pnpm-lock.yaml': '', + }); + expect(() => projectForDependency(root, ['vitest'])).toThrow( + 'vitest is declared in multiple independent projects: backend/package.json, frontend/package.json; run from the intended directory or pass --cwd ', + ); +}); +it('ignores dependencies, build output, hidden, gitignored and symlinked directories, and manifests without a lockfile', () => { + const files: Record = { + '.gitignore': 'ignored/\n', + 'apps/.gitignore': 'local/\n', + 'unlocked/package.json': manifest, + }; + for (const dir of [ + 'node_modules/dep', + 'dist/example', + '.cache/example', + 'ignored', + 'apps/local', + ]) { + files[`${dir}/package.json`] = manifest; + files[`${dir}/package-lock.json`] = ''; + } + const root = fixture(files); + const outside = fixture({ 'package.json': manifest, 'package-lock.json': '' }); + symlinkSync(outside, join(root, 'linked'), 'dir'); + expect(projectForDependency(root, ['vitest'])).toBe(root); +}); +it('accepts alternate package names and all direct dependency fields', () => { + for (const field of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]) { + const root = fixture({ + 'frontend/package.json': JSON.stringify({ [field]: { 'router-dom': '^6' } }), + 'frontend/package-lock.json': '', + }); + expect(projectForDependency(root, ['router', 'router-dom'])).toBe(join(root, 'frontend')); + } +}); diff --git a/packages/core/src/projects.ts b/packages/core/src/projects.ts new file mode 100644 index 0000000..3c34043 --- /dev/null +++ b/packages/core/src/projects.ts @@ -0,0 +1,95 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { UptideError } from './errors.js'; +import { defaultSkip, directoryRules, type IgnoreRule, ignoredBy } from './list/ignore.js'; +import { workspacePackagesOf } from './workspaces.js'; + +/** An independent install boundary, not just another manifest in a workspace. */ +export function hasProjectLockfile(root: string): boolean { + return [ + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + ].some((file) => existsSync(join(root, file))); +} + +/** A child lockfile is independent only when the ancestor does not declare it as a workspace. */ +export function isIndependentProject(root: string, ancestor: string): boolean { + return ( + hasProjectLockfile(root) && + !workspacePackagesOf(ancestor).some( + (workspace) => resolve(ancestor, workspace) === resolve(root), + ) + ); +} + +function declares(root: string, names: string[]): boolean { + return workspacePackagesOf(root).some((workspace) => { + const file = join(root, workspace, 'package.json'); + if (!existsSync(file)) return false; + const pkg = JSON.parse(readFileSync(file, 'utf8')); + return names.some((name) => + ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].some( + (field) => Object.hasOwn(pkg[field] ?? {}, name), + ), + ); + }); +} + +/** + * Keep the requested project when it declares the dependency. Otherwise select the unique + * nested install root that does. Independent projects are never combined into a workspace: + * their package managers, lockfiles, configuration and verification remain separate. + */ +export function projectForDependency(cwd: string, names: string[]): string { + const root = resolve(cwd); + if (!existsSync(root) || declares(root, names)) return cwd; + const matches: string[] = []; + const walk = (dir: string, inherited: IgnoreRule[], members: Set): void => { + const entries = readdirSync(dir, { withFileTypes: true }); + const rules = [ + ...inherited, + ...directoryRules( + dir, + entries.map((entry) => entry.name), + ), + ]; + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith('.') || + defaultSkip(entry.name) || + entry.name === 'out' + ) + continue; + const child = join(dir, entry.name); + if (ignoredBy(child, true, rules)) continue; + let nestedMembers = members; + if ( + !members.has(child) && + existsSync(join(child, 'package.json')) && + hasProjectLockfile(child) + ) { + if (declares(child, names)) matches.push(child); + nestedMembers = new Set([ + ...members, + ...workspacePackagesOf(child).map((w) => resolve(child, w)), + ]); + } + walk(child, rules, nestedMembers); + } + }; + walk(root, [], new Set(workspacePackagesOf(root).map((w) => resolve(root, w)))); + if (matches.length > 1) + throw new UptideError( + 'NO_PROJECT', + `${names.join(' or ')} is declared in multiple independent projects: ${matches + .map((dir) => `${relative(root, dir)}/package.json`) + .sort() + .join(', ')}; run from the intended directory or pass --cwd `, + ); + return matches[0] ?? cwd; +}