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
14 changes: 14 additions & 0 deletions .changeset/ai-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"uptide": minor
---

A verified migration pack for the AI SDK (`ai`) 6 → 7: the renamed options (`system` →
`instructions`, `onFinish` → `onEnd`, `onStepFinish` → `onStepEnd`, `experimental_telemetry`
→ `telemetry`), `stepCountIs` → `isStepCount`, `fullStream` → `stream`, `totalUsage` → `usage`
and the removed `experimental_*` options are rewritten by rule; telemetry `metadata`, tool
`context` and the stream result helpers go to the agent with the guide; what the compiler
cannot see (telemetry registration, results that now cover every step, rejected system
messages) is listed for review. Scored against vercel/chatbot and miurla/morphic at the
commit before their own upgrade: no false positive. `uptide pack test` gains
`--update-fixtures`, fixture markers take the compiler's `message`, and ground-truth
repositories can use bun.
12 changes: 12 additions & 0 deletions .changeset/companion-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"uptide": minor
---

`check` and `fix` upgrade a package together with the packages that must move with it,
using the groups `list` already draws (family, peer link, shared pin), each at the version
that agrees with the target. `fix ai` also bumps `@ai-sdk/react`, `@ai-sdk/provider` and the
installed `@ai-sdk/*` providers in one install and one commit; check's plan, the fix summary
and the PR description say which and why, and `fix` stops before changing anything when a
member has no release that agrees. npm and pnpm installs resolve the exact version first,
so a `^` range keeps the version the target pins. Ground-truth entries take `with`, the
packages the real upgrade moved, and `pack test` fails when check would leave one behind.
3 changes: 3 additions & 0 deletions .github/workflows/packs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
cache: pnpm
- if: steps.scope.outputs.run == 'true'
run: pnpm install --frozen-lockfile --ignore-scripts
# Some ground-truth repositories install with bun (scripts off, as every install here).
- uses: oven-sh/setup-bun@v2
if: steps.scope.outputs.run == 'true'
# Ground-truth repositories (shallow, installed with scripts off) and the registry
# tarballs `check` extracts. Keyed on the ground truth: a new repository is fetched once.
- uses: actions/cache@v4
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,16 @@ open a **Pack request** issue, and say you are writing it.
`detect` when its sites can be found in the source. (15 minutes)
4. **Write fixtures**: `fixtures/<case>/before.ts` with each site marked
(`// @uptide <rule>`, `keep` for one it must leave alone) and `after.ts`, what the rules
must produce. `pnpm uptide pack test <package> --fixtures-only` runs them in seconds.
must produce. `pnpm uptide pack test <package> --fixtures-only` runs them in seconds;
`--update-fixtures` writes each `after.ts` from what the rules do, for you to read.
5. **Find ground truth.** Two public repositories that made this upgrade in a commit you
can point at. Pin the commit before it, and draft the expected findings from their own
compiler and their own upgrade commit:
```sh
pnpm packs:truth <package> owner/name <commit-before> <upgrade-commit> --to <version>
```
Add `--also <package>@<version>` for each package the upgrade commit moved together with
it (its lockfile has the versions).
Read each site, drop what is not about the package, and give each its rule (`generic`
when no rule of the pack covers it). (15 minutes)
6. **Score it.**
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)

Uptide finds what a dependency upgrade breaks in your code, migrates it, and proves it with
your compiler and tests. TypeScript repositories; verified migrations for **zod 3 → 4** and
**stripe** today.
your compiler and tests. TypeScript repositories; verified migrations for **zod 3 → 4**,
**stripe** and the **AI SDK (`ai`) 6 → 7** today.

## Quickstart (30 seconds)

Expand Down Expand Up @@ -205,7 +205,7 @@ Every direct dependency can be discovered and selected for analysis. What differ

| Tier | Dependencies | What you get |
| --- | --- | --- |
| **Verified** | zod 3 → 4, stripe 14 and newer | A migration pack: rules written for that dependency, a guide for the agent, behavior checks (zod schemas compared on generated inputs; Stripe changelog filtered to what you call), and ground truth the pack is scored against. `fix` migrates by rule first, by agent for the rest. |
| **Verified** | zod 3 → 4, stripe 14 and newer, ai 6 → 7 | A migration pack: rules written for that dependency, a guide for the agent, behavior checks (zod schemas compared on generated inputs; Stripe changelog filtered to what you call), and ground truth from public repositories the pack is scored against ([docs/packs.md](docs/packs.md)). `fix` migrates by rule first, by agent for the rest. |
| **Generic** | any other dependency | The same analysis without a pack. A finding is called breaking only when your compiler or the runtime probe confirms it, or when it is a `require()` of an ESM-only package or an import of a removed export; everything else is in `--details`. `fix` migrates with the agent alone, under the same verification, and says so in the pull request. |

The tier is on every row of `check`, in the HTML report and in the pull request. A generic
Expand Down
16 changes: 16 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ never fetched as dependencies of one another), findings per member, and one repo
`@aws-sdk/* (3 packages)` with `members` listed. A member with nothing to analyze leaves
the group.

### Companions

A package can also have to move with packages outside its scope (`src/check/companions.ts`).
`ai` and the `@ai-sdk/*` packages pin the same `@ai-sdk/provider`: `ai` 7 next to
`@ai-sdk/react` 3 is an install no real upgrade has, with two copies of the provider's
types. When `check` is asked about one package, the group is the one `list` draws
(`dependencyGroups`: family, peer link, shared pin) among the dependencies installed in its
workspaces, and each member moves to the version that agrees with the target: the version
the target pins exactly; else its newest release that pins or accepts the target; else its
newest release whose exact pins agree with the target's. The report is the lead's
(`ai (3 packages)`) with `companions` (name, from, to, why) and `companionConflicts` (a member
no release agrees with). `fix` installs them together: the version is resolved exactly first
and the range style restored after, so `^4.0.1` stays at the 4.0.1 the target pins; the
lockfile may change inside any of their subtrees and nowhere else. A conflict stops `fix`
before it writes anything.

### One dependency, one decision

A dependency at the same installed version and target in several workspaces is one entry
Expand Down
6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ Everything happens in a temporary clone of your repository, never in your checko
with the stored description.
2. **Upgrades.** Bumps the version in every workspace that declares it (and in pnpm
catalogs), preserving `^`, `~`, or exact ranges. Installs run in a temporary git worktree with lifecycle scripts disabled. The real package manager produces the lockfile; Uptide rejects changes outside the target dependency subtree before copying it back unchanged. One commit.
Packages that must move with it move in the same install and commit, each at the version
that agrees with the target (see "Companions" in [architecture](./architecture.md)):
`fix ai` also bumps `@ai-sdk/react`, `@ai-sdk/provider` and every installed `@ai-sdk/*`
provider, and `check`'s plan, the summary (`With`) and the PR description say which and
why. When one of them has no release that agrees, `fix` stops before changing anything.
3. **Rule-based fixes.** Deterministic rewrites for the changes it has rules for, such as
zod's `required_error` / `invalid_type_error` (add `--include-deprecated` for
`z.string().email()`-style chains). One commit.
Expand Down Expand Up @@ -327,6 +332,7 @@ pnpm uptide pack test --json # every pack, for CI
pnpm uptide pack test ai --offline # cached repositories only
pnpm uptide pack test ai --fixtures-only
pnpm uptide pack test ai --write # record the measured status in verification.json
pnpm uptide pack test ai --fixtures-only --update-fixtures # write after.ts from the rules
```

`pack test` prints precision and recall per rule, overall and for breaking findings, and
Expand Down
33 changes: 27 additions & 6 deletions docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ installed).

- **Detect.** `check` already diffs the two versions' declarations and compiles the
repository against the target. A rule claims the findings whose change kind and symbol
path it matches (`kinds`, `symbols`). For sites no type diff reports, a rule can add
path it matches (`kinds`, `symbols`), and, with `message`, what the compiler said there: a
compiler-only finding's path is just its code (`TS2353`), the message names the property.
The plan files every claimed site under the rule, whether or not it rewrites (`ruleOf`). For sites no type diff reports, a rule can add
`detect(text, file)`, which returns sites found in the source of a file that uses the
package; `check` lists them as the pack's own findings.
- **Rewrite.** `rewrite(text, finding, context)` edits the reported site and nothing else,
Expand Down Expand Up @@ -87,11 +89,14 @@ export const contact = z.string().email(); // @uptide string-format at:.email( p
first non-blank column).
- `kind:<kind>`, `path:<path>`: the change `check` reports there, when the rule matches on
them (default: the rule's first kind, and the `at:` text).
- `message:"..."`: the compiler's message there, for a rule that matches on `message`.

Each marked site of a rule with `rewrite` is rewritten, bottom-up, and the result must equal
`after.ts` byte for byte (the markers are comments, so they stay). A rule or note with
`detect` must find exactly the marked lines in `before.ts`, and nothing on any other line.
Fixtures are test data: biome and `tsc` skip them, and they are never reformatted.
`uptide pack test <package> --fixtures-only --update-fixtures` writes each `after.ts` from
what the rules produce, like a snapshot update: read the diff before committing it.

### Behavior notes: what the compiler cannot see

Expand Down Expand Up @@ -137,6 +142,7 @@ Runtime never downloads instructions: they are checked in, with the date they we
"directory": "web",
"from": "3.25.76",
"to": "4.6.5",
"with": { "@scope/companion": "2.0.1" },
"why": "what this repository exercises",
"findings": [{ "file": "src/schema.ts", "line": 12, "rule": "error-params" }]
}
Expand All @@ -155,17 +161,24 @@ Where the expected findings come from: two sources that are not Uptide.
target version linked where the package is installed and without it. Every diagnostic
new at the target is a site: errors, and deprecations (6385, 6387, what an editor strikes
through). A repository on TypeScript 7, which has no JavaScript API, is compiled with the
TypeScript of this checkout, and the draft says so.
TypeScript of this checkout, and the draft says so. The target is linked with its own
dependencies at the versions it declares, as an install would leave it, and
`--also <package>@<version>` links the companions the upgrade commit moved with it (the
versions in its lockfile): `ai` 7 next to `@ai-sdk/react` 3 is a mix no repository ships,
and its errors are not the upgrade's.
2. **The upgrade commit.** The lines it changed or removed (`git diff -U0 <commit> <upgrade>`,
the old side). One site per construct: a run of changed lines is one site, at its first
line of code, and a run that a diagnostic falls inside (or just after) is that
diagnostic's site. Comment-only lines, pure insertions (no line at `<commit>`), and
the old side). One site per change: consecutive changed lines of the same change are one
site, at the first line of code (a line that is plainly another rule's starts a new
one); a run with a diagnostic inside it, or right after a diagnostic of the same rule, is
that diagnostic's site. Comment-only lines, pure insertions (no line at `<commit>`), and
changes for another package are not sites.

`pnpm packs:truth` drafts both for one repository:

```sh
pnpm packs:truth zod owner/name <commit> <upgrade-commit> --to 4.6.5 [--directory web]
pnpm packs:truth ai vercel/chatbot <commit> <upgrade-commit> --to 7.0.9 \
--also @ai-sdk/react@4.0.10 --also @ai-sdk/provider@4.0.1
```

It prints the compiler's new diagnostics with each line's text, and the changed and inserted
Expand All @@ -177,11 +190,19 @@ source has is a false positive, and it stays one.
`directory` is the project inside the repository when it is not at the root; `from` is the
version its lockfile has at `commit`, and `pack test` says so when it is not.

`with` lists the packages the repository's own upgrade moved together with this one, at the
versions it chose (from its `package.json` at the upgrade commit). `pack test` prints what
`check` moves with the package there and fails when it would leave one of them behind, or
cannot move one consistently: a pack whose `fix` produces an install the real upgrade never
had is not verified. Versions may differ (check picks the release that agrees with `to`);
the names may not.

`fixture` entries (`"fixture": "fixtures/repos/storefront"`) are repositories in this tree.
They are scored like the others and never count toward `verified`.

`uptide pack test` fetches each repository once: a shallow fetch of the pinned commit only,
dependencies installed with lifecycle scripts off, and nothing from the repository is ever
dependencies installed from its lockfile (npm, pnpm, Yarn or bun) with lifecycle scripts
off, and nothing from the repository is ever
executed. They are kept in `~/.cache/uptide/ground-truth` (`UPTIDE_GROUND_TRUTH_CACHE`), so
`pack test --offline` runs without network after the first time. `pnpm packs:fetch` fills
the cache for every pack (and `--corpus` for the repositories in `fixtures/corpus.json`).
Expand Down
27 changes: 17 additions & 10 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import type { CheckReport, CheckResult, FixReport } from '@uptide/core';
import {
activePacks,
formatFix,
isFailure,
PRICE_DATE,
Expand Down Expand Up @@ -42,13 +43,7 @@ import { type Io, type Ui, type UiFlags, uiOf } from './io.js';
import { formatPackTest, packRoot, packsToTest } from './pack.js';
import { PRIVACY } from './privacy.js';
import { createProgress, elapsed, type Progress } from './progress.js';
import {
collectStatus,
declaredDependencies,
formatStatus,
locateDependencies,
SUPPORTED as PACKED,
} from './status.js';
import { collectStatus, declaredDependencies, formatStatus, locateDependencies } from './status.js';
import { createTelemetry, type Telemetry } from './telemetry/client.js';
import { checkMetrics, fixMetrics, listMetrics } from './telemetry/metrics.js';
import { VERSION } from './version.js';
Expand Down Expand Up @@ -712,7 +707,9 @@ ${EXIT_CODES('a plan was made', 'not used', ';\n or a dependency failed to a
.description('Upgrade one dependency on a new branch and migrate your code, verified')
.option(
'--only <package>',
'the dependency to upgrade: zod or stripe (verified), or any other (generic, agent only)',
`the dependency to upgrade: ${activePacks()
.map((pack) => pack.name)
.join(', ')} (verified), or any other (generic, agent only)`,
)
.option(
'--target <spec>',
Expand Down Expand Up @@ -830,7 +827,7 @@ ${PRIVACY}`,
});
// No pack, so no rule: without the agent there is nothing this command can do,
// and it says so before it touches anything.
const hasPack = (PACKED as readonly string[]).includes(only);
const hasPack = activePacks().some((pack) => pack.name === only);
if (!hasPack && (flags.llm === false || !selection.available))
throw noAgentForGeneric(only, flags.llm === false);
if (flags.pinCurrentApi && only !== 'stripe')
Expand Down Expand Up @@ -1229,6 +1226,10 @@ ${EXIT_CODES('scaffolded', 'not used')}`,
.option('--offline', 'never fetch: use only ground-truth repositories already cached')
.option('--fixtures-only', 'run the fixtures, skip the ground-truth repositories')
.option('--write', "record this run's result in the pack's verification.json")
.option(
'--update-fixtures',
"write each fixture's after.ts from what the rules produce; review the diff before committing",
)
.option('--cwd <dir>', 'a directory inside the uptide checkout')
.option('--json', 'machine-readable output for CI')
.option('--no-color', 'no color (NO_COLOR is respected too)')
Expand All @@ -1247,7 +1248,12 @@ ${EXIT_CODES('every pack passed', 'a false positive among breaking findings, a f
.action(
(
name: string | undefined,
flags: Shared & { offline?: boolean; fixturesOnly?: boolean; write?: boolean },
flags: Shared & {
offline?: boolean;
fixturesOnly?: boolean;
write?: boolean;
updateFixtures?: boolean;
},
) =>
act(flags, async ({ ui, cwd }) => {
const root = packRoot(cwd);
Expand All @@ -1258,6 +1264,7 @@ ${EXIT_CODES('every pack passed', 'a false positive among breaking findings, a f
...(flags.offline ? { offline: true } : {}),
...(flags.fixturesOnly ? { fixturesOnly: true } : {}),
...(flags.write ? { write: true } : {}),
...(flags.updateFixtures ? { updateFixtures: true } : {}),
log: (line) => io.err(`${line}\n`),
});
reports.push(report);
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/format-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,39 @@ describe('formatCheck, the first screen', () => {
expect(out).toContain('@aws-sdk/* (2 packages) ');
});

it('names what moves with the package, why, and offers fix for the whole group', () => {
const out = formatCheck(
report([
pkg({
name: 'ai',
installed: '6.0.116',
latest: '7.0.9',
target: '7.0.9',
majorsBehind: 1,
status: 'breaking',
findings: [removed('app/chat.ts', 3)],
members: [
{ name: 'ai', installed: '6.0.116', target: '7.0.9' },
{ name: '@ai-sdk/react', installed: '3.0.118', target: '4.0.10' },
],
companions: [
{
name: '@ai-sdk/react',
from: '3.0.118',
to: '4.0.10',
reason: '@ai-sdk/react 4.0.10 pins ai 7.0.9',
},
],
}),
]),
{ ...shown, fixable: ['ai'] },
);
expect(out).toContain(
'↑ upgrades with @ai-sdk/react 3.0.118 → 4.0.10 @ai-sdk/react 4.0.10 pins ai 7.0.9',
);
expect(out).toContain('npx uptide@next fix ai');
});

it('says where each target came from: the npm dist-tag, or the one asked for', () => {
const latest = formatCheck(report([pkg({ name: 'twilio' })]), shown);
expect(latest).toContain('5.0.1 → 5.3.0 minor · latest on npm');
Expand Down
Loading
Loading