diff --git a/.changeset/ai-pack.md b/.changeset/ai-pack.md new file mode 100644 index 0000000..705dff9 --- /dev/null +++ b/.changeset/ai-pack.md @@ -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. diff --git a/.changeset/companion-upgrades.md b/.changeset/companion-upgrades.md new file mode 100644 index 0000000..aaa35dc --- /dev/null +++ b/.changeset/companion-upgrades.md @@ -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. diff --git a/.github/workflows/packs.yml b/.github/workflows/packs.yml index 2d997c9..67a1ef2 100644 --- a/.github/workflows/packs.yml +++ b/.github/workflows/packs.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f4d7e8..8ba4bc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//before.ts` with each site marked (`// @uptide `, `keep` for one it must leave alone) and `after.ts`, what the rules - must produce. `pnpm uptide pack test --fixtures-only` runs them in seconds. + must produce. `pnpm uptide pack test --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 owner/name --to ``` + Add `--also @` 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.** diff --git a/README.md b/README.md index bab53eb..ffe23cb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 96cb7ac..87335bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index 65e4ab3..825d142 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. @@ -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 diff --git a/docs/packs.md b/docs/packs.md index 6f4ef24..940244f 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -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, @@ -87,11 +89,14 @@ export const contact = z.string().email(); // @uptide string-format at:.email( p first non-blank column). - `kind:`, `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 --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 @@ -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" }] } @@ -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 @` 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 `, - 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 ``), 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 ``), and changes for another package are not sites. `pnpm packs:truth` drafts both for one repository: ```sh pnpm packs:truth zod owner/name --to 4.6.5 [--directory web] +pnpm packs:truth ai vercel/chatbot --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 @@ -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`). diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4f20aa8..a85d2d5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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, @@ -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'; @@ -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 ', - '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 ', @@ -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') @@ -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 ', 'a directory inside the uptide checkout') .option('--json', 'machine-readable output for CI') .option('--no-color', 'no color (NO_COLOR is respected too)') @@ -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); @@ -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); diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index 0cedfe6..5aa27f7 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -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'); diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index 4be7b07..11dddfc 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -281,6 +281,14 @@ function sectionLines(row: Row, colors: Colors): string[] { ` ${colors.yellow('!')} ${plural(sites, 'deprecated call')} (${deprecatedNames(deprecated)}) ${colors.dim(who)}`, ); } + // What moves with it: one install, at versions that agree, the way `fix` upgrades it. + const companions = row.p.companions ?? []; + if (companions.length > 0) + lines.push( + ` ${colors.cyan('↑')} upgrades with ${companions.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')} ${colors.dim(companions.map((c) => c.reason).join('; '))}`, + ); + for (const conflict of row.p.companionConflicts ?? []) + lines.push(` ${colors.red('✗')} ${conflict}: no consistent upgrade`); // An importer the manifest does not show, or one the analysis could not reach: the reader // decides whether the sites above are all of them. for (const note of importers) lines.push(` ${colors.yellow('⚠')} ${note}`); @@ -345,8 +353,9 @@ export function nextCommands(rows: Row[], opts: FormatCheckOptions): [string, st opts.fixable === true || (Array.isArray(opts.fixable) && opts.fixable.includes(name)); for (const row of rows) { const name = row.p.name; - // A release group is several packages: fix takes one. - if (!fixable(name) || row.p.members) continue; + // A release group is several packages: fix takes one, unless the group is the package + // and what moves with it, which `fix` upgrades together. + if (!fixable(name) || (row.p.members && !row.p.companions)) continue; if (commands.length >= FIX_SUGGESTIONS) break; const target = opts.repeat?.targets?.[name]; const pinned = target ? ` --target ${quote(target)}` : ''; diff --git a/packages/cli/src/format-fix.ts b/packages/cli/src/format-fix.ts index c293563..9c50d15 100644 --- a/packages/cli/src/format-fix.ts +++ b/packages/cli/src/format-fix.ts @@ -41,6 +41,11 @@ export function formatFixSummary(report: FixReport, opts: FormatFixOptions = {}) ['Tests', cells.tests], ] as const ).map(([label, text]) => ` ${pad(label, 9)} ${plain(text)}`), + ...(report.companions?.length + ? [ + ` ${pad('With', 9)} ${report.companions.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')}`, + ] + : []), // Without a pack the reader is told once, here, what stands behind the edits. ...(report.tier === 'generic' && report.sites.some((s) => s.outcome === 'agent') ? [ diff --git a/packages/cli/src/pack.ts b/packages/cli/src/pack.ts index 8f822fb..0a6f90c 100644 --- a/packages/cli/src/pack.ts +++ b/packages/cli/src/pack.ts @@ -82,7 +82,8 @@ export function formatPackTest(report: PackTestReport, color = false): string { for (const s of f.falseNegatives) out.push(` ${c.red('✗')} ${s.file}:${s.line} ${s.rule} marked, not detected`); - if (report.repos.length === 0) + if (report.fixturesOnly) out.push('ground truth skipped (--fixtures-only)'); + else if (report.repos.length === 0) out.push('ground truth no repository yet: the pack ships as a candidate'); else { out.push('ground truth'); @@ -96,6 +97,9 @@ export function formatPackTest(report: PackTestReport, color = false): string { `${head} predicted ${r.predicted.length}, expected ${r.expected.length}, false positives ${r.falsePositives.length}, false negatives ${r.falseNegatives.length}`, ); if (r.installedMismatch) out.push(` ${c.yellow('!')} ${r.installedMismatch}`); + if (r.companions?.length) + out.push(` with ${r.companions.map((m) => `${m.name} ${m.from} → ${m.to}`).join(', ')}`); + for (const left of r.leftBehind ?? []) out.push(` ${c.red('✗')} ${left}`); } out.push(''); out.push( diff --git a/packages/cli/src/readme.test.ts b/packages/cli/src/readme.test.ts index b9069c1..8032935 100644 --- a/packages/cli/src/readme.test.ts +++ b/packages/cli/src/readme.test.ts @@ -52,7 +52,7 @@ describe('README', () => { }); it('explains the support tiers, verification, privacy and exit codes', () => { - expect(readme).toMatch(/\| \*\*Verified\*\* \| zod 3 → 4, stripe 14 and newer \|/); + expect(readme).toMatch(/\| \*\*Verified\*\* \| zod 3 → 4, stripe 14 and newer, ai 6 → 7 \|/); expect(readme).toMatch(/\| \*\*Generic\*\* \| any other dependency/); for (const heading of ['## How verification works', '## Privacy', '## Documentation']) expect(readme).toContain(heading); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index 914b0d2..c3dc905 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -35,6 +35,7 @@ import type { MigrationPack } from '../packs/types.js'; import { diffRuntime, probeRuntime } from '../runtime/runtime.js'; import { resetSharedState } from '../shared-state.js'; import { requireFindUsages } from './capabilities.js'; +import { type CompanionPlan, companionsOf, type InstalledDependency } from './companions.js'; import { arbitrateUnchecked } from './file-kind.js'; import { groupName, releaseGroups } from './groups.js'; import { importedByText, importFileCounts } from './importers.js'; @@ -116,6 +117,11 @@ export interface CheckOptions { hardDeadline?: number; /** Set by `check` for its workspace jobs: the dependencies with something to analyze. */ behind?: string[]; + /** + * Set by `check`: for each package asked for by name, what moves with it (its group, at the + * versions that agree with its target). Analyzed as one group led by it, as `fix` upgrades. + */ + companions?: Record; /** * The packs to use instead of the verified ones (`uptide pack test` passes a candidate). * Packs are code, which no worker receives: with this set, workspaces are checked in this @@ -156,7 +162,7 @@ export async function check(opts: CheckOptions): Promise { const root: RepoDir = { dir: opts.cwd }; const workspaces = (await adapter.workspacePackages?.(root)) ?? ['.']; - const ctx: Ctx = { adapter, fetcher: memoizingFetcher(fetcher), cache, findUsages, opts }; + const memo = memoizingFetcher(fetcher); const installedByWorkspace: Record> = {}; const catalogByWorkspace: Record = {}; for (const workspace of workspaces) { @@ -167,6 +173,19 @@ export async function check(opts: CheckOptions): Promise { .filter(([, spec]) => spec.startsWith('catalog:')) .map(([name]) => name); } + // What moves with each package asked for by name, analyzed with it at the versions that + // agree with its target: the upgrade `fix` makes, not one no install ever produces. + const plans = await companionPlans(opts, memo, adapter, workspaces, installedByWorkspace); + const moving = Object.values(plans).flatMap((p) => p.companions); + if (opts.only && moving.length > 0) + opts = { + ...opts, + only: [...new Set([...opts.only, ...moving.map((c) => c.name)])], + targets: { ...Object.fromEntries(moving.map((c) => [c.name, c.to])), ...opts.targets }, + companions: plans, + }; + else if (Object.keys(plans).length > 0) opts = { ...opts, companions: plans }; + const ctx: Ctx = { adapter, fetcher: memo, cache, findUsages, opts }; // Who imports what, whether or not they declare it: the candidates are the packages asked // for, or every package some workspace declares. const candidates = opts.only ?? [ @@ -819,13 +838,25 @@ async function checkWorkspace(ctx: Ctx, job: WorkspaceJob): Promise ({ name, installed: version, dependsOn: dependsOnOf(installedPackageDirOf(adapter, repo, name, version)?.dir), })), ); + // A package asked for by name leads its companions' group, and absorbs any release group + // one of them was in: one overlay with every target linked. + for (const [lead, plan] of Object.entries(opts.companions ?? {})) { + const members = [lead, ...plan.companions.map((c) => c.name)].filter((n) => + analyzable.some(([name]) => name === n), + ); + if (!members.includes(lead) || members.length < 2) continue; + for (const g of groups) + if (g.some((m) => members.includes(m))) + for (const m of g) if (!members.includes(m)) members.push(m); + groups = [...groups.filter((g) => !g.some((m) => members.includes(m))), members]; + } const groupOf = new Map(groups.flatMap((g) => g.map((m) => [m, g] as const))); const done = new Set(); // The analysis is CPU-bound on one thread: six at once finish together, late, and each @@ -955,9 +986,76 @@ interface RunFetcher extends PackageFetcher { * per package, and one extraction per `name@version`, however many workspaces or target * dependency graphs need it. Extracted directories live until the run ends. */ +/** + * For each package `check` was asked about by name, what has to move with it: its group as + * `list` draws it, at the versions that agree with its target (`companions.ts`). From the + * manifests installed in the workspaces that declare it, and one packument per candidate. + */ +async function companionPlans( + opts: CheckOptions, + fetcher: RunFetcher, + adapter: LanguageAdapter, + workspaces: string[], + installedByWorkspace: Record>, +): Promise> { + const plans: Record = {}; + const manifests = fetcher.manifests; + if (!opts.only || !manifests) return plans; + const linked = (version: string) => /^(link|workspace|file):/.test(version); + for (const lead of opts.only) { + const declaring = workspaces.filter((w) => { + const version = installedByWorkspace[w]?.[lead]; + return version !== undefined && !linked(version); + }); + if (declaring.length === 0) continue; + const target = + opts.targets?.[lead] ?? (await fetcher.resolve(lead, 'latest').catch(() => undefined)); + if (!target) continue; + const installed = new Map(); + for (const workspace of declaring) + for (const [name, version] of Object.entries(installedByWorkspace[workspace] ?? {})) { + if (linked(version)) continue; + const known = installed.get(`${name}@${version}`); + if (known) { + known.workspaces.push(workspace); + continue; + } + const dir = installedPackageDirOf( + adapter, + { dir: resolve(opts.cwd, workspace) }, + name, + version, + )?.dir; + let manifest = {}; + try { + if (dir) manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + } catch { + // Unreadable: it can still be named in another package's manifest. + } + installed.set(`${name}@${version}`, { name, version, manifest, workspaces: [workspace] }); + } + try { + const plan = await companionsOf({ + name: lead, + target, + installed: [...installed.values()], + manifests: (name) => manifests(name), + }); + if (plan.companions.length > 0 || plan.conflicts.length > 0) plans[lead] = plan; + } catch { + // A registry that cannot answer leaves the package alone, as before. + } + } + return plans; +} + function memoizingFetcher(inner: PackageFetcher): RunFetcher { const resolved = new Map>(); const versions = new Map>(); + const manifests = new Map< + string, + Promise>>> + >(); const fetched = new Map>(); const once = ( map: Map>, @@ -981,6 +1079,12 @@ function memoizingFetcher(inner: PackageFetcher): RunFetcher { once(versions, name, () => inner.versions?.(name) ?? Promise.resolve([])), } : {}), + ...(inner.manifests + ? { + manifests: (name: string) => + once(manifests, name, () => inner.manifests?.(name) ?? Promise.resolve({})), + } + : {}), async dispose() { for (const dir of fetched.values()) { await dir.then((pkg) => releasePackage(inner, pkg)).catch(() => undefined); @@ -1355,7 +1459,12 @@ async function checkGroup( // A member with nothing to analyze (up to date, unused, unanalyzable) leaves the group; a lone package reports as itself. if (prepared.length === 0) return early.length === 1 ? (early[0] as PackageReport) : mergeEarly(workspace, early); - const name = groupName(prepared.map((p) => p.name)); + // Led by the package asked for, when its companions are in the group: it names the report, + // and its versions are the upgrade's. + const plans = ctx.opts.companions ?? {}; + const leader = prepared.find((p) => plans[p.name] !== undefined && prepared.length > 1); + const plan = leader ? plans[leader.name] : undefined; + const name = leader?.name ?? groupName(prepared.map((p) => p.name)); const notes = prepared.flatMap((p) => prepared.length > 1 ? p.notes.map((n) => `${p.name}: ${n}`) : p.notes, ); @@ -1375,9 +1484,12 @@ async function checkGroup( })), } : {}), - installed: installed[0] as string, - latest: (prepared.length === 1 ? prepared[0]?.latest : targets.at(-1)) as string, - target: targets.at(-1) as string, + installed: (leader?.installedVersion ?? installed[0]) as string, + latest: (leader?.latest ?? + (prepared.length === 1 ? prepared[0]?.latest : targets.at(-1))) as string, + target: (leader?.target ?? targets.at(-1)) as string, + ...(plan?.companions.length ? { companions: plan.companions } : {}), + ...(plan?.conflicts.length ? { companionConflicts: plan.conflicts } : {}), majorsBehind: Math.max(...prepared.map((p) => majorsBehind(p.installedVersion, p.target))), findings: [], callSitesChecked: 0, @@ -1791,7 +1903,8 @@ async function checkGroup( findings.push(...arbitrated); // A pack can see what no type diff shows: a client the SDK bump reconfigures at runtime. const pack = packsOf(ctx.opts).find((candidate) => candidate.name === name); - const only = prepared.length === 1 ? prepared[0] : undefined; + // The pack speaks for the package it covers: alone, or leading its companions. + const only = leader ?? (prepared.length === 1 ? prepared[0] : undefined); const packDirs = (p: Prepared) => { const target = fetched.find((f) => f.p === p); return { @@ -1823,7 +1936,12 @@ async function checkGroup( } } // Without a pack, only what the compiler or the runtime probe confirms is called breaking. - const tier = tierOf(packsOf(ctx.opts), name, installed[0] as string, targets.at(-1) as string); + const tier = tierOf( + packsOf(ctx.opts), + name, + (leader?.installedVersion ?? installed[0]) as string, + (leader?.target ?? targets.at(-1)) as string, + ); const evidence = (f: Finding): Finding['evidence'] => { const p = runtimeOf(f); return evidenceOf(f, p?.runtime, p ? loadRootOf(p) : undefined); diff --git a/packages/core/src/check/companions.test.ts b/packages/core/src/check/companions.test.ts new file mode 100644 index 0000000..44210da --- /dev/null +++ b/packages/core/src/check/companions.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { companionsOf, type InstalledDependency } from './companions.js'; + +type Versions = Record< + string, + { dependencies?: Record; peerDependencies?: Record } +>; + +/** The registry as an AI SDK 6 → 7 upgrade sees it: the packages pin the same provider and provider-utils. */ +const registry: Record = { + ai: { + '6.0.116': { + dependencies: { '@ai-sdk/provider': '3.0.3', '@ai-sdk/provider-utils': '4.0.5' }, + }, + '7.0.9': { + dependencies: { '@ai-sdk/provider': '4.0.1', '@ai-sdk/provider-utils': '5.0.1' }, + }, + }, + '@ai-sdk/provider': { '3.0.3': {}, '4.0.1': {}, '4.0.2': {} }, + '@ai-sdk/react': { + '3.0.118': { dependencies: { ai: '6.0.116', '@ai-sdk/provider-utils': '4.0.5' } }, + '4.0.10': { dependencies: { ai: '7.0.9', '@ai-sdk/provider-utils': '5.0.1' } }, + '4.0.11': { dependencies: { ai: '7.0.10', '@ai-sdk/provider-utils': '5.0.2' } }, + }, + '@ai-sdk/openai': { + '3.0.10': { dependencies: { '@ai-sdk/provider': '3.0.3' } }, + '4.0.5': { dependencies: { '@ai-sdk/provider': '4.0.1' } }, + '4.0.6': { dependencies: { '@ai-sdk/provider': '4.0.2' } }, + '5.0.0-beta.1': { dependencies: { '@ai-sdk/provider': '4.0.1' } }, + }, + zod: { '3.25.76': {}, '4.6.5': {} }, +}; +const manifests = async (name: string) => registry[name] ?? {}; +const at = (name: string, version: string, workspaces = ['.']): InstalledDependency => ({ + name, + version, + manifest: { name, version, ...registry[name]?.[version] }, + workspaces, +}); +const repository = [ + at('ai', '6.0.116'), + at('@ai-sdk/provider', '3.0.3'), + at('@ai-sdk/react', '3.0.118'), + at('@ai-sdk/openai', '3.0.10'), + at('zod', '3.25.76'), +]; + +describe('companionsOf', () => { + it('moves every installed @ai-sdk package with ai to the versions that agree with it', async () => { + // Before groups, `fix ai` moved ai alone: ai 7 next to @ai-sdk/react 3 and @ai-sdk/openai 3, + // two copies of @ai-sdk/provider, and an install no lockfile of the real upgrade has. + const plan = await companionsOf({ + name: 'ai', + target: '7.0.9', + installed: repository, + manifests, + }); + expect(plan.conflicts).toEqual([]); + expect(plan.companions).toEqual([ + { + name: '@ai-sdk/openai', + from: '3.0.10', + to: '4.0.5', + reason: '@ai-sdk/openai 4.0.5 pins @ai-sdk/provider 4.0.1, as ai 7.0.9 does', + }, + { + name: '@ai-sdk/provider', + from: '3.0.3', + to: '4.0.1', + reason: 'ai 7.0.9 pins @ai-sdk/provider 4.0.1', + }, + { + name: '@ai-sdk/react', + from: '3.0.118', + to: '4.0.10', + reason: '@ai-sdk/react 4.0.10 pins ai 7.0.9', + }, + ]); + }); + + it('leaves a package with no companion installed alone', async () => { + const plan = await companionsOf({ + name: 'zod', + target: '4.6.5', + installed: repository, + manifests, + }); + expect(plan).toEqual({ companions: [], conflicts: [] }); + }); + + it('reports a member with no release that agrees, instead of moving the package alone', async () => { + const plan = await companionsOf({ + name: 'ai', + target: '7.0.9', + installed: repository, + manifests: async (name) => + name === '@ai-sdk/react' + ? { '3.0.118': registry['@ai-sdk/react']?.['3.0.118'] ?? {} } + : manifests(name), + }); + expect(plan.conflicts).toEqual([ + '@ai-sdk/react 3.0.118 has no release that agrees with ai 7.0.9', + ]); + }); + + it('only groups packages installed in a workspace that has the package', async () => { + const plan = await companionsOf({ + name: 'ai', + target: '7.0.9', + installed: [ + at('ai', '6.0.116', ['apps/web']), + at('@ai-sdk/react', '3.0.118', ['apps/web']), + at('@ai-sdk/openai', '3.0.10', ['apps/docs']), + ], + manifests, + }); + expect(plan.companions.map((c) => c.name)).toEqual(['@ai-sdk/react']); + }); +}); + +describe('companionsOf, a member that already agrees', () => { + it('stays where it is', async () => { + // @modelcontextprotocol/sdk takes zod `^3.25 || ^4.0`: zod 4 needs nothing else to move. + const versions: Versions = { + '1.28.0': { + dependencies: { zod: '^3.25 || ^4.0' }, + peerDependencies: { zod: '^3.25 || ^4.0' }, + }, + '1.32.1': { + dependencies: { zod: '^3.25 || ^4.0' }, + peerDependencies: { zod: '^3.25 || ^4.0' }, + }, + }; + const plan = await companionsOf({ + name: 'zod', + target: '4.6.5', + installed: [ + at('zod', '3.25.76'), + { + name: '@modelcontextprotocol/sdk', + version: '1.28.0', + manifest: { name: '@modelcontextprotocol/sdk', version: '1.28.0', ...versions['1.28.0'] }, + workspaces: ['.'], + }, + ], + manifests: async (name) => + name === '@modelcontextprotocol/sdk' ? versions : manifests(name), + }); + expect(plan).toEqual({ companions: [], conflicts: [] }); + }); +}); diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts new file mode 100644 index 0000000..0dd0be8 --- /dev/null +++ b/packages/core/src/check/companions.ts @@ -0,0 +1,205 @@ +import { satisfies } from '../fetch/range.js'; +import type { Manifest } from '../list/evidence.js'; +import { dependencyGroups } from '../list/groups.js'; +import type { ListedDependency } from '../list/list.js'; +import { compareVersions, parseVersion } from './version.js'; + +/** + * The packages that have to move with one upgrade. `ai` and the `@ai-sdk/*` packages a + * repository installs pin the same `@ai-sdk/provider`: `ai` 7 next to `@ai-sdk/react` 3 is + * an install no lockfile of the upgrade has, with two copies of the provider's types. The + * group is the one `list` shows (`dependencyGroups`: family, peer link, shared pin); the + * version each companion moves to is the one that agrees with the target: + * - the version the target pins exactly (`ai@7.0.9` → `@ai-sdk/provider` 4.0.1); + * - else the installed one, when its range for the target already accepts it + * (`@modelcontextprotocol/sdk` 1.28.0 takes `zod` `^3.25 || ^4.0`: nothing to move); + * - else its newest release that pins the target exactly, or whose range for it accepts + * the target (`@ai-sdk/react@4.0.10` → `ai` 7.0.9); + * - else its newest release whose exact pins agree with every exact pin of the target + * (`@ai-sdk/openai` on `@ai-sdk/provider`). + */ +export interface Companion { + name: string; + from: string; + to: string; + /** Why this version: `ai 7.0.9 pins @ai-sdk/provider 4.0.1`. */ + reason: string; +} + +export interface InstalledDependency { + name: string; + version: string; + /** The installed copy's manifest: what it depends on and pins today. */ + manifest: Manifest; + workspaces: string[]; +} + +export interface CompanionPlan { + companions: Companion[]; + /** Members of the group with no release that agrees: the upgrade cannot be consistent. */ + conflicts: string[]; + /** The group as `list` names it (`@ai-sdk family, shared @ai-sdk/provider-utils`). */ + reason?: string; +} + +type Manifests = Record>; + +const EXACT = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/; +const scopeOf = (name: string): string | undefined => + name.startsWith('@') && !name.startsWith('@types/') ? name.split('/')[0] : undefined; +const stable = (version: string): boolean => { + const parsed = parseVersion(version); + return parsed !== undefined && parsed.pre === undefined; +}; +const accepts = (range: string, version: string): boolean => { + try { + return satisfies(version, range); + } catch { + return false; + } +}; +const exactPins = (m: Pick): Map => + new Map(Object.entries(m.dependencies ?? {}).filter(([, v]) => EXACT.test(v))); + +/** Whether `other` is plausibly in `lead`'s group, from what is installed: worth asking the registry about. */ +function related(lead: InstalledDependency, other: InstalledDependency): boolean { + if (!lead.workspaces.some((w) => other.workspaces.includes(w))) return false; + const mentions = (m: Manifest, name: string) => + name in (m.dependencies ?? {}) || name in (m.peerDependencies ?? {}); + if (mentions(lead.manifest, other.name) || mentions(other.manifest, lead.name)) return true; + const theirs = exactPins(other.manifest); + for (const [dep, version] of exactPins(lead.manifest)) + if (theirs.get(dep) === version) return true; + return false; +} + +/** The release of `name` that agrees with `lead@target`, and why; undefined when none does. */ +function agreeing( + installed: InstalledDependency, + lead: string, + target: string, + targetManifest: Pick, + versions: Manifests, +): { version: string; reason: string } | undefined { + const name = installed.name; + const pinned = targetManifest.dependencies?.[name]; + if (pinned && EXACT.test(pinned) && versions[pinned]) + return { version: pinned, reason: `${lead} ${target} pins ${name} ${pinned}` }; + const current = versions[installed.version] ?? installed.manifest; + const range = current.dependencies?.[lead] ?? current.peerDependencies?.[lead]; + if (range !== undefined && accepts(range, target)) + return { + version: installed.version, + reason: `${name} ${installed.version} accepts ${lead} ${target}`, + }; + const candidates = Object.keys(versions).filter(stable).sort(compareVersions).reverse(); + for (const version of candidates) { + const m = versions[version] ?? {}; + const range = m.dependencies?.[lead] ?? m.peerDependencies?.[lead]; + if (range === undefined || !accepts(range, target)) continue; + return { + version, + reason: EXACT.test(range) + ? `${name} ${version} pins ${lead} ${target}` + : `${name} ${version} accepts ${lead} ${target} (${range})`, + }; + } + const leadPins = exactPins(targetManifest); + for (const version of candidates) { + const pins = exactPins(versions[version] ?? {}); + const shared = [...pins].filter(([dep]) => leadPins.has(dep)); + if (shared.length > 0 && shared.every(([dep, v]) => leadPins.get(dep) === v)) + return { + version, + reason: `${name} ${version} pins ${shared.map(([dep, v]) => `${dep} ${v}`).join(', ')}, as ${lead} ${target} does`, + }; + } + return undefined; +} + +/** + * What moves with `name@target` in a repository, from its installed dependencies and the + * registry's manifests of every version (`manifests`, one cached packument per package). + */ +export async function companionsOf(input: { + name: string; + target: string; + installed: InstalledDependency[]; + manifests: (name: string) => Promise; +}): Promise { + const lead = input.installed.find((d) => d.name === input.name); + if (!lead) return { companions: [], conflicts: [] }; + const targetManifest = (await input.manifests(input.name))[input.target]; + if (!targetManifest) return { companions: [], conflicts: [] }; + // Plausible members: related to the lead, then the rest of their families. + const direct = input.installed.filter((d) => d.name !== lead.name && related(lead, d)); + const families = new Set(direct.map((d) => scopeOf(d.name)).filter(Boolean)); + const scope = scopeOf(lead.name); + if (scope) families.add(scope); + const candidates = input.installed.filter( + (d) => + d.name !== lead.name && + (direct.includes(d) || + (families.has(scopeOf(d.name)) && d.workspaces.some((w) => lead.workspaces.includes(w)))), + ); + if (candidates.length === 0) return { companions: [], conflicts: [] }; + + const moves = new Map(); + for (const c of candidates) + moves.set( + c.name, + agreeing(c, lead.name, input.target, targetManifest, await input.manifests(c.name)), + ); + + // The group, as `list` draws it, with each member at the version that agrees. + const listed = (d: InstalledDependency, latest: string): ListedDependency => + ({ + name: d.name, + current: d.version, + latest, + workspaces: d.workspaces, + classification: 'used', + reasons: [], + // The package being upgraded leads the group, as the one `list` shows used most. + usage: { files: d === lead ? 1 : 0, callSites: 0, references: 0, workspaces: d.workspaces }, + }) as unknown as ListedDependency; + const packages = [ + listed(lead, input.target), + ...candidates.map((c) => listed(c, moves.get(c.name)?.version ?? c.version)), + ]; + const metadata = new Map(input.installed.map((d) => [d.name, [d.manifest]])); + const targets = new Map([[lead.name, targetManifest]]); + for (const c of candidates) { + const move = moves.get(c.name); + const manifest = move && (await input.manifests(c.name))[move.version]; + if (manifest) targets.set(c.name, manifest); + } + const group = dependencyGroups(packages, metadata, targets).find((g) => + g.members.some((m) => m.name === lead.name), + ); + if (!group) return { companions: [], conflicts: [] }; + const companions: Companion[] = []; + const conflicts: string[] = []; + for (const member of group.members) { + if (member.name === lead.name) continue; + const move = moves.get(member.name); + if (!move) { + conflicts.push( + `${member.name} ${member.current} has no release that agrees with ${lead.name} ${input.target}`, + ); + continue; + } + if (move.version === member.current) continue; + companions.push({ + name: member.name, + from: member.current, + to: move.version, + reason: move.reason, + }); + } + return { + companions: companions.sort((a, b) => a.name.localeCompare(b.name)), + conflicts, + ...(group.reason ? { reason: group.reason } : {}), + }; +} diff --git a/packages/core/src/domain/io.ts b/packages/core/src/domain/io.ts index 5e0fc7e..e22abeb 100644 --- a/packages/core/src/domain/io.ts +++ b/packages/core/src/domain/io.ts @@ -19,6 +19,15 @@ export interface PackageFetcher { bin?: string | Record; }>; versions?(name: string): Promise; + /** Every published version's dependencies and peers. Optional: without it no companion moves with a package. */ + manifests?( + name: string, + ): Promise< + Record< + string, + { dependencies?: Record; peerDependencies?: Record } + > + >; /** * Done with a fetched directory. A fetcher that extracts into a persistent cache keeps it; * without this method the caller removes the directory itself. diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index b9186bf..b86b1dd 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -117,6 +117,13 @@ export interface PackageReport { /** A dependency, or a release group (`@aws-sdk/*`) upgraded together; `members` lists the group. */ name: string; members?: { name: string; installed: string; target: string }[]; + /** + * What moves with the package `check` was asked about (`check/companions.ts`): its group at + * the versions that agree with its target, each with why; `fix` upgrades them together. + */ + companions?: { name: string; from: string; to: string; reason: string }[]; + /** Members of that group with no release that agrees with the target. */ + companionConflicts?: string[]; /** Set when the entry merges several workspaces (`workspace` is then `*`). */ workspaces?: string[]; /** diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index fe2c3fe..924b75c 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -91,6 +91,7 @@ export type ErrorCode = | 'NO_PROJECT' | 'TYPESCRIPT_UNAVAILABLE' | 'INSTALL_FAILED' + | 'INCONSISTENT_UPGRADE' | 'PACKAGE_MANAGER_UNAVAILABLE' | 'PACKAGE_MANAGER_VERSION' | 'LOCKFILE_OUT_OF_SCOPE' diff --git a/packages/core/src/fetch/npm-fetcher.ts b/packages/core/src/fetch/npm-fetcher.ts index c9adafb..1fe3c3b 100644 --- a/packages/core/src/fetch/npm-fetcher.ts +++ b/packages/core/src/fetch/npm-fetcher.ts @@ -11,6 +11,7 @@ import { authHeaders, loadRegistryConfig, type RegistryConfig, registryFor } fro import { downloadTarball, type FetchFn, + listManifests, listVersions, manifestUrl, packumentUrl, @@ -176,6 +177,11 @@ export function createNpmFetcher(opts: NpmFetcherOptions = {}): PackageFetcher { listVersions(name, config, fetchFn), ); }, + async manifests(name) { + return remembered([...packagePathSegments(name), 'manifests'], false, () => + listManifests(name, config, fetchFn), + ); + }, async fetch(name, version): Promise { const target = await resolved(name, version); // The tarball is verified on every fetch, cached or not; the extraction is reused only then. diff --git a/packages/core/src/fetch/range.ts b/packages/core/src/fetch/range.ts index bfe5cca..7b495ed 100644 --- a/packages/core/src/fetch/range.ts +++ b/packages/core/src/fetch/range.ts @@ -13,3 +13,8 @@ export function maxSatisfying(versions: readonly string[], range: string): strin export function minVersion(range: string): string | undefined { return semver.validRange(range) ? (semver.minVersion(range)?.version ?? undefined) : undefined; } + +/** Whether `range` is a semver range at all (`npm:`, `workspace:` and URLs are not). */ +export function validRange(range: string): boolean { + return semver.validRange(range) !== null; +} diff --git a/packages/core/src/fetch/registry.ts b/packages/core/src/fetch/registry.ts index 610a9e1..586e13f 100644 --- a/packages/core/src/fetch/registry.ts +++ b/packages/core/src/fetch/registry.ts @@ -210,3 +210,36 @@ export async function listVersions( .filter((v) => parseVersion(v) !== undefined) .sort(compareVersions); } + +/** What one version declares, as the abbreviated packument carries it. */ +export interface VersionDependencies { + dependencies?: Record; + peerDependencies?: Record; +} + +/** + * Every published version's dependencies and peer dependencies, from the same abbreviated + * packument `listVersions` reads: one request answers which release of a package pins what. + */ +export async function listManifests( + name: string, + config: RegistryConfig, + fetchFn: FetchFn, +): Promise> { + const registry = registryFor(name, config); + const packument = await getJson( + packumentUrl(name, registry), + config, + fetchFn, + ); + if (!packument) throw new PackageNotFoundError(name, registry); + const out: Record = {}; + for (const [version, manifest] of Object.entries(packument.versions ?? {})) { + if (parseVersion(version) === undefined) continue; + out[version] = { + ...(manifest.dependencies ? { dependencies: manifest.dependencies } : {}), + ...(manifest.peerDependencies ? { peerDependencies: manifest.peerDependencies } : {}), + }; + } + return out; +} diff --git a/packages/core/src/fix/generic.test.ts b/packages/core/src/fix/generic.test.ts index 07b24df..162e50f 100644 --- a/packages/core/src/fix/generic.test.ts +++ b/packages/core/src/fix/generic.test.ts @@ -294,3 +294,64 @@ describe('fix for a dependency without a pack', () => { expect(result.sites).toEqual([]); }, 20000); }); + +describe('fix with companions', () => { + /** `paint` with `brush` installed beside it: check says brush has to move with paint 2. */ + function withBrush(conflicts: string[] = []) { + const fixture = paintFixture(); + const { root, report, services } = fixture; + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + manifest.dependencies.brush = '1.0.0'; + writeFileSync(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + git(root, 'commit', '-am', 'brush'); + const lead = report.packages[0] as CheckReport['packages'][number]; + lead.companions = [ + { name: 'brush', from: '1.0.0', to: '2.0.0', reason: 'brush 2.0.0 pins paint 2.0.0' }, + ]; + if (conflicts.length) lead.companionConflicts = conflicts; + const installs: Parameters[1][] = []; + const install = services.install; + services.install = async (dir, upgrade) => { + installs.push(upgrade); + await install(dir, upgrade); + }; + return { ...fixture, installs }; + } + + it('installs the package and every companion in one step, and says so', async () => { + // Moving paint alone would leave brush 1 pinned to paint 1: the install only + // agrees when both move. + const { root, services, fixer, installs } = withBrush(); + const result = await fix({ cwd: root, only: 'paint', fixer: fixer(0.01) }, services); + expect(installs).toHaveLength(1); + expect(installs[0]).toMatchObject({ + name: 'paint', + version: '2.0.0', + also: [{ name: 'brush', version: '2.0.0' }], + }); + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + expect(manifest.dependencies).toEqual({ paint: '2.0.0', brush: '2.0.0' }); + expect(git(root, 'log', '--format=%s', '-n', '1', 'HEAD~1')).toBe( + 'chore: upgrade paint to 2.0.0 with brush 2.0.0', + ); + expect(result.companions).toEqual([ + { name: 'brush', from: '1.0.0', to: '2.0.0', reason: 'brush 2.0.0 pins paint 2.0.0' }, + ]); + expect(prBody(result)).toContain( + 'Upgraded with paint, so the install stays consistent:\n\n- `brush` 1.0.0 → 2.0.0 (brush 2.0.0 pins paint 2.0.0)', + ); + }, 20000); + + it('changes nothing when a companion has no version that agrees', async () => { + const { root, services, fixer, installs } = withBrush([ + 'brush 1.0.0 has no release that agrees with paint 2.0.0', + ]); + const head = git(root, 'rev-parse', 'HEAD'); + await expect( + fix({ cwd: root, only: 'paint', fixer: fixer(0.01) }, services), + ).rejects.toMatchObject({ code: 'INCONSISTENT_UPGRADE' }); + expect(installs).toEqual([]); + expect(git(root, 'rev-parse', 'HEAD')).toBe(head); + expect(git(root, 'status', '--porcelain')).toBe(''); + }); +}); diff --git a/packages/core/src/fix/isolate.ts b/packages/core/src/fix/isolate.ts index ce923d9..7d81b75 100644 --- a/packages/core/src/fix/isolate.ts +++ b/packages/core/src/fix/isolate.ts @@ -17,6 +17,7 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; import { workspacePackagesOf } from '../adapters/typescript/repo.js'; import { UptideError } from '../errors.js'; import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js'; +import { activePack } from '../packs/index.js'; import { uptideVersionInfo } from '../version.js'; import { git, projectRoot } from './process.js'; import { prBody } from './report.js'; @@ -456,7 +457,7 @@ export async function isolatedFix( throw new Error('--max-cost must be a positive finite amount in USD'); if ( !options.fixer && - !['zod', 'stripe'].includes(options.only) && + !activePack(options.only) && !options.pack?.rules.length && (options.fixer === null || !selected.available) ) diff --git a/packages/core/src/fix/managers/lock-guard.ts b/packages/core/src/fix/managers/lock-guard.ts index 7a025fb..ab21bc2 100644 --- a/packages/core/src/fix/managers/lock-guard.ts +++ b/packages/core/src/fix/managers/lock-guard.ts @@ -10,6 +10,10 @@ export interface LockGraph { metadata: unknown; } const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; +/** What an upgrade moves: one package, or a package and the companions that move with it. */ +export type Targets = string | readonly string[]; +export const targetNames = (targets: Targets): readonly string[] => + typeof targets === 'string' ? [targets] : targets; function stable(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; if (value && typeof value === 'object') @@ -21,12 +25,13 @@ function stable(value: unknown): string { } export function withoutTarget( value: Record, - target: string, + target: Targets, ): Record { const result = structuredClone(value); for (const section of sections) { if (result[section] && typeof result[section] === 'object') - delete (result[section] as Record)[target]; + for (const name of targetNames(target)) + delete (result[section] as Record)[name]; } return result; } @@ -37,10 +42,11 @@ export interface LockDiff { allowed: string[]; } /** Compare entries, not lines: formatting changes cannot disguise an unrelated resolution. */ -export function assertLockScope(before: LockGraph, after: LockGraph, target: string): LockDiff { +export function assertLockScope(before: LockGraph, after: LockGraph, target: Targets): LockDiff { + const names = targetNames(target); const allowed = new Set(); for (const graph of [before, after]) { - const queue = [...graph.records].filter(([, r]) => r.name === target).map(([k]) => k); + const queue = [...graph.records].filter(([, r]) => names.includes(r.name)).map(([k]) => k); const visited = new Set(); for (const key of queue) { if (visited.has(key)) continue; @@ -80,7 +86,7 @@ export function assertLockScope(before: LockGraph, after: LockGraph, target: str }); throw new UptideError( 'LOCKFILE_OUT_OF_SCOPE', - `Install changed entries outside ${target}'s dependency subtree: ${described.join('; ')}. No upgrade will be committed.`, + `Install changed entries outside ${names.join(', ')}'s dependency subtree: ${described.join('; ')}. No upgrade will be committed.`, ); } return diff; diff --git a/packages/core/src/fix/managers/npm-lock.ts b/packages/core/src/fix/managers/npm-lock.ts index 2f760b5..2440b68 100644 --- a/packages/core/src/fix/managers/npm-lock.ts +++ b/packages/core/src/fix/managers/npm-lock.ts @@ -1,12 +1,18 @@ import { dirname } from 'node:path'; import { UptideError } from '../../errors.js'; -import { type LockGraph, type LockRecord, withoutTarget } from './lock-guard.js'; +import { + type LockGraph, + type LockRecord, + type Targets, + targetNames, + withoutTarget, +} from './lock-guard.js'; type Entry = Record & { dependencies?: Record; requires?: Record; }; -export function npmGraph(text: string, target: string): LockGraph { +export function npmGraph(text: string, target: Targets): LockGraph { const lock = JSON.parse(text); if (![2, 3].includes(lock.lockfileVersion) || !lock.packages) throw new UptideError( @@ -32,7 +38,7 @@ export function npmGraph(text: string, target: string): LockGraph { : undefined; if (workspace && Object.hasOwn(importers, workspace) && own.requires) { own.requires = { ...own.requires }; - delete own.requires[target]; + for (const name of targetNames(target)) delete own.requires[name]; } nodes.set(key, own); if (dependencies) legacy(dependencies, key); diff --git a/packages/core/src/fix/managers/text-lock.test.ts b/packages/core/src/fix/managers/text-lock.test.ts index 006387d..138719f 100644 --- a/packages/core/src/fix/managers/text-lock.test.ts +++ b/packages/core/src/fix/managers/text-lock.test.ts @@ -28,6 +28,58 @@ snapshots: ).toThrow('outside target'); }); +it('lets a package and its companions move together, and nothing else', () => { + const lock = ( + ai: string, + react: string, + provider: string, + other = '1.0.0', + ) => `lockfileVersion: '9.0' +importers: + .: + dependencies: + ai: + specifier: ${ai} + version: ${ai} + '@ai-sdk/react': + specifier: ${react} + version: ${react}(ai@${ai}) + other: + specifier: ${other} + version: ${other} +packages: + ai@${ai}: {} + '@ai-sdk/react@${react}': {} + '@ai-sdk/provider@${provider}': {} + other@${other}: {} +snapshots: + ai@${ai}: + dependencies: + '@ai-sdk/provider': ${provider} + '@ai-sdk/react@${react}(ai@${ai})': + dependencies: + ai: ${ai} + '@ai-sdk/provider@${provider}': {} + other@${other}: {} +`; + const names = ['ai', '@ai-sdk/react']; + const before = pnpmGraph(lock('6.0.116', '3.0.118', '3.0.3'), names); + expect(() => + assertLockScope(before, pnpmGraph(lock('7.0.9', '4.0.10', '4.0.1'), names), names), + ).not.toThrow(); + // ai alone may not drag @ai-sdk/react to its next major: that is what the group is for. + expect(() => + assertLockScope( + pnpmGraph(lock('6.0.116', '3.0.118', '3.0.3'), 'ai'), + pnpmGraph(lock('7.0.9', '4.0.10', '4.0.1'), 'ai'), + 'ai', + ), + ).toThrow('outside ai'); + expect(() => + assertLockScope(before, pnpmGraph(lock('7.0.9', '4.0.10', '4.0.1', '2.0.0'), names), names), + ).toThrow('outside ai, @ai-sdk/react'); +}); + describe('alias entries (@isaacs/cliui style) survive a manager re-quoting them', () => { const dir = resolve(import.meta.dirname, '../../../../../fixtures/lockfiles/aliases'); const bump = (text: string): string => diff --git a/packages/core/src/fix/managers/text-lock.ts b/packages/core/src/fix/managers/text-lock.ts index 94a8616..45efb15 100644 --- a/packages/core/src/fix/managers/text-lock.ts +++ b/packages/core/src/fix/managers/text-lock.ts @@ -1,5 +1,11 @@ import { UptideError } from '../../errors.js'; -import { type LockGraph, type LockRecord, withoutTarget } from './lock-guard.js'; +import { + type LockGraph, + type LockRecord, + type Targets, + targetNames, + withoutTarget, +} from './lock-guard.js'; interface Block { key: string; @@ -42,7 +48,7 @@ function dependencyPairs(lines: string[], indent: number): [string, string][] { return pairs; } /** Discard only the target dependency block, never text merely mentioning its name. */ -function omitTarget(lines: string[], target: string): string[] { +function omitTarget(lines: string[], target: Targets): string[] { const out: string[] = []; let dependencyIndent = -1, skippedIndent = -1; @@ -55,7 +61,7 @@ function omitTarget(lines: string[], target: string): string[] { else if (indent <= dependencyIndent) dependencyIndent = -1; if (dependencyIndent >= 0 && indent === dependencyIndent + 2) { const key = /^(?:"([^"]+)"|'([^']+)'|([^\s:]+))(?::|\s)/.exec(line.trim()); - if ((key?.[1] ?? key?.[2] ?? key?.[3]) === target) { + if (targetNames(target).includes(key?.[1] ?? key?.[2] ?? key?.[3] ?? '')) { skippedIndent = indent; continue; } @@ -137,7 +143,7 @@ export function parsedEntry(lines: string[], indent: number): Record(); const metadata: Record = {}; const entries = blocks(text.split('\n'), 0); @@ -167,12 +173,16 @@ export function yarnGraph(text: string, target: string): LockGraph { * packages changed: the suffix is the target's version as its dependents see it. It is * compared without the version, so a renamed dependent is the same entry. */ -function withoutTargetPeerVersion(text: string, target: string): string { - const name = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return text.replace(new RegExp(`\\(${name}@[^()]+\\)`, 'g'), `(${target}@*)`); +function withoutTargetPeerVersion(text: string, target: Targets): string { + let out = text; + for (const each of targetNames(target)) { + const name = each.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + out = out.replace(new RegExp(`\\(${name}@[^()]+\\)`, 'g'), `(${each}@*)`); + } + return out; } -export function pnpmGraph(raw: string, target: string): LockGraph { +export function pnpmGraph(raw: string, target: Targets): LockGraph { const text = withoutTargetPeerVersion(raw, target); const records = new Map(); const metadata: string[] = []; @@ -186,7 +196,9 @@ export function pnpmGraph(raw: string, target: string): LockGraph { ...meaningful(lines).filter((line) => { const indent = line.length - line.trimStart().length; if (indent <= 4) - skip = indent === 4 && unquote(line.trim().replace(/:$/, '')) === target; + skip = + indent === 4 && + targetNames(target).includes(unquote(line.trim().replace(/:$/, ''))); return !skip; }), ); @@ -201,7 +213,7 @@ export function pnpmGraph(raw: string, target: string): LockGraph { name, dependencies: [], // A dependent's pointer at the target moves with the target; the rest of it may not. - data: name === target ? data : withoutTarget(data, target), + data: targetNames(target).includes(name) ? data : withoutTarget(data, target), }); if (section === 'snapshots') { const r = records.get(`${section}:${key}`) as LockRecord; diff --git a/packages/core/src/fix/managers/upgrade.ts b/packages/core/src/fix/managers/upgrade.ts index f876193..93fd807 100644 --- a/packages/core/src/fix/managers/upgrade.ts +++ b/packages/core/src/fix/managers/upgrade.ts @@ -4,7 +4,7 @@ import { parseNpm, parsePnpm, parseYarn } from '../../adapters/typescript/lockfi import { UptideError } from '../../errors.js'; import { command } from '../process.js'; import { assertManagerAvailable } from './availability.js'; -import { assertLockScope, type LockDiff, type LockGraph } from './lock-guard.js'; +import { assertLockScope, type LockDiff, type LockGraph, type Targets } from './lock-guard.js'; import { type PackageManager, updateArgs } from './manager.js'; import { npmGraph } from './npm-lock.js'; import { pnpmGraph, yarnGraph } from './text-lock.js'; @@ -13,12 +13,17 @@ export interface Upgrade { version: string; workspaces: string[]; files: string[]; + /** + * What moves with it, each at the version that agrees (`check/companions.ts`): one install, + * and the lockfile may change inside any of their subtrees, never outside them. + */ + also?: { name: string; version: string }[]; } export interface InstallReport extends LockDiff { manager: string; file: string; } -export function lockGraph(pm: PackageManager, text: string, target: string): LockGraph { +export function lockGraph(pm: PackageManager, text: string, target: Targets): LockGraph { return pm.kind === 'npm' ? npmGraph(text, target) : pm.kind === 'pnpm' @@ -41,7 +46,9 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise m.name); + const before = lockGraph(pm, beforeText, names); const originals = new Map( upgrade.files .filter((f) => f.endsWith('package.json')) @@ -53,10 +60,15 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise [m.name, m.version])), + }; writeFileSync(rootManifest, `${JSON.stringify(json, null, 2)}\n`); - } else if (pm.kind === 'npm') { - // Resolve an exact version first, then restore range style; npm reuses this satisfying lock entry. + } else { + // Resolve an exact version first, then restore range style; npm and pnpm reuse this + // satisfying lock entry. A range alone resolves the newest release in it: `^4.0.1` gives + // a second @ai-sdk/provider next to the 4.0.1 that ai pins. for (const [file, text] of originals) { const json = JSON.parse(text); for (const section of [ @@ -65,7 +77,7 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise(); @@ -87,14 +100,18 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise `- \`${c.name}\` ${c.from} → ${c.to} (${c.reason})`), + '', + ); // The note vouches for agent edits; with none, it has nothing to say. if (report.tier === 'generic' && report.sites.some((s) => s.outcome === 'agent')) lines.push(`> ${GENERIC_NOTE(report.package)}`, ''); diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index a7e93c2..6b6920b 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -222,6 +222,16 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise p.companionConflicts ?? []))]; + if (conflicts.length > 0) + throw new UptideError( + 'INCONSISTENT_UPGRADE', + `${pack.name} ${target} cannot be installed consistently: ${conflicts.join('; ')}. Nothing was changed.`, + ); + const companions = [ + ...new Map(packages.flatMap((p) => p.companions ?? []).map((c) => [c.name, c])).values(), + ].sort((a, b) => a.name.localeCompare(b.name)); const initialContext = { from: [...packages].sort((a, b) => compareVersions(a.installed, b.installed))[0]?.installed ?? '', @@ -285,19 +295,34 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise bumpVersions(root, c.name, c.to)), + ]; + const bump = { + files: [...new Set(bumps.flatMap((b) => b.files))], + workspaces: [...new Set(bumps.flatMap((b) => b.workspaces))], + }; + const moved = companions.map((c) => `${c.name} ${c.to}`).join(', '); const lockfile = await progress( options.onProgress, - { phase: 'install', package: pack.name, detail: `${pack.name} ${target}` }, + { + phase: 'install', + package: pack.name, + detail: `${pack.name} ${target}${moved ? ` with ${moved}` : ''}`, + }, () => services.install(root, { name: pack.name, version: target, workspaces: bump.workspaces, files: bump.files.map((f) => relative(root, f)), + ...(companions.length + ? { also: companions.map((c) => ({ name: c.name, version: c.to })) } + : {}), }), ); - commit(root, `chore: upgrade ${pack.name} to ${target}`, [ + commit(root, `chore: upgrade ${pack.name} to ${target}${moved ? ` with ${moved}` : ''}`, [ ...bump.files, join(root, packageManager(root).lockfile), ]); @@ -454,6 +479,7 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise { + const result = runFixtures(aiPack, fileURLToPath(new URL('.', import.meta.url))); + expect(result.cases.length).toBeGreaterThan(0); + expect(result.unknown).toEqual([]); + expect(result.rewriteFailures).toEqual([]); + expect(result.falsePositives).toEqual([]); + expect(result.falseNegatives).toEqual([]); +}); diff --git a/packages/core/src/packs/ai/detect.ts b/packages/core/src/packs/ai/detect.ts new file mode 100644 index 0000000..03da772 --- /dev/null +++ b/packages/core/src/packs/ai/detect.ts @@ -0,0 +1,195 @@ +import { + type CallExpression, + type NewExpression, + Node, + type ObjectLiteralElementLike, + Project, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; +import type { Finding } from '../../domain/report.js'; +import { onReset } from '../../shared-state.js'; +import type { SourceSite } from '../contract.js'; +import type { TransformResult } from '../types.js'; + +/** + * Where the AI SDK 7 renames apply: the options object of a call to a function imported from + * `ai`, by its exported name, and of the stream-result helpers, which no other library + * names this way. Text alone cannot tell `system:` in a `generateText` call from `system:` + * anywhere else; the import can. + */ +const parsed = new Map(); +onReset(() => parsed.clear()); +function parse(text: string): SourceFile { + let source = parsed.get(text); + if (!source) { + source = new Project({ useInMemoryFileSystem: true }).createSourceFile('consumer.tsx', text); + if (parsed.size >= 16) parsed.delete(parsed.keys().next().value as string); + parsed.set(text, source); + } + return source; +} + +/** Local names bound to `ai` exports: local → exported. */ +function imports(source: SourceFile): Map { + const names = new Map(); + for (const declaration of source.getImportDeclarations()) { + if (declaration.getModuleSpecifierValue() !== 'ai') continue; + for (const specifier of declaration.getNamedImports()) + names.set(specifier.getAliasNode()?.getText() ?? specifier.getName(), specifier.getName()); + } + return names; +} + +/** Methods of a streamText result that take UI message stream options. */ +const RESULT_METHODS = new Set([ + 'toUIMessageStream', + 'toUIMessageStreamResponse', + 'pipeUIMessageStreamToResponse', +]); + +/** The options object of each call to one of `callees` (exported names), with its callee. */ +function optionObjects( + source: SourceFile, + callees: ReadonlySet, +): { callee: string; options: ObjectLiteralElementLike[] }[] { + const names = imports(source); + const found: { callee: string; options: ObjectLiteralElementLike[] }[] = []; + const visit = (call: CallExpression | NewExpression) => { + const expression = call.getExpression(); + let callee: string | undefined; + if (Node.isIdentifier(expression)) callee = names.get(expression.getText()); + else if ( + Node.isPropertyAccessExpression(expression) && + RESULT_METHODS.has(expression.getName()) + ) + callee = expression.getName(); + if (!callee || !callees.has(callee)) return; + const argument = call.getArguments()[0]; + if (argument && Node.isObjectLiteralExpression(argument)) + found.push({ callee, options: argument.getProperties() }); + }; + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) visit(call); + for (const call of source.getDescendantsOfKind(SyntaxKind.NewExpression)) visit(call); + return found; +} + +function keyOf(property: ObjectLiteralElementLike): Node | undefined { + if ( + Node.isPropertyAssignment(property) || + Node.isShorthandPropertyAssignment(property) || + Node.isMethodDeclaration(property) + ) { + const name = property.getNameNode(); + return Node.isIdentifier(name) || Node.isStringLiteral(name) ? name : undefined; + } + return undefined; +} + +function site(source: SourceFile, node: Node): SourceSite { + const { line, column } = source.getLineAndColumnAtPos(node.getStart()); + return { + line, + column, + snippet: (source.getFullText().split('\n')[line - 1] ?? '').trim(), + name: node.getText().replace(/['"]/g, ''), + }; +} + +/** Sites of the option `key` in calls to `callees`. */ +export function optionSites(text: string, key: string, callees: ReadonlySet): SourceSite[] { + if (!text.includes(key)) return []; + const source = parse(text); + return optionObjects(source, callees).flatMap(({ options }) => + options + .map(keyOf) + .filter((k): k is Node => k !== undefined && k.getText().replace(/['"]/g, '') === key) + .map((k) => site(source, k)), + ); +} + +/** The option key at the reported site renamed: `system:` → `instructions:`, `{ system }` → `{ instructions: system }`. */ +export function renameOption( + text: string, + finding: Pick, + key: string, + to: string, + callees: ReadonlySet, +): TransformResult { + const source = parse(text); + for (const { options } of optionObjects(source, callees)) + for (const property of options) { + const name = keyOf(property); + if (!name || name.getText().replace(/['"]/g, '') !== key) continue; + const at = site(source, name); + if (at.line !== finding.usage.line || at.column !== finding.usage.column) continue; + const replacement = Node.isShorthandPropertyAssignment(property) ? `${to}: ${key}` : to; + return { + text: `${text.slice(0, name.getStart())}${replacement}${text.slice(name.getEnd())}`, + applied: true, + reason: `\`${key}\` is \`${to}\` in AI SDK 7`, + }; + } + return { text, applied: false, reason: `the reported site is not the \`${key}\` option` }; +} + +/** `stepCountIs` imported from `ai`: the import and every reference to the local name. */ +export function importSites(text: string, exported: string): SourceSite[] { + if (!text.includes(exported)) return []; + const source = parse(text); + const sites: SourceSite[] = []; + for (const declaration of source.getImportDeclarations()) { + if (declaration.getModuleSpecifierValue() !== 'ai') continue; + for (const specifier of declaration.getNamedImports()) { + if (specifier.getName() !== exported) continue; + sites.push(site(source, specifier.getNameNode())); + // Aliased (`stepCountIs as stop`): the local keeps its name, only the import changes. + if (specifier.getAliasNode()) continue; + const local = specifier.getNameNode(); + if (!Node.isIdentifier(local)) continue; + for (const reference of local.findReferencesAsNodes()) + if ( + reference.getSourceFile() === source && + !reference.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) + ) + sites.push(site(source, reference)); + } + } + return sites.sort((a, b) => a.line - b.line || a.column - b.column); +} + +/** + * Whether the file declares `name` anywhere (a variable, parameter, function, class or import), + * other than as a binding taken from a `streamText(...)` result: that one is what renaming + * `fullStream` to `stream` writes, so it never collides with the rename itself. + */ +export function declaresName(text: string, name: string): boolean { + if (!text.includes(name)) return false; + const source = parse(text); + const streamText = [...imports(source)].find(([, exported]) => exported === 'streamText')?.[0]; + return source.getDescendantsOfKind(SyntaxKind.Identifier).some((id) => { + if (id.getText() !== name) return false; + const parent = id.getParent(); + if (Node.isBindingElement(parent) && parent.getNameNode() === id) { + const declaration = parent.getFirstAncestorByKind(SyntaxKind.VariableDeclaration); + const init = declaration?.getInitializer(); + const call = init && Node.isAwaitExpression(init) ? init.getExpression() : init; + return !( + streamText && + call && + Node.isCallExpression(call) && + call.getExpression().getText() === streamText + ); + } + if (Node.isImportClause(parent)) return parent.getDefaultImport() === id; + return ( + (Node.isVariableDeclaration(parent) || + Node.isParameterDeclaration(parent) || + Node.isFunctionDeclaration(parent) || + Node.isClassDeclaration(parent) || + Node.isImportSpecifier(parent) || + Node.isNamespaceImport(parent)) && + parent.getNameNode() === id + ); + }); +} diff --git a/packages/core/src/packs/ai/fixtures/full-stream-clash/after.ts b/packages/core/src/packs/ai/fixtures/full-stream-clash/after.ts new file mode 100644 index 0000000..69c0c09 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/full-stream-clash/after.ts @@ -0,0 +1,9 @@ +import { streamText } from 'ai'; + +// A `stream` already in the file: a destructured `fullStream` keeps its name. +export async function relay(stream: WritableStream, prompt: string) { + const { fullStream } = streamText({ model, prompt }); // @uptide full-stream keep at:fullStream kind:deprecated path:StreamTextResult#fullStream + const result = streamText({ model, prompt }); + for await (const part of result.stream) await write(stream, part); // @uptide full-stream at:.fullStream kind:deprecated path:StreamTextResult#fullStream + return fullStream; +} diff --git a/packages/core/src/packs/ai/fixtures/full-stream-clash/before.ts b/packages/core/src/packs/ai/fixtures/full-stream-clash/before.ts new file mode 100644 index 0000000..09eb482 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/full-stream-clash/before.ts @@ -0,0 +1,9 @@ +import { streamText } from 'ai'; + +// A `stream` already in the file: a destructured `fullStream` keeps its name. +export async function relay(stream: WritableStream, prompt: string) { + const { fullStream } = streamText({ model, prompt }); // @uptide full-stream keep at:fullStream kind:deprecated path:StreamTextResult#fullStream + const result = streamText({ model, prompt }); + for await (const part of result.fullStream) await write(stream, part); // @uptide full-stream at:.fullStream kind:deprecated path:StreamTextResult#fullStream + return fullStream; +} diff --git a/packages/core/src/packs/ai/fixtures/full-stream/after.ts b/packages/core/src/packs/ai/fixtures/full-stream/after.ts new file mode 100644 index 0000000..d9fc11d --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/full-stream/after.ts @@ -0,0 +1,6 @@ +import { streamText } from 'ai'; + +export async function* parts(prompt: string) { + const { stream } = streamText({ model, prompt }); // @uptide full-stream at:fullStream kind:deprecated path:StreamTextResult#fullStream + for await (const part of stream) yield part; // @uptide full-stream at:fullStream kind:deprecated path:StreamTextResult#fullStream +} diff --git a/packages/core/src/packs/ai/fixtures/full-stream/before.ts b/packages/core/src/packs/ai/fixtures/full-stream/before.ts new file mode 100644 index 0000000..93d3989 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/full-stream/before.ts @@ -0,0 +1,6 @@ +import { streamText } from 'ai'; + +export async function* parts(prompt: string) { + const { fullStream } = streamText({ model, prompt }); // @uptide full-stream at:fullStream kind:deprecated path:StreamTextResult#fullStream + for await (const part of fullStream) yield part; // @uptide full-stream at:fullStream kind:deprecated path:StreamTextResult#fullStream +} diff --git a/packages/core/src/packs/ai/fixtures/options/after.ts b/packages/core/src/packs/ai/fixtures/options/after.ts new file mode 100644 index 0000000..7d96286 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/options/after.ts @@ -0,0 +1,37 @@ +import { useChat } from '@ai-sdk/react'; +import { createUIMessageStream, generateObject, generateText as generate, streamText, ToolLoopAgent } from 'ai'; +import { complete } from 'other-llm'; + +const system = 'You are terse.'; + +export async function title(prompt: string) { + const { text } = await generate({ + model, + instructions: 'Write a title.', // @uptide instructions + prompt, + onStepEnd: (step) => log(step), // @uptide on-step-end + telemetry: { isEnabled: true, functionId: 'title' }, // @uptide telemetry + onEnd: ({ text }) => log(text), // @uptide on-end + }); + return text; +} + +export const reply = streamText({ model, instructions: system, prompt: 'hi' }); // @uptide instructions at:system + +export const agent = new ToolLoopAgent({ + model, + instructions: 'Use the tools.', // @uptide instructions + onEnd() {}, // @uptide on-end +}); + +export const ui = createUIMessageStream({ + execute: ({ writer }) => writer.merge(reply.toUIMessageStream({ onEnd: () => {} })), // @uptide on-end at:onFinish + onEnd: async ({ messages }) => save(messages), // @uptide on-end +}); + +// Left alone: another library's options, a hook from @ai-sdk/react, generateObject's onFinish. +export const other = complete({ system: 'x' }); // @uptide instructions keep at:system +export const chat = useChat({ onFinish: () => {} }); // @uptide on-end keep at:onFinish +export const object = generateObject({ model, schema, prompt: 'p', onFinish: () => {} }); // @uptide on-end keep at:onFinish +export const traced = complete({ onStepFinish: log, experimental_telemetry: on }); // @uptide on-step-end keep at:onStepFinish +export const tracedToo = complete({ experimental_telemetry: on }); // @uptide telemetry keep at:experimental_telemetry diff --git a/packages/core/src/packs/ai/fixtures/options/before.ts b/packages/core/src/packs/ai/fixtures/options/before.ts new file mode 100644 index 0000000..00a7b2b --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/options/before.ts @@ -0,0 +1,37 @@ +import { useChat } from '@ai-sdk/react'; +import { createUIMessageStream, generateObject, generateText as generate, streamText, ToolLoopAgent } from 'ai'; +import { complete } from 'other-llm'; + +const system = 'You are terse.'; + +export async function title(prompt: string) { + const { text } = await generate({ + model, + system: 'Write a title.', // @uptide instructions + prompt, + onStepFinish: (step) => log(step), // @uptide on-step-end + experimental_telemetry: { isEnabled: true, functionId: 'title' }, // @uptide telemetry + onFinish: ({ text }) => log(text), // @uptide on-end + }); + return text; +} + +export const reply = streamText({ model, system, prompt: 'hi' }); // @uptide instructions at:system + +export const agent = new ToolLoopAgent({ + model, + system: 'Use the tools.', // @uptide instructions + onFinish() {}, // @uptide on-end +}); + +export const ui = createUIMessageStream({ + execute: ({ writer }) => writer.merge(reply.toUIMessageStream({ onFinish: () => {} })), // @uptide on-end at:onFinish + onFinish: async ({ messages }) => save(messages), // @uptide on-end +}); + +// Left alone: another library's options, a hook from @ai-sdk/react, generateObject's onFinish. +export const other = complete({ system: 'x' }); // @uptide instructions keep at:system +export const chat = useChat({ onFinish: () => {} }); // @uptide on-end keep at:onFinish +export const object = generateObject({ model, schema, prompt: 'p', onFinish: () => {} }); // @uptide on-end keep at:onFinish +export const traced = complete({ onStepFinish: log, experimental_telemetry: on }); // @uptide on-step-end keep at:onStepFinish +export const tracedToo = complete({ experimental_telemetry: on }); // @uptide telemetry keep at:experimental_telemetry diff --git a/packages/core/src/packs/ai/fixtures/removed-option/after.ts b/packages/core/src/packs/ai/fixtures/removed-option/after.ts new file mode 100644 index 0000000..4162f46 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/removed-option/after.ts @@ -0,0 +1,10 @@ +import { smoothStream, streamText } from 'ai'; + +export const result = streamText({ + model, + prompt: 'p', + activeTools: ['search'], // @uptide removed-experimental-option at:experimental_activeTools kind:type path:TS2353 message:"Object literal may only specify known properties, and 'experimental_activeTools' does not exist in type" + prepareStep: prepare, // @uptide removed-experimental-option at:experimental_prepareStep kind:type path:TS2353 message:"Object literal may only specify known properties, and 'experimental_prepareStep' does not exist in type" + // Another rejected option is another rule's: never renamed by this one. + experimental_transform: smoothStream(), // @uptide removed-experimental-option keep at:experimental_transform kind:type path:TS2353 message:"Object literal may only specify known properties, and 'metadata' does not exist in type" +}); diff --git a/packages/core/src/packs/ai/fixtures/removed-option/before.ts b/packages/core/src/packs/ai/fixtures/removed-option/before.ts new file mode 100644 index 0000000..796f2c7 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/removed-option/before.ts @@ -0,0 +1,10 @@ +import { smoothStream, streamText } from 'ai'; + +export const result = streamText({ + model, + prompt: 'p', + experimental_activeTools: ['search'], // @uptide removed-experimental-option at:experimental_activeTools kind:type path:TS2353 message:"Object literal may only specify known properties, and 'experimental_activeTools' does not exist in type" + experimental_prepareStep: prepare, // @uptide removed-experimental-option at:experimental_prepareStep kind:type path:TS2353 message:"Object literal may only specify known properties, and 'experimental_prepareStep' does not exist in type" + // Another rejected option is another rule's: never renamed by this one. + experimental_transform: smoothStream(), // @uptide removed-experimental-option keep at:experimental_transform kind:type path:TS2353 message:"Object literal may only specify known properties, and 'metadata' does not exist in type" +}); diff --git a/packages/core/src/packs/ai/fixtures/step-count/after.ts b/packages/core/src/packs/ai/fixtures/step-count/after.ts new file mode 100644 index 0000000..85ea070 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/step-count/after.ts @@ -0,0 +1,8 @@ +import { isStepCount, streamText } from 'ai'; // @uptide is-step-count at:stepCountIs +import { isStepCount as stopAfter } from 'ai'; // @uptide is-step-count at:stepCountIs + +export const a = streamText({ model, prompt: 'p', stopWhen: isStepCount(5) }); // @uptide is-step-count at:stepCountIs +export const b = streamText({ model, prompt: 'p', stopWhen: [isStepCount(10), done] }); // @uptide is-step-count at:stepCountIs +// The alias keeps its name; a name that only starts the same is not the import. +export const c = streamText({ model, prompt: 'p', stopWhen: stopAfter(3) }); +export const stepCountIsDefault = 5; // @uptide is-step-count keep at:stepCountIsDefault diff --git a/packages/core/src/packs/ai/fixtures/step-count/before.ts b/packages/core/src/packs/ai/fixtures/step-count/before.ts new file mode 100644 index 0000000..9e08892 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/step-count/before.ts @@ -0,0 +1,8 @@ +import { stepCountIs, streamText } from 'ai'; // @uptide is-step-count at:stepCountIs +import { stepCountIs as stopAfter } from 'ai'; // @uptide is-step-count at:stepCountIs + +export const a = streamText({ model, prompt: 'p', stopWhen: stepCountIs(5) }); // @uptide is-step-count at:stepCountIs +export const b = streamText({ model, prompt: 'p', stopWhen: [stepCountIs(10), done] }); // @uptide is-step-count at:stepCountIs +// The alias keeps its name; a name that only starts the same is not the import. +export const c = streamText({ model, prompt: 'p', stopWhen: stopAfter(3) }); +export const stepCountIsDefault = 5; // @uptide is-step-count keep at:stepCountIsDefault diff --git a/packages/core/src/packs/ai/fixtures/total-usage/after.ts b/packages/core/src/packs/ai/fixtures/total-usage/after.ts new file mode 100644 index 0000000..7ec07cb --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/total-usage/after.ts @@ -0,0 +1,8 @@ +import { streamText } from 'ai'; + +export async function cost(prompt: string) { + const result = streamText({ model, prompt }); + const used = await result.usage; // @uptide total-usage at:.totalUsage kind:deprecated path:StreamTextResult#totalUsage + const totalUsage = used.inputTokens; // @uptide total-usage keep at:totalUsage kind:deprecated path:StreamTextResult#totalUsage + return totalUsage; +} diff --git a/packages/core/src/packs/ai/fixtures/total-usage/before.ts b/packages/core/src/packs/ai/fixtures/total-usage/before.ts new file mode 100644 index 0000000..d44ef48 --- /dev/null +++ b/packages/core/src/packs/ai/fixtures/total-usage/before.ts @@ -0,0 +1,8 @@ +import { streamText } from 'ai'; + +export async function cost(prompt: string) { + const result = streamText({ model, prompt }); + const used = await result.totalUsage; // @uptide total-usage at:.totalUsage kind:deprecated path:StreamTextResult#totalUsage + const totalUsage = used.inputTokens; // @uptide total-usage keep at:totalUsage kind:deprecated path:StreamTextResult#totalUsage + return totalUsage; +} diff --git a/packages/core/src/packs/ai/ground-truth.json b/packages/core/src/packs/ai/ground-truth.json new file mode 100644 index 0000000..ea078c2 --- /dev/null +++ b/packages/core/src/packs/ai/ground-truth.json @@ -0,0 +1,405 @@ +{ + "$comment": "Ground truth for the ai pack (docs/packs.md, \"Ground truth\"). Each public repository is pinned at the commit before its own AI SDK 6 to 7 upgrade. Expected findings come from two sources that are not Uptide, drafted by pnpm packs:truth and read by hand: the diagnostics the repository's TypeScript reports with the target and the companions its upgrade moved with it linked in (@ai-sdk/react, @ai-sdk/provider, the providers; their versions from the upgrade's lockfile) and not without them, errors and deprecations, and the lines the upgrade commit changed for ai (one site per construct; comments, insertions and other packages' changes such as @ai-sdk/react's experimental_throttle left out). Rules are assigned from each site's code and text. Each entry's `with` lists the packages that upgrade moved with ai (from its package.json); pack test fails when check would not move each of them.", + "package": "ai", + "repos": [ + { + "repo": "vercel/chatbot", + "commit": "77d362e596e2c624638cad5a35199c0c8785c9fd", + "migration": "https://github.com/vercel/chatbot/commit/2397c783b505997a09968c4619ec42dc450cc705", + "from": "6.0.116", + "to": "7.0.9", + "with": { + "@ai-sdk/provider": "4.0.1", + "@ai-sdk/react": "4.0.10" + }, + "why": "Next.js chat app: streamText with tools, artifact streams over fullStream, UI message streams, telemetry", + "findings": [ + { + "file": "app/(chat)/actions.ts", + "line": 30, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 7, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 196, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 198, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 199, + "rule": "removed-experimental-option", + "note": "compiler TS2353" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 236, + "rule": "telemetry", + "note": "changed by the upgrade" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 243, + "rule": "ui-message-stream", + "note": "compiler TS6387" + }, + { + "file": "app/(chat)/api/chat/route.ts", + "line": 257, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/code/server.ts", + "line": 18, + "rule": "full-stream", + "note": "compiler TS6385" + }, + { + "file": "artifacts/code/server.ts", + "line": 20, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/code/server.ts", + "line": 24, + "rule": "full-stream", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/code/server.ts", + "line": 40, + "rule": "full-stream", + "note": "compiler TS6385" + }, + { + "file": "artifacts/code/server.ts", + "line": 42, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/code/server.ts", + "line": 46, + "rule": "full-stream", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/sheet/server.ts", + "line": 11, + "rule": "full-stream", + "note": "compiler TS6385" + }, + { + "file": "artifacts/sheet/server.ts", + "line": 13, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/sheet/server.ts", + "line": 17, + "rule": "full-stream", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/sheet/server.ts", + "line": 33, + "rule": "full-stream", + "note": "compiler TS6385" + }, + { + "file": "artifacts/sheet/server.ts", + "line": 35, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/sheet/server.ts", + "line": 39, + "rule": "full-stream", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/text/server.ts", + "line": 11, + "rule": "full-stream", + "note": "compiler TS6385" + }, + { + "file": "artifacts/text/server.ts", + "line": 13, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/text/server.ts", + "line": 19, + "rule": "full-stream", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/text/server.ts", + "line": 35, + "rule": "full-stream", + "note": "compiler TS6385" + }, + { + "file": "artifacts/text/server.ts", + "line": 37, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "artifacts/text/server.ts", + "line": 42, + "rule": "full-stream", + "note": "changed by the upgrade" + }, + { + "file": "lib/ai/tools/request-suggestions.ts", + "line": 51, + "rule": "instructions", + "note": "changed by the upgrade" + } + ] + }, + { + "repo": "miurla/morphic", + "commit": "989a05697d973030f2fbb4b805326bb24e68fd0d", + "migration": "https://github.com/miurla/morphic/commit/0ec2ca733c8cadb589d741097a2766f67e1766a3", + "from": "6.0.180", + "to": "7.0.66", + "with": { + "@ai-sdk/anthropic": "4.0.39", + "@ai-sdk/gateway": "4.0.52", + "@ai-sdk/google": "4.0.44", + "@ai-sdk/openai": "4.0.42", + "@ai-sdk/openai-compatible": "3.0.31", + "@ai-sdk/react": "4.0.69", + "ai-sdk-ollama": "4.1.0" + }, + "why": "Next.js research agent on bun: ToolLoopAgent, telemetry metadata moved to runtimeContext, tool execute options, UI message streams, tests that mock ai", + "findings": [ + { + "file": "lib/agents/__tests__/researcher.test.ts", + "line": 10, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/researcher.ts", + "line": 1, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/researcher.ts", + "line": 3, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/researcher.ts", + "line": 145, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/researcher.ts", + "line": 148, + "rule": "telemetry", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/researcher.ts", + "line": 151, + "rule": "telemetry-metadata", + "note": "compiler TS2353" + }, + { + "file": "lib/agents/researcher.ts", + "line": 168, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/title-generator.ts", + "line": 43, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/title-generator.ts", + "line": 48, + "rule": "telemetry", + "note": "changed by the upgrade" + }, + { + "file": "lib/agents/title-generator.ts", + "line": 51, + "rule": "telemetry-metadata", + "note": "compiler TS2353" + }, + { + "file": "lib/render/__tests__/llm-image-output.e2e.test.ts", + "line": 12, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/render/__tests__/llm-image-output.e2e.test.ts", + "line": 89, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "lib/render/__tests__/llm-image-output.e2e.test.ts", + "line": 93, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/render/__tests__/llm-related-questions.e2e.test.ts", + "line": 11, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/render/__tests__/llm-related-questions.e2e.test.ts", + "line": 103, + "rule": "instructions", + "note": "changed by the upgrade" + }, + { + "file": "lib/render/__tests__/llm-related-questions.e2e.test.ts", + "line": 107, + "rule": "is-step-count", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/__tests__/create-chat-stream-response.test.ts", + "line": 79, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/__tests__/create-chat-stream-response.test.ts", + "line": 94, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/__tests__/create-ephemeral-chat-stream-response.test.ts", + "line": 62, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/__tests__/create-ephemeral-chat-stream-response.test.ts", + "line": 88, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/create-chat-stream-response.ts", + "line": 222, + "rule": "on-step-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/create-chat-stream-response.ts", + "line": 238, + "rule": "total-usage", + "note": "compiler TS6385" + }, + { + "file": "lib/streaming/create-chat-stream-response.ts", + "line": 245, + "rule": "ui-message-stream", + "note": "compiler TS6387" + }, + { + "file": "lib/streaming/create-chat-stream-response.ts", + "line": 255, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/create-ephemeral-chat-stream-response.ts", + "line": 134, + "rule": "on-step-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/streaming/create-ephemeral-chat-stream-response.ts", + "line": 148, + "rule": "total-usage", + "note": "compiler TS6385" + }, + { + "file": "lib/streaming/create-ephemeral-chat-stream-response.ts", + "line": 153, + "rule": "ui-message-stream", + "note": "compiler TS6387" + }, + { + "file": "lib/streaming/create-ephemeral-chat-stream-response.ts", + "line": 163, + "rule": "on-end", + "note": "changed by the upgrade" + }, + { + "file": "lib/tools/__tests__/fetch-error.test.ts", + "line": 23, + "rule": "tool-context", + "note": "compiler TS2345" + }, + { + "file": "lib/tools/__tests__/fetch-error.test.ts", + "line": 55, + "rule": "tool-context", + "note": "compiler TS2345" + }, + { + "file": "lib/tools/__tests__/fetch-error.test.ts", + "line": 91, + "rule": "tool-context", + "note": "compiler TS2345" + }, + { + "file": "lib/tools/search.ts", + "line": 212, + "rule": "tool-context", + "note": "compiler TS2345" + }, + { + "file": "lib/tools/search.ts", + "line": 214, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "lib/types/agent.ts", + "line": 23, + "rule": "generic", + "note": "changed by the upgrade" + } + ] + } + ] +} diff --git a/packages/core/src/packs/ai/index.ts b/packages/core/src/packs/ai/index.ts new file mode 100644 index 0000000..ab8ceb7 --- /dev/null +++ b/packages/core/src/packs/ai/index.ts @@ -0,0 +1,272 @@ +import { definePack, type PackRule, replaceAtSite } from '../contract.js'; +import type { TransformResult } from '../types.js'; +import { declaresName, importSites, optionSites, renameOption } from './detect.js'; + +/** + * AI SDK (`ai`) 6 → 7. Taken from the official migration guide and the 7.0.0 changelog + * (`meta.sources`), checked 2026-10-07 against ai 7.0.130. Every rename below is a deprecated + * alias that still compiles in 7.x, so its rule is `deprecated`; what the compiler rejects is + * `breaking`. Changes the compiler cannot see are behavior notes, listed for review. + */ +const CALLS = new Set([ + 'generateText', + 'streamText', + 'generateObject', + 'streamObject', + 'ToolLoopAgent', +]); +const TEXT_CALLS = new Set(['generateText', 'streamText', 'ToolLoopAgent']); +const UI_STREAMS = new Set([ + 'createUIMessageStream', + 'toUIMessageStream', + 'toUIMessageStreamResponse', + 'pipeUIMessageStreamToResponse', +]); +const TELEMETRY_CALLS = new Set([...CALLS, 'embed', 'embedMany', 'rerank']); + +/** A renamed option of the calls above: found by `detect`, renamed by `rewrite`. */ +function option( + id: string, + key: string, + to: string, + callees: ReadonlySet, + summary: string, +): PackRule { + return { + id, + summary, + severity: 'deprecated', + // Sites come from `detect`: no type diff reports an option key that still compiles. + kinds: [], + symbols: /$^/, + guide: `${summary}. Rename the option and keep its value as it is.`, + detect: (text) => optionSites(text, key, callees), + rewrite: (text, finding) => renameOption(text, finding, key, to, callees), + }; +} + +/** Where a line's trailing `//` comment starts, outside strings; the line's length without one. */ +function codeEnd(line: string): number { + let quote: string | undefined; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = undefined; + } else if (c === '"' || c === "'" || c === '`') quote = c; + else if (c === '/' && line[i + 1] === '/') return i; + } + return line.length; +} + +/** One occurrence of `before` in the code of the reported line, when it has exactly one. */ +function onlyOnLine( + text: string, + line: number, + before: RegExp, + after: string, + reason: string, +): TransformResult { + const lines = text.split('\n'); + const current = lines[line - 1] ?? ''; + const end = codeEnd(current); + const code = current.slice(0, end); + const matches = code.match(new RegExp(before.source, 'g')) ?? []; + if (matches.length !== 1) + return { text, applied: false, reason: 'the reported line is not one occurrence to rename' }; + lines[line - 1] = `${code.replace(before, after)}${current.slice(end)}`; + return { text: lines.join('\n'), applied: true, reason }; +} + +export const aiPack = definePack({ + meta: { + package: 'ai', + from: '>=6 <7', + to: '>=7 <8', + sources: [ + { + title: 'Migrate AI SDK 6.x to 7.0', + url: 'https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0', + }, + { + title: 'Migration guide source', + url: 'https://github.com/vercel/ai/blob/main/content/docs/08-migration-guides/23-migration-guide-7-0.mdx', + }, + { + title: 'ai 7.0.0 changelog', + url: 'https://github.com/vercel/ai/blob/main/packages/ai/CHANGELOG.md', + }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '7.0.130', + rules: [ + option( + 'instructions', + 'system', + 'instructions', + CALLS, + '`system` is `instructions` (generateText, streamText, generateObject, streamObject, ToolLoopAgent)', + ), + option( + 'on-end', + 'onFinish', + 'onEnd', + new Set([...TEXT_CALLS, ...UI_STREAMS]), + '`onFinish` is `onEnd` (generateText, streamText, ToolLoopAgent, UI message streams)', + ), + option('on-step-end', 'onStepFinish', 'onStepEnd', CALLS, '`onStepFinish` is `onStepEnd`'), + option( + 'telemetry', + 'experimental_telemetry', + 'telemetry', + TELEMETRY_CALLS, + '`experimental_telemetry` is `telemetry`', + ), + { + id: 'is-step-count', + summary: '`stepCountIs` is `isStepCount`', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + guide: '`stepCountIs(n)` is `isStepCount(n)`: same stop condition.', + detect: (text) => importSites(text, 'stepCountIs'), + rewrite: (text, finding) => replaceAtSite(text, finding, 'stepCountIs', 'isStepCount'), + }, + { + id: 'full-stream', + summary: '`fullStream` on a streamText result is `stream`', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: /#fullStream$/, + guide: + '`result.fullStream` is `result.stream`, the same parts. Rename a destructured `fullStream` and its uses together, unless the scope already has a `stream`.', + rewrite: (text, finding) => + // A destructured local becomes `stream` only where no other `stream` can collide. + declaresName(text, 'stream') + ? onlyOnLine( + text, + finding.usage.line, + /\.fullStream\b/, + '.stream', + '`.fullStream` is `.stream`', + ) + : onlyOnLine( + text, + finding.usage.line, + /\bfullStream\b/, + 'stream', + '`fullStream` is `stream`', + ), + }, + { + id: 'total-usage', + summary: '`totalUsage` is `usage`, which covers every step in AI SDK 7', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: /#totalUsage$/, + guide: + '`result.totalUsage` is `result.usage`: in AI SDK 7 `usage` sums every step, which is what `totalUsage` was.', + rewrite: (text, finding) => + onlyOnLine(text, finding.usage.line, /\.totalUsage\b/, '.usage', '`totalUsage` is `usage`'), + }, + { + id: 'ui-message-stream', + summary: + 'stream result helpers (`toUIMessageStream`, `toUIMessageStreamResponse`, …) are standalone functions', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: + /#(?:toUIMessageStream|toUIMessageStreamResponse|pipeUIMessageStreamToResponse|toTextStreamResponse|pipeTextStreamToResponse)$/, + guide: + '`result.toUIMessageStream(options)` is `toUIMessageStream({ stream: result.stream, ...options })`, imported from "ai"; `result.toUIMessageStreamResponse()` is `createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })`. Keep every option.', + }, + { + id: 'removed-experimental-option', + summary: + '`experimental_activeTools`, `experimental_prepareStep` and `experimental_output` are gone: `activeTools`, `prepareStep`, `output`', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS2353$/, + message: /'experimental_(?:activeTools|prepareStep|output)'/, + guide: + 'The experimental option was promoted: rename `experimental_activeTools` to `activeTools`, `experimental_prepareStep` to `prepareStep`, `experimental_output` to `output`. Nothing else changes.', + rewrite: (text, finding) => { + const name = /'experimental_(activeTools|prepareStep|output)'/.exec( + finding.usage.compileError ?? '', + )?.[1]; + return name + ? onlyOnLine( + text, + finding.usage.line, + new RegExp(`\\bexperimental_${name}\\b`), + name, + `\`experimental_${name}\` is \`${name}\``, + ) + : { text, applied: false, reason: 'not a removed experimental option' }; + }, + }, + { + id: 'telemetry-metadata', + summary: + 'telemetry `metadata` is gone: per-call values move to `runtimeContext`, named in `telemetry.includeRuntimeContext`', + severity: 'breaking', + // The type diff names it (`TelemetrySettings#metadata` removed); the compiler alone says TS2353. + kinds: ['removed', 'type', 'signature'], + symbols: /(?:^TS2353$|#metadata$)/, + message: /'metadata'/, + guide: + 'Move each `metadata` entry to a top-level `runtimeContext: { key: value }` on the call, and list it in `telemetry: { includeRuntimeContext: { key: true } }`. Keep functionId and every other telemetry setting.', + }, + { + id: 'tool-context', + summary: + 'tool execution options carry a typed `context`: `experimental_context` is `context`, and a direct `tool.execute` call passes one', + severity: 'breaking', + kinds: ['type', 'signature', 'required'], + symbols: /^TS\d+$/, + message: /'(?:experimental_)?context'/, + guide: + 'A tool reads `context` (was `experimental_context`) from its execute options; the call site supplies it per tool with `toolsContext`, and shared values with `runtimeContext` (was `context`). A direct `tool.execute(input, { toolCallId, messages })` call adds `context`. Do not cast to make it compile.', + }, + ], + behavior: [ + { + id: 'telemetry-registration', + summary: + 'AI SDK 7 emits no OpenTelemetry spans by itself: register an integration once (`registerTelemetry(new OpenTelemetry())` from "ai" and "@ai-sdk/otel"), or tracing silently stops', + reported: ['decision'], + }, + { + id: 'results-cover-all-steps', + summary: + '`usage`, `content`, `toolCalls`, `toolResults`, `files`, `sources` and `warnings` on a result and on the `onEnd` event now cover every step; the last step alone is `finalStep`', + reported: ['decision'], + }, + { + id: 'system-messages-rejected', + summary: + 'system messages inside `messages` or `prompt` are rejected by default: pass them as `instructions`, or set `allowSystemInMessages`', + reported: ['decision'], + }, + { + id: 'prepare-step-carries-forward', + summary: + 'what `prepareStep` returns for instructions and messages now carries forward to later steps', + reported: ['decision'], + }, + { + id: 'ui-stream-errors-redacted', + summary: + 'UI message streams send "An error occurred." instead of the error: pass `onError` to keep the details', + reported: ['decision'], + }, + { + id: 'esm-only', + summary: 'every `ai` and `@ai-sdk/*` package is ESM-only and needs Node 22 or later', + reported: ['decision'], + }, + ], + instructions: + 'Migrate only the reported site to AI SDK 7, from the compiler error and the guide above. Keep every value, prompt, tool and callback body as it is. Never cast or suppress a diagnostic to make it compile. If the site needs a decision the guide leaves open (which step a value should come from, what a runtime context holds), say so instead of choosing.', +}); diff --git a/packages/core/src/packs/ai/verification.json b/packages/core/src/packs/ai/verification.json new file mode 100644 index 0000000..241029e --- /dev/null +++ b/packages/core/src/packs/ai/verification.json @@ -0,0 +1,10 @@ +{ + "status": "verified", + "truth": "sha256-ae1f3857d2634807816517e08d4642234b233e4fde83a0a7e244e7bf93de45a7", + "repos": 2, + "breaking": { + "precision": 1, + "recall": 0.3636, + "falsePositives": 0 + } +} diff --git a/packages/core/src/packs/contract.ts b/packages/core/src/packs/contract.ts index 6acb0b5..0c11ef4 100644 --- a/packages/core/src/packs/contract.ts +++ b/packages/core/src/packs/contract.ts @@ -39,6 +39,8 @@ export interface SourceSite { column: number; /** The code at the site, trimmed: what the report shows. */ snippet: string; + /** The name as the code writes it (`system`, `stepCountIs`): what a summary of sites lists. */ + name?: string; } /** @@ -59,6 +61,12 @@ export interface PackRule extends MigrationRule { rewrite?(text: string, finding: Finding, context: PackContext): TransformResult; /** Sites in a file that uses the package which the type diff cannot report. */ detect?(text: string, file: string): SourceSite[]; + /** + * With `kinds` and `symbols`: what the compiler said at the site must match too. A + * compiler-only finding's path is its code (`TS2353`), which says nothing about which + * property was rejected; the message does. + */ + message?: RegExp; } /** @@ -105,6 +113,12 @@ export interface GroundTruthRepo { /** The exact version installed at `commit` and the one to check against. */ from: string; to: string; + /** + * The packages the repository's own upgrade moved with this one, at the versions it chose + * (`@ai-sdk/react` with `ai`). `pack test` fails when `check` would not move each of them: + * the package upgraded alone is an install the real upgrade never had. + */ + with?: Record; why: string; /** Repository-relative `file`, 1-based `line`, and the rule (or behavior note) id. */ findings: GroundTruthFinding[]; @@ -184,14 +198,22 @@ export interface RegisteredPack { verification: PackVerification; } -/** The rule a finding belongs to: the one that set it, else the first whose kinds and symbols match. */ -export function ruleFor( +/** + * The rule a finding belongs to: the one that set it, else the first whose kinds, symbols and + * (when it has one) message match. + */ +export function ruleFor( rules: readonly R[], finding: Finding, ): R | undefined { return ( rules.find((r) => r.id === finding.rule) ?? - rules.find((r) => r.kinds.includes(finding.change.kind) && r.symbols.test(finding.change.path)) + rules.find( + (r) => + r.kinds.includes(finding.change.kind) && + r.symbols.test(finding.change.path) && + (!r.message || r.message.test(finding.usage.compileError ?? '')), + ) ); } @@ -223,7 +245,7 @@ function detected( const findings: Finding[] = []; for (const source of sources) for (const site of source.detect?.(input.text, input.file) ?? []) { - const path = `${pack.meta.package}:${source.id}`; + const path = site.name ?? `${pack.meta.package}:${source.id}`; findings.push({ change: { package: pack.meta.package, @@ -295,6 +317,7 @@ export function definePack(spec: PackSpec): Pack { const result = rule.rewrite(text, finding, context); return result.applied ? { ...result, rule: rule.id } : result; }, + ruleOf: (finding) => ruleFor([...spec.rules, ...detectingNotes(behavior)], finding)?.id, guide(finding) { const rule = ruleFor(spec.rules, finding); return [rule?.guide, spec.instructions].filter(Boolean).join('\n'); @@ -314,6 +337,13 @@ export function definePack(spec: PackSpec): Pack { return pack; } +/** Notes found by `detect`: their findings carry the note's id, as rules' do. */ +function detectingNotes(behavior: readonly BehaviorNote[]): MigrationRule[] { + return behavior + .filter((b) => b.detect && b.reported.includes('finding')) + .map((b) => ({ id: b.id, kinds: [], symbols: /$^/, guide: b.summary })); +} + /** Sites detected in one file, for fixtures and ground truth: what `check` would add. */ export function detectIn( pack: Pick, diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index 15a657f..e40329f 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -1,5 +1,8 @@ // Generated by `uptide pack new` (packages/core/src/packs/tooling/registry-file.ts). // Every pack in this tree, with the status `uptide pack test --write` recorded for it. + +import { aiPack } from './ai/index.js'; +import aiVerification from './ai/verification.json' with { type: 'json' }; import type { PackVerification, RegisteredPack } from './contract.js'; import { stripePack } from './stripe/index.js'; import stripeVerification from './stripe/verification.json' with { type: 'json' }; @@ -7,6 +10,7 @@ import { zodPack } from './zod/index.js'; import zodVerification from './zod/verification.json' with { type: 'json' }; export const REGISTRY: readonly RegisteredPack[] = [ + { dir: 'ai', pack: aiPack, verification: aiVerification as PackVerification }, { dir: 'stripe', pack: stripePack, verification: stripeVerification as PackVerification }, { dir: 'zod', pack: zodPack, verification: zodVerification as PackVerification }, ]; diff --git a/packages/core/src/packs/tooling/fixtures.ts b/packages/core/src/packs/tooling/fixtures.ts index 0cb3f2a..1fed56a 100644 --- a/packages/core/src/packs/tooling/fixtures.ts +++ b/packages/core/src/packs/tooling/fixtures.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { ChangeKind } from '../../domain/change.js'; import type { Finding } from '../../domain/report.js'; @@ -14,7 +14,8 @@ import { detectIn, type Pack, ruleFor } from '../contract.js'; * * `keep` marks a site the rule must leave alone. `at:` is where on the line the site * starts (default: the first non-blank column), `kind:` and `path:` the change `check` would - * report there (default: the rule's first kind, and the rule id). Every marked site of a rule + * report there (default: the rule's first kind, and the `at:` text), `message:"..."` the compiler's + * message for a compiler-only finding (a rule with `message` matches on it). Every marked site of a rule * with `rewrite` is rewritten, bottom-up, and the result must equal `after.ts` byte for byte * (markers included: they are comments). A rule or note with `detect` must find exactly its * marked lines in `before.ts`, and nothing on any other line. @@ -28,6 +29,7 @@ export interface FixtureSite { at?: string; kind?: ChangeKind; path?: string; + message?: string; } export interface FixtureResult { @@ -42,7 +44,7 @@ export interface FixtureResult { unknown: { file: string; line: number; rule: string }[]; } -const MARKER = /\/\/\s*@uptide\s+([\w-]+)((?:\s+[^\s]+)*)\s*$/; +const MARKER = /\/\/\s*@uptide\s+([\w-]+)((?:\s+(?:message:"[^"]*"|[^\s]+))*)\s*$/; export function parseMarkers(text: string, caseName: string, file: string): FixtureSite[] { const sites: FixtureSite[] = []; @@ -56,7 +58,11 @@ export function parseMarkers(text: string, caseName: string, file: string): Fixt rule: match[1] as string, keep: false, }; - for (const word of (match[2] ?? '').trim().split(/\s+/).filter(Boolean)) { + const rest = (match[2] ?? '').replace(/message:"([^"]*)"/, (_all, message: string) => { + site.message = message; + return ''; + }); + for (const word of rest.trim().split(/\s+/).filter(Boolean)) { if (word === 'keep') site.keep = true; else if (word.startsWith('at:')) site.at = word.slice(3); else if (word.startsWith('kind:')) site.kind = word.slice(5) as ChangeKind; @@ -109,6 +115,12 @@ export function fixtureFinding( access: 'call', snippet: lineText.trim(), via: 'direct', + ...(site.message !== undefined + ? { + compileError: site.message, + ...(/^TS\d+$/.test(path) ? { compileCode: Number(path.slice(2)) } : {}), + } + : {}), }, severity, confidence: 1, @@ -128,7 +140,12 @@ export function fixtureCases(dir: string): string[] { } /** Every fixture case of the pack in `dir`, scored the way ground truth is. */ -export function runFixtures(pack: Pack, dir: string): FixtureResult { +export function runFixtures( + pack: Pack, + dir: string, + /** Write each case's `after.ts` from what the rules produce (review the diff before committing). */ + options: { update?: boolean } = {}, +): FixtureResult { const result: FixtureResult = { cases: [], rules: {}, @@ -213,7 +230,8 @@ export function runFixtures(pack: Pack, dir: string): FixtureResult { if (!rule.detect) tally(rule.id).truePositives++; text = out.text; } - if (text !== after) + if (options.update && text !== after) writeFileSync(afterPath, text); + else if (text !== after) result.rewriteFailures.push({ case: name, file: `fixtures/${name}/after.ts`, diff --git a/packages/core/src/packs/tooling/pack-test.ts b/packages/core/src/packs/tooling/pack-test.ts index 6cbd748..b0c7361 100644 --- a/packages/core/src/packs/tooling/pack-test.ts +++ b/packages/core/src/packs/tooling/pack-test.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createFsSurfaceCache } from '../../cache/fs-surface-cache.js'; import { check } from '../../check/check.js'; +import type { Companion } from '../../check/companions.js'; import type { CheckReport } from '../../domain/report.js'; import { type GroundTruth, @@ -45,6 +46,10 @@ export interface RepoScore { error?: string; /** What the lockfile says is installed, when it is not the ground truth's `from`. */ installedMismatch?: string; + /** What `check` moves with the package there, at versions that agree (`fix` installs them together). */ + companions?: Companion[]; + /** Packages the real upgrade moved (`with`) that `check` would leave behind, or cannot move consistently. */ + leftBehind?: string[]; predicted: ScoredSite[]; expected: ScoredSite[]; /** Predicted sites no expected finding has, at the line level. */ @@ -63,6 +68,8 @@ export interface PackTestReport { recorded: PackStatus; /** The recorded verification no longer matches this run (`--write` updates it). */ stale: boolean; + /** Ground truth was not scored (`--fixtures-only`). */ + fixturesOnly: boolean; verification: PackVerification; problems: string[]; fixtures: FixtureResult; @@ -184,8 +191,24 @@ async function scoreRepo( ]; if (installed.length > 0 && !installed.includes(entry.from)) base.installedMismatch = `the lockfile has ${installed.join(', ')}, the ground truth says ${entry.from}`; + const own = report.packages.filter((p) => p.name === pack.name); + const companions = [ + ...new Map(own.flatMap((p) => p.companions ?? []).map((c) => [c.name, c])).values(), + ].sort((a, b) => a.name.localeCompare(b.name)); + const leftBehind = [ + ...new Set(own.flatMap((p) => p.companionConflicts ?? [])), + ...Object.entries(entry.with ?? {}) + .filter(([name]) => !companions.some((c) => c.name === name)) + .map(([name, version]) => `${name}: the upgrade moved it to ${version}, check leaves it`), + ]; const predicted = predictedSites(report, pack); - return { ...base, predicted, ...scoreSites(predicted, expected) }; + return { + ...base, + ...(companions.length ? { companions } : {}), + ...(leftBehind.length ? { leftBehind } : {}), + predicted, + ...scoreSites(predicted, expected), + }; } catch (err) { return { ...base, error: (err as Error).message }; } @@ -198,6 +221,8 @@ export interface PackTestOptions extends EnsureOptions { fixturesOnly?: boolean; /** Record this run's verification in the pack's `verification.json`. */ write?: boolean; + /** Write each fixture case's `after.ts` from what the rules produce. */ + updateFixtures?: boolean; } export function packsDir(root: string): string { @@ -221,7 +246,7 @@ export async function testPack( const problems: string[] = []; if (!pack.meta.sources.length) problems.push('meta.sources lists no changelog or guide'); if (!pack.meta.maintainer) problems.push('meta.maintainer is empty'); - const fixtures = runFixtures(pack, dir); + const fixtures = runFixtures(pack, dir, { update: options.updateFixtures === true }); if (fixtures.cases.length === 0 && pack.rules.some((r) => r.rewrite || r.detect)) problems.push('rules that rewrite or detect need fixtures (fixtures//before.ts)'); const read = readTruth(dir); @@ -242,10 +267,14 @@ export async function testPack( const overall = tally(predicted.length - fp.length, fp.length, fn.length); const breakingPredicted = predicted.filter((p) => p.severity === 'breaking'); const breakingFp = fp.filter((p) => p.severity === 'breaking'); + // A miss is a breaking one unless the rule it was expected under is deprecated. + const deprecatedRules = new Set( + pack.rules.filter((r) => r.severity === 'deprecated').map((r) => r.id), + ); const breaking = tally( breakingPredicted.length - breakingFp.length, breakingFp.length, - fn.length, + fn.filter((f) => !deprecatedRules.has(f.rule)).length, ); const rules: Record = {}; const ids = new Set([ @@ -267,8 +296,9 @@ export async function testPack( if (tp + rfp + rfn > 0) rules[id] = tally(tp, rfp, rfn); } + // A pack whose upgrade leaves part of the group behind produces an inconsistent install. const status: PackStatus = - repos.some((r) => r.error) || options.fixturesOnly + repos.some((r) => r.error || r.leftBehind?.length) || options.fixturesOnly ? 'candidate' : statusOf(truth, { falsePositives: breakingFp.length, predicted: breakingPredicted.length }); const verification: PackVerification = { @@ -290,13 +320,19 @@ export async function testPack( writeVerification(options.root, entry.dir, verification); stale = false; } - const passed = scoredAll && fixturesPass(fixtures) && breakingFp.length === 0 && !stale; + const passed = + scoredAll && + fixturesPass(fixtures) && + breakingFp.length === 0 && + !repos.some((r) => r.leftBehind?.length) && + !stale; return { package: pack.name, dir: entry.dir, status, recorded, stale, + fixturesOnly: options.fixturesOnly === true, verification, problems, fixtures, diff --git a/packages/core/src/packs/tooling/registry-file.ts b/packages/core/src/packs/tooling/registry-file.ts index da7c332..ceb9ca7 100644 --- a/packages/core/src/packs/tooling/registry-file.ts +++ b/packages/core/src/packs/tooling/registry-file.ts @@ -15,18 +15,26 @@ const HEADER = `// Generated by \`uptide pack new\` (packages/core/src/packs/too export function renderRegistry(entries: readonly RegistryEntry[]): string { const sorted = [...entries].sort((a, b) => a.dir.localeCompare(b.dir)); - const imports = sorted.flatMap((e) => [ - `import { ${e.name} } from './${e.dir}/index.js';`, - `import ${verificationName(e)} from './${e.dir}/verification.json' with { type: 'json' };`, - ]); + // Sorted by module path as biome's organize-imports wants them, `./contract.js` included. + const imports: [string, string][] = [ + ['./contract.js', "import type { PackVerification, RegisteredPack } from './contract.js';"], + ...sorted.flatMap((e): [string, string][] => [ + [`./${e.dir}/index.js`, `import { ${e.name} } from './${e.dir}/index.js';`], + [ + `./${e.dir}/verification.json`, + `import ${verificationName(e)} from './${e.dir}/verification.json' with { type: 'json' };`, + ], + ]), + ]; + imports.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const rows = sorted.map( (e) => ` { dir: '${e.dir}', pack: ${e.name}, verification: ${verificationName(e)} as PackVerification },`, ); return [ HEADER, - "import type { PackVerification, RegisteredPack } from './contract.js';", - ...imports, + '', + ...imports.map(([, line]) => line), '', 'export const REGISTRY: readonly RegisteredPack[] = [', ...rows, diff --git a/packages/core/src/packs/tooling/scaffold.test.ts b/packages/core/src/packs/tooling/scaffold.test.ts index 42dbf64..82d015c 100644 --- a/packages/core/src/packs/tooling/scaffold.test.ts +++ b/packages/core/src/packs/tooling/scaffold.test.ts @@ -45,16 +45,17 @@ function miniCheckout(): string { it('pack new scaffolds a pack that lints, passes its own test and pack test; one added rule keeps it passing', async () => { const home = miniCheckout(); - const result = scaffoldPack({ root, package: 'toy-lib', from: '>=1 <2', to: '>=2 <3' }); - expect(result).toMatchObject({ dir: 'toy-lib', constant: 'toyLibPack', formatted: true }); + const result = scaffoldPack({ root, package: 'alpha-lib', from: '>=1 <2', to: '>=2 <3' }); + expect(result).toMatchObject({ dir: 'alpha-lib', constant: 'alphaLibPack', formatted: true }); expect(parseRegistry(readFileSync(join(home, 'registry.ts'), 'utf8'))).toEqual([ - { dir: 'toy-lib', name: 'toyLibPack' }, + { dir: 'alpha-lib', name: 'alphaLibPack' }, ]); - expect(() => scaffoldPack({ root, package: 'toy-lib', from: '>=1 <2', to: '>=2 <3' })).toThrow( + expect(() => scaffoldPack({ root, package: 'alpha-lib', from: '>=1 <2', to: '>=2 <3' })).toThrow( /already exists/, ); - // Lint: the checkout's biome accepts every generated file as it is. + // Lint: the checkout's biome accepts every generated file as it is, the registry included + // (`alpha-lib` sorts before `contract.ts`, which the import order has to respect). const biome = spawnSync( join(repo, 'node_modules', '.bin', 'biome'), ['check', '--no-errors-on-unmatched', 'packages'], @@ -64,7 +65,7 @@ it('pack new scaffolds a pack that lints, passes its own test and pack test; one expect(biome.status).toBe(0); // Add one rule: a note the compiler cannot see, detected in the source, with its fixture. - const index = join(home, 'toy-lib', 'index.ts'); + const index = join(home, 'alpha-lib', 'index.ts'); writeFileSync( index, readFileSync(index, 'utf8').replace( @@ -85,11 +86,11 @@ it('pack new scaffolds a pack that lints, passes its own test and pack test; one ],`, ), ); - mkdirSync(join(home, 'toy-lib', 'fixtures', 'retry-default'), { recursive: true }); + mkdirSync(join(home, 'alpha-lib', 'fixtures', 'retry-default'), { recursive: true }); writeFileSync( - join(home, 'toy-lib', 'fixtures', 'retry-default', 'before.ts'), + join(home, 'alpha-lib', 'fixtures', 'retry-default', 'before.ts'), [ - "import { connect } from 'toy-lib';", + "import { connect } from 'alpha-lib';", '', "export const a = connect('db'); // @uptide retry-default", "export const b = connect('db', { retries: 0 }); // @uptide retry-default keep", @@ -97,11 +98,11 @@ it('pack new scaffolds a pack that lints, passes its own test and pack test; one ].join('\n'), ); - const pack = ((await import(index)) as { toyLibPack: Pack }).toyLibPack; + const pack = ((await import(index)) as { alphaLibPack: Pack }).alphaLibPack; const verification = JSON.parse( - readFileSync(join(home, 'toy-lib', 'verification.json'), 'utf8'), + readFileSync(join(home, 'alpha-lib', 'verification.json'), 'utf8'), ) as PackVerification; - const report = await testPack({ dir: 'toy-lib', pack, verification }, { root }); + const report = await testPack({ dir: 'alpha-lib', pack, verification }, { root }); expect(report.problems).toEqual([]); expect(report.fixtures.cases).toEqual(['example-rename', 'retry-default']); expect(report.fixtures.rules['retry-default']).toEqual({ @@ -115,7 +116,7 @@ it('pack new scaffolds a pack that lints, passes its own test and pack test; one // The generated test, run by the checkout's vitest as `pnpm test` would. const vitest = spawnSync( join(repo, 'node_modules', '.bin', 'vitest'), - ['run', '--root', join(root, 'packages', 'core'), 'src/packs/toy-lib'], + ['run', '--root', join(root, 'packages', 'core'), 'src/packs/alpha-lib'], { cwd: root, encoding: 'utf8' }, ); expect(vitest.stdout).toMatch(/1 passed/); diff --git a/packages/core/src/packs/tooling/truth.ts b/packages/core/src/packs/tooling/truth.ts index f1c1c09..4bb6d36 100644 --- a/packages/core/src/packs/tooling/truth.ts +++ b/packages/core/src/packs/tooling/truth.ts @@ -70,8 +70,28 @@ const UNFROZEN: Record = { pnpm: ['install', '--ignore-scripts', '--no-frozen-lockfile'], 'yarn-classic': ['install', '--ignore-scripts', '--non-interactive'], 'yarn-berry': ['install'], + bun: ['install', '--ignore-scripts'], }; +/** + * The package manager to install a ground-truth repository with: `fix`'s (npm, pnpm, Yarn), + * and bun, which `check` reads (its text lockfile) and some public repositories use. + */ +function installerOf(dir: string): { + kind: string; + bin: string; + args: string[]; + env?: Record; +} { + const others = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock']; + if ( + (existsSync(join(dir, 'bun.lock')) || existsSync(join(dir, 'bun.lockb'))) && + !others.some((file) => existsSync(join(dir, file))) + ) + return { kind: 'bun', bin: 'bun', args: ['install', '--frozen-lockfile', '--ignore-scripts'] }; + return packageManager(dir); +} + /** * The repository's own lockfile, frozen, with lifecycle scripts off. A repository whose * lockfile was already out of sync at that commit (it happens: `npm ci` refuses what `npm @@ -83,7 +103,7 @@ async function installDependencies( name: string, options: EnsureOptions, ): Promise<{ manager: string; frozen: boolean }> { - const pm = packageManager(dir); + const pm = installerOf(dir); const env = { ...pm.env, npm_config_manage_package_manager_versions: 'true' }; const timeout = options.timeoutMs ?? 900_000; options.log?.(`installing ${name} with ${pm.kind}, scripts off`); diff --git a/packages/core/src/packs/types.ts b/packages/core/src/packs/types.ts index 3b73920..404651d 100644 --- a/packages/core/src/packs/types.ts +++ b/packages/core/src/packs/types.ts @@ -99,6 +99,12 @@ export interface MigrationPack { supports(from: string, to: string): boolean; transform(text: string, finding: Finding, context: PackContext): TransformResult; guide(finding: Finding, context?: PackContext): string; + /** + * The rule a reported site belongs to, when the pack can say so itself: the plan files the + * site under it whether or not the rule rewrites. Packs built with `definePack` answer from + * their rules; without it the plan classifies by the change (`changeRule`). + */ + ruleOf?(finding: Finding): string | undefined; reviewNotes(context: PackContext): string[]; validateAssisted?( text: string, diff --git a/packages/core/src/packs/zod/ground-truth.json b/packages/core/src/packs/zod/ground-truth.json index 0e00e84..9729e37 100644 --- a/packages/core/src/packs/zod/ground-truth.json +++ b/packages/core/src/packs/zod/ground-truth.json @@ -313,6 +313,12 @@ "rule": "types", "note": "compiler TS6385" }, + { + "file": "packages/designer/src/open-workflow.ts", + "line": 149, + "rule": "generic", + "note": "changed by the upgrade" + }, { "file": "packages/engine/src/run-workflow.ts", "line": 1208, @@ -433,6 +439,12 @@ "rule": "generic", "note": "changed by the upgrade" }, + { + "file": "packages/schema/src/nodes.ts", + "line": 291, + "rule": "types", + "note": "changed by the upgrade" + }, { "file": "packages/schema/src/nodes.ts", "line": 292, @@ -475,6 +487,12 @@ "rule": "types", "note": "compiler TS6385" }, + { + "file": "packages/schema/src/wire-step-plugins.ts", + "line": 86, + "rule": "generic", + "note": "changed by the upgrade" + }, { "file": "packages/schema/src/wire-step-plugins.ts", "line": 87, @@ -487,12 +505,24 @@ "rule": "types", "note": "compiler TS6385" }, + { + "file": "packages/schema/src/wire-step-plugins.ts", + "line": 93, + "rule": "generic", + "note": "changed by the upgrade" + }, { "file": "packages/schema/src/wire-step-plugins.ts", "line": 94, "rule": "types", "note": "compiler TS6385" }, + { + "file": "packages/schema/src/wire-step-plugins.ts", + "line": 97, + "rule": "generic", + "note": "changed by the upgrade" + }, { "file": "packages/schema/src/wire-step-plugins.ts", "line": 104, diff --git a/packages/core/src/packs/zod/verification.json b/packages/core/src/packs/zod/verification.json index fb5186f..2ab89ad 100644 --- a/packages/core/src/packs/zod/verification.json +++ b/packages/core/src/packs/zod/verification.json @@ -1,10 +1,10 @@ { "status": "verified", - "truth": "sha256-ce97dc27050c267807a0765d9a472078fbcef84eb65877d5ecc1597be45f1957", + "truth": "sha256-076a6c920536242c855573c0875e1c6c9f9264e89f53ba46558c9db2d8a6a404", "repos": 3, "breaking": { "precision": 1, - "recall": 0.76, + "recall": 0.7125, "falsePositives": 0 } } diff --git a/scripts/packs-truth.ts b/scripts/packs-truth.ts index aa828c9..5b1ff02 100644 --- a/scripts/packs-truth.ts +++ b/scripts/packs-truth.ts @@ -2,7 +2,8 @@ * A draft of the expected findings for one ground-truth repository, from two sources that are * not Uptide: the repository's own upgrade commit, and its own TypeScript compiler. * - * pnpm packs:truth --to [--directory ] + * pnpm packs:truth --to + * [--directory ] [--also @ ...] * * - changed: every line of `` the upgrade commit changed or removed (`git diff -U0`, * the old side), in source files. Pure insertions have no line in `` and are listed @@ -17,16 +18,21 @@ */ import { execFileSync } from 'node:child_process'; import { + cpSync, existsSync, lstatSync, + mkdirSync, + mkdtempSync, readdirSync, readFileSync, + realpathSync, renameSync, rmSync, symlinkSync, } from 'node:fs'; -import { join, relative } from 'node:path'; -import { createNpmFetcher, ensureRepo } from '@uptide/core'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, relative } from 'node:path'; +import { createNpmFetcher, ensureRepo, maxSatisfying, satisfies, validRange } from '@uptide/core'; import { compilerDiagnostics, type Diagnostic } from './packs-truth-compiler.ts'; const args = process.argv.slice(2); @@ -87,14 +93,14 @@ for (const line of git('diff', '-U0', '--no-color', '--no-ext-diff', commit, upg } // Where the repository has the package: the root's node_modules and each workspace's. -function installs(dir: string, depth = 0): string[] { +function installs(name: string, dir: string, depth = 0): string[] { const found: string[] = []; - const own = join(dir, 'node_modules', ...pkg.split('/')); + const own = join(dir, 'node_modules', ...name.split('/')); if (existsSync(own)) found.push(relative(project, own)); if (depth >= 3) return found; for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory() && !['node_modules', '.git', 'dist', 'build'].includes(entry.name)) - found.push(...installs(join(dir, entry.name), depth + 1)); + found.push(...installs(name, join(dir, entry.name), depth + 1)); return found; } function tsconfigs(dir: string, depth = 0): string[] { @@ -107,23 +113,90 @@ function tsconfigs(dir: string, depth = 0): string[] { found.push(...tsconfigs(join(dir, entry.name), depth + 1)); return found; } -const links = installs(project); const configs = tsconfigs(project); const fetcher = createNpmFetcher(); -const target = await fetcher.fetch(pkg, to); +const work = mkdtempSync(join(tmpdir(), 'uptide-truth-')); + +// The target, and the companions the upgrade moved with it (`--also @ai-sdk/react@4.0.10`). +const swaps: { name: string; version: string }[] = [ + { name: pkg, version: to }, + ...args + .filter((_a, i) => args[i - 1] === '--also') + .map((spec) => { + const at = spec.lastIndexOf('@'); + return { name: spec.slice(0, at), version: spec.slice(at + 1) }; + }), +]; +/** The version of `name` the repository resolves from `from` upward, with its real directory. */ +function consumerCopy(name: string, from: string): { version: string; dir: string } | undefined { + for (let dir = from; ; dir = dirname(dir)) { + const manifest = join(dir, 'node_modules', ...name.split('/'), 'package.json'); + if (existsSync(manifest)) + return { + version: JSON.parse(readFileSync(manifest, 'utf8')).version, + dir: realpathSync(dirname(manifest)), + }; + if (dirname(dir) === dir || !dir.startsWith(root)) return undefined; + } +} + +/** + * A package at a version, as an install of it would leave it: a copy with its own + * `node_modules`, each dependency the repository's copy when that satisfies the declared + * range (peers always the repository's), else the highest version inside the range, fetched + * and assembled the same way. What check does for the target (target-deps.ts). + */ +async function assemble(name: string, version: string, depth = 0): Promise { + const dir = join(work, `${name.replace('/', '__')}@${version}`); + if (existsSync(dir)) return dir; + cpSync((await fetcher.fetch(name, version)).dir, dir, { recursive: true }); + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { + dependencies?: Record; + peerDependencies?: Record; + }; + const link = (dep: string, at: string) => { + const into = join(dir, 'node_modules', ...dep.split('/')); + mkdirSync(dirname(into), { recursive: true }); + if (!existsSync(into)) symlinkSync(at, into); + }; + for (const dep of Object.keys(manifest.peerDependencies ?? {})) { + const own = consumerCopy(dep, project); + if (own) link(dep, own.dir); + } + for (const [dep, range] of Object.entries(manifest.dependencies ?? {})) { + if (!validRange(range)) continue; + // A version the upgrade pinned (`--also`) wins, as one deduplicated copy in its lockfile does. + const pinned = swaps.find((swap) => swap.name === dep && satisfies(swap.version, range)); + const own = consumerCopy(dep, project); + if (pinned) link(dep, await assemble(pinned.name, pinned.version, depth + 1)); + else if (own && satisfies(own.version, range)) link(dep, own.dir); + else if (depth < 4) { + const best = maxSatisfying((await fetcher.versions?.(dep)) ?? [], range); + if (best) link(dep, await assemble(dep, best, depth + 1)); + } + } + return dir; +} + +const links: { at: string; dir: string }[] = []; +for (const swap of swaps) { + const dir = await assemble(swap.name, swap.version); + for (const at of installs(swap.name, project)) links.push({ at, dir }); +} const { compiler: compilerName, diagnostics: baseline } = compilerDiagnostics(project, configs); for (const link of links) { - const at = join(project, link); + const at = join(project, link.at); renameSync(at, `${at}.uptide-orig`); - symlinkSync(target.dir, at); + symlinkSync(link.dir, at); } let after: Diagnostic[]; try { after = compilerDiagnostics(project, configs).diagnostics; } finally { + rmSync(work, { recursive: true, force: true, maxRetries: 2 }); for (const link of links) { - const at = join(project, link); + const at = join(project, link.at); if (lstatSync(at).isSymbolicLink()) rmSync(at); renameSync(`${at}.uptide-orig`, at); } @@ -148,7 +221,7 @@ console.log( upgrade, ...(directory ? { directory } : {}), to, - links, + links: links.map((l) => `${l.at} → ${basename(l.dir)}`), compiledWith: compilerName, tsconfigs: configs, compiler: compiler.map((d) => ({