Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-nested-projects.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. npm pins use Corepack when needed, or local npm 9+ with a v3 lockfile and a report note; frozen installs must leave the lockfile unchanged. Everything else: [getting started](docs/getting-started.md).

## Why Uptide
Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
packageSelected,
packLeading,
priceFor,
projectForDependency,
publicationFailureReason,
ReportedError,
registeredPacks,
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/failures.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.',
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/nested-project.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
35 changes: 35 additions & 0 deletions packages/core/src/fix/isolate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/fix/isolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -533,6 +534,14 @@ export async function isolatedFix(
services?: FixServices,
): Promise<FixReport> {
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 &&
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/fix/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/projects.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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<string, string>[] = [
{ '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 <directory>',
);
});
it('ignores dependencies, build output, hidden, gitignored and symlinked directories, and manifests without a lockfile', () => {
const files: Record<string, string> = {
'.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'));
}
});
95 changes: 95 additions & 0 deletions packages/core/src/projects.ts
Original file line number Diff line number Diff line change
@@ -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),
),
);
});
}
Comment on lines +33 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Broken nested manifests abort dependency discovery

The new discovery walk probes arbitrary directories, but parses them with strict, unguarded calls: declares runs JSON.parse on every workspace member manifest with no error handling (packages/core/src/projects.ts:33), and the walk resolves nested workspaces with the strict workspacePackagesOf, which throws INVALID_WORKSPACE for a broken pnpm-workspace.yaml (packages/core/src/projects.ts:79). One malformed nested package.json (or one whose JSON is not an object, which also throws TypeError at the field access) or a broken workspace file therefore makes projectForDependency throw a raw SyntaxError, TypeError, or INVALID_WORKSPACE instead of returning a project, failing the whole fix run before any error reporting. The codebase already guards the root manifest in declaredPatterns and exports the tolerant workspacePackagesOrRoot for exactly these probe contexts, but this walk uses neither.

📋 Prompt for AI Agents

In packages/core/src/projects.ts, harden the discovery probes: (1) in declares (lines 29-40), wrap readFileSync/JSON.parse in a try/catch that treats an unreadable or invalid manifest as not declaring the dependency, and guard that the parsed value is a non-null object before the field access; (2) replace workspacePackagesOf with workspacePackagesOrRoot from './workspaces.js' for the directories probed during the walk (the declares helper, the workspacePackagesOf(child) call at line 79, and the seed call at line 85), and in isIndependentProject (lines 20-27), which is called from projectRoot (packages/core/src/fix/process.ts:127) and the ancestor loop in packages/cli/src/failures.ts:204. This prevents a malformed vendored/example package.json or a broken nested pnpm-workspace.yaml from aborting uptide fix with a raw error before the intended NO_PROJECT/NOT_REPOSITORY_ROOT diagnostics.


/**
* 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<string>): 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 <directory>`,
);
return matches[0] ?? cwd;
}
Loading