Skip to content

feat(check): plan what the target version requires; report a major as a blocker (UPT-50) - #142

Merged
lucasbraum merged 3 commits into
mainfrom
claude/eager-archimedes-7sorao
Oct 15, 2026
Merged

lucasbraum merged 3 commits into
mainfrom
claude/eager-archimedes-7sorao

Conversation

@lucasbraum

Copy link
Copy Markdown
Collaborator

What and why

UPT-50, found by the openai pack (#130). The planner moved a package's release group (#135, companions.ts) but never a package the target version requires. openai 7 peers on zod ^3.25 || ^4.0, yet batch-ai stayed on zod 3.24.2.

This builds on #135; it does not change it. A new step, check/required.ts, runs after the release group:

  • What a planned version declares. Take every planned version: the lead's target and each companion it moves. When one declares a peer or dependency range the installed copy rejects, that package moves to the lowest release of its line that satisfies the range. A line is one major, or one minor for 0.x (what a caret range reaches). Example: zod 3.24.2 → 3.25.0.
    • The rule applies only when the upgrade introduces the violation, i.e. the installed host accepted the installed copy. A range the repository already breaks today is not this upgrade's (see morphic below).
  • What depends on the lead. A direct dependency whose own dependency range rejects the lead's target moves the same way, within its line.
  • A required move across a major is never made.
    • A peer or a dependent becomes a blocker with the exact range: openai 7.9.0 needs zod ^4.0.0; installed 3.25.76, and only a new major satisfies it (zod 4.0.0).
    • A plain dependency gets its own nested copy. That is not a blocker.
    • Blockers appear in check (✗ blocked: … a new major, never made by Uptide), in the PR body, and in pack test (✗ blocked: …, and on each with entry they keep behind).
    • fix still runs, leaves the blocked package where it is, and rates the risk High ("a required move is blocked").
  • Shown apart from release-group companions. A required move is a companion marked required, so fix installs it in the same single install. The PR body lists it separately, next to fix(check): move only packages released with the lead; report the rest as staying #135's "Left where they are":
    Upgraded with openai, so the install stays consistent:      ← release group, unchanged
    Moved because the new version requires it:
    - `zod` 3.24.2 → 3.25.0 (moved because openai 7.4.0 requires zod ^3.25 || ^4.0)
    Blocked: a required move only a new major makes, which Uptide never makes:
    - @openai/agents 0.1.11 depends on openai ^5.20.2; only a new major accepts openai 7.9.0 (@openai/agents 0.15.0, which needs zod ^4.0.0; installed 3.25.76)
    Left where they are (not released with it):                  ← #135, unchanged
    
  • The install scope guard allows exactly these moves. The allowed set is still the lead plus its companions, so a planned move is allowed. Testing the other half turned up a gap on npm: a root dependency the user declares (zod) could change without the plan saying so, because npm resolves it as a moved package's peer and so it counted as inside openai's subtree. pnpm and Yarn already refused that. Now on npm too, an importer's declared dependency changes only when the plan names it (lock-guard.ts).

Not in this PR: UPT-54 (@types stub removal) touches the same planner and comes after this.

How it was verified

Failing tests first (the commit test(check): …): 14 tests fail on main's code and pass here, plus one more added with the refinement.

  • check/required.test.ts, synthetic, modeled on feat(packs): add candidate openai 4 to 7 migration pack #130's repositories:
    • batch-ai: zod 3.24.2 → 3.25.0; a prerelease is skipped.
    • openai-guardrails-js: @openai/agents 0.1.11 only reaches openai 7 at 0.15.0, a new 0.x line that needs zod 4. This is a blocker naming both, and neither package is moved.
    • A within-line dependent that moves, plus what its new release then requires.
    • A peer that needs a new major (blocker); a 0.x minor treated as a major.
    • A plain dependency: moves within its major, or keeps a nested copy (no blocker).
    • Workspace visibility (sibling vs. hoisted root).
    • Release-group companions unchanged next to a required move.
    • A requirement the install already breaks (morphic).
  • fix/managers/required-scope.test.ts, for npm, pnpm and Yarn: the lead plus the required move is allowed; the required package moving unplanned is refused (failed on npm before); anything else moving next to the plan is refused.
  • fix/generic.test.ts:
    • fix installs the required move in one install (also), and the PR body names it.
    • With a blocker, fix moves nothing extra, and the PR body names the blocker.
  • PR body and risk (fix/report.test.ts), check output (format-check.test.ts), and pack test accounting (pack-test.test.ts: a required move counts as moved, a blocker is attached to what it keeps behind).

Re-scored every registered pack, before (main 9ba005a) and after (this branch). Same truth, scripts off:

Pack Before: breaking P / R (tp fp fn) After: breaking P / R (tp fp fn) Overall P / R before → after Gate before → after
ai 100% / 82% (9 0 2) 100% / 82% (9 0 2) 100% / 89% → same PASS → PASS
lucide-react 100% / 100% (11 0 0) same 100% / 100% → same PASS → PASS
next 100% / 85% (11 0 2) same 100% / 80% → same PASS → PASS
react 100% / 80% (57 0 14) same 100% / 81% → same FAIL → FAIL ¹
@sentry/browser 100% / 100% (3 0 0) same 100% / 100% → same PASS → PASS
@sentry/nextjs 100% / 100% (8 0 0) same 100% / 100% → same PASS → PASS
@sentry/react 100% / 100% (2 0 0) same 100% / 100% → same PASS → PASS
stripe 100% / 90% (18 0 2) same 100% / 90% → same PASS → PASS
vitest 100% / 50% (16 0 16) same 0% / 50% → same PASS → PASS
zod 100% / 74% (59 0 21) same 100% / 85% → same PASS → PASS

Breaking precision does not drop for any pack.

  • The only plan change: in ai (vercel/chatbot), swr 2.3.3 → 2.4.1 moves because @ai-sdk/react 4.0.10 requires swr ^2.4.1. Its scores are unchanged.
  • The first after-run did drop ai precision: in miurla/morphic, react 19.2.0 → 19.2.1 moved and produced 59 false positives, 4 of them breaking. The installed @ai-sdk/react 3.0.182 already declares react ~19.1.2 || ^19.2.1, so that violation is the repository's today, not the upgrade's. The fix(check): plan only requirements the upgrade introduces commit fixes it, with a regression test. Then ai was re-scored.
  • The other packs were scored on the commit before that fix, which only narrows the rule. None of them had any required move or blocker in that run.

¹ react fails the same way on both sides for an environmental reason: tldraw/tldraw pins yarn@4.7.0 and this container only has Yarn 1.22. The other react repositories score the same.

pack test openai on #130's truth, from throwaway worktrees (main + #130 and this branch + #130, merged locally, never pushed; #130 and its truth are untouched):

  openai/openai-guardrails-js@6c13ba08ab02     openai 4.104.0 → 7.9.0  predicted 5, expected 11, false positives 4, false negatives 10
    ✗ blocked: @openai/agents 0.1.11 depends on openai ^5.20.2; only a new major accepts openai 7.9.0 (@openai/agents 0.15.0, which needs zod ^4.0.0; installed 3.25.76)
    ✗ @openai/agents: the upgrade moved it to 0.17.2, check leaves it (blocked: …)
    ✗ zod: the upgrade moved it to 4.5.4, check leaves it (blocked: …)
  grantsingleton/batch-ai@05c7e9f132b3         openai 4.83.0 → 7.4.0  predicted 0, expected 2, false positives 0, false negatives 2
    with zod 3.24.2 → 3.25.0 (moved because openai 7.4.0 requires zod ^3.25 || ^4.0)
  microsoft/prompty@e878b6b7789f               openai 4.104.0 → 7.10.0  predicted 52, expected 0, false positives 52, false negatives 0
    ✗ blocked: @azure/ai-projects 2.4.0 depends on openai ^6.16.0; no release accepts openai 7.10.0
  breaking (sites)                   43%     15%     3    4   17      (before: 43% 15% 3 4 17)
FAIL openai: 4 false positives among breaking findings
  • batch-ai's zod move is no longer missed. That is UPT-50's done condition. Its upgrade chose 4.4.3; check picks the lowest release of the same major that satisfies the range, and with compares names only.
  • guardrails is the needs-a-major case, and it reports the blocker with the exact ranges. Its real upgrade moved both packages across their majors (agents 0.1 → 0.17, zod 3 → 4). Uptide never makes such a move, so the pack's with stays unmet there, now with the reason.
  • prompty gets a new, accurate blocker: no @azure/ai-projects release accepts openai 7 yet.
  • Scores are unchanged. feat(packs): add candidate openai 4 to 7 migration pack #130 still fails its gate on its 4 compile-scope false positives, its engine gap 2, which is outside this PR.

Repository checks:

  • pnpm lint: only the warning and info that were already there.
  • pnpm typecheck passes.
  • pnpm test: core 1,667 passed / 32 skipped, CLI 453 passed.

Checklist

  • Tests for the change (a bug fix starts with a failing test)
  • pnpm lint && pnpm typecheck && pnpm test pass
  • README and docs updated if behavior changed (docs/architecture.md Companions, docs/packs.md with, README one line)
  • Changeset added if a user would notice (.changeset/required-moves.md, minor)
  • No new dependency, or it was agreed in an issue
  • No private code, names or paths in fixtures, tests or docs (synthetic registries modeled on public repositories)
  • The rules in CONTRIBUTING.md ("Rules that do not bend") still hold (installs with scripts off; nothing moves across a major)

The task brief pointed to AGENTS.md, but there is no AGENTS.md in the repository; I followed CLAUDE.md and CONTRIBUTING.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H9K3U7AeqmsqU2z6wKKxhw


Generated by Claude Code

…(UPT-50)

Synthetic regressions modeled on batch-ai (openai 7 needs zod ^3.25 || ^4;
zod 3.24.2 must move to 3.25.x) and openai-guardrails-js (@openai/agents
0.1.11 needs a new 0.x line, which needs zod 4: a blocker), plus a needs-a-
major peer, a 0.x line, workspace visibility and plain dependencies. The
install scope guard must allow exactly the planned moves on npm, pnpm and
Yarn. These fail on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9K3U7AeqmsqU2z6wKKxhw
Signed-off-by: Claude <noreply@anthropic.com>
… major as a blocker (UPT-50)

Found by the openai pack (#130). After the release group (#135), check
plans what the planned versions require (check/required.ts): a package the
target, or anything moving with it, declares as a peer or dependency at a
range the installed copy rejects moves to the lowest release of its line
that satisfies it; so does a direct dependency whose dependency range
rejects a planned version. A line is one major, one minor for 0.x. A move
only a new major makes is never made: a peer or a dependent is a blocker
with the exact range, in check, the PR body and pack test; a plain
dependency gets its own nested copy.

Required moves are companions marked `required`, shown apart from the
release group ("moved because openai 7.4.0 requires zod ^3.25 || ^4.0").
The npm scope guard no longer lets a declared root dependency change
because a moved package peers on it; pnpm and Yarn already refused that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9K3U7AeqmsqU2z6wKKxhw
Signed-off-by: Claude <noreply@anthropic.com>
Re-scoring the ai pack found morphic: @ai-sdk/react 3.0.182 already peers on
react ~19.1.2 || ^19.2.1 with react 19.2.0 installed, so the new rule moved
react to 19.2.1 and compiled the app at it (59 false positives, 4 breaking).
A range the installed host already broke is not the upgrade's and stays;
dependents are checked against the lead's target only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9K3U7AeqmsqU2z6wKKxhw
Signed-off-by: Claude <noreply@anthropic.com>
@lucasbraum
lucasbraum merged commit 9c57934 into main Oct 15, 2026
16 of 17 checks passed
@lucasbraum
lucasbraum deleted the claude/eager-archimedes-7sorao branch October 15, 2026 00:13
@uptide-release uptide-release Bot mentioned this pull request Oct 15, 2026
lucasbraum added a commit that referenced this pull request Oct 15, 2026
… (UPT-54) (#147)

## What and why

[UPT-54](https://linear.app/uptide/issue/UPT-54): a deprecated `@types`
stub is now removed instead of upgraded.

Before this, upgrading ioredis 4 → 5 moved `@types/ioredis` 4.28.10 to
5.0.0. That release is the stub DefinitelyTyped publishes once a package
ships its own types. It declares nothing, and npm marks it deprecated.
Two things went wrong:
- `check` gave up on the package: `not analyzed: @types/ioredis@5.0.0:
no type declarations in the target, cannot diff`. Stripe 7 → 23 did the
same with `@types/stripe@8.0.417`.
- `fix` would have installed the stub.

This builds on #142 and does not change it. A new step,
`check/removed.ts` next to `check/required.ts`, runs after the release
group and before required moves.

**When a `@types` companion is removed.** Both of these must hold:
- npm's `deprecated` field is set on the `@types` release the plan would
move to. For stripe, only 8.0.417 carries it, so the plan reads the
field on the release it picks.
- The target's own manifest declares types: `types`, `typings`, or a
`types` condition anywhere in `exports`.

Either one alone keeps today's upgrade. A registry that cannot answer
also keeps the upgrade.

**Where the data comes from.**
- `deprecated` comes from the abbreviated packument the planner already
reads. No new request.
- `ownTypes` comes from the full per-version manifest that `metadata`
already fetches. Its cache key moves from `metadata-` to `manifest-` so
old entries without the field are not trusted.

**What the user sees.** The plan gets `removals`, and the report gets
`companionRemovals`, kept apart from companions:
- `check`: `− removed: @types/ioredis, ioredis 5 ships its own types npm
deprecates its new release as a stub; was 4.28.10`. This line shows even
on a row where nothing breaks.
- The PR body gets its own section:
  ```
  Removed:

  - `@types/ioredis`, ioredis 5 ships its own types
  ```

**`check` diffs against the package's own types.** When the plan removes
the stub, the target side of the diff is the package's own declarations,
labeled "types via @types/ioredis 4.28.10 → ioredis 5.4.1's own types".
The baseline is still the installed `@types` release. This is what makes
the stripe case analyzable at all.

**`fix`.**
- Takes the entry out of every workspace that installs it
(`dependencies`, `devDependencies`, `optionalDependencies`). A
`peerDependencies` declaration is a promise to consumers and stays.
- Does it in the same single install.
- Commit message: `chore: upgrade ioredis to 5.4.1, remove
@types/ioredis`.

**The install scope guard allows exactly that removal**
(`lock-guard.ts`, a new `removed` argument):
- An importer's edge to the stub may disappear, never appear or change.
- A lock entry only the stub reached may be deleted, never added or
changed.
- An outside dependent's edge into that subtree is still compared, so a
shared `@types/node` cannot vanish under another package.
- `upgradeInstall` builds both graphs with the removed names among the
targets, so importer declarations of them are not compared as metadata.

## How it was verified

**Failing tests first** (the commit `test(check): …`). Twelve tests fail
on main's code and pass here. The "upgrades as before" cases pass on
both, as they should.
- `check/removed.test.ts`, modeled on npm's real releases:
  - ioredis 4.28.5 → 5.4.1 removes `@types/ioredis` 4.28.10.
- stripe 7.15.0 → 18.0.0 removes `@types/stripe` 7.13.25: the plan would
reach 8.0.417, the deprecated one.
- The upgrade is kept when the release is not deprecated (stripe 8.0.0 →
`@types/stripe` 8.0.416), when the target ships no types, and when
nothing can say whether it does.
- `fix/managers/removed-scope.test.ts`, for npm, pnpm and Yarn:
- The planned removal is allowed, and so is `@types/node` going with it,
since only the stub reached it.
  - The same removal is refused when the plan did not make it.
  - The stub moving to 5.0.0 instead of going is refused.
  - Anything else moving next to the removal is refused.
  - On main's guard, the "allowed" case fails for all three managers.
- `fix/generic.test.ts`: one install with `remove: ['@types/paint']`,
the manifest entry gone, the commit message, and the PR body section.
- `fetch/npm-fetcher.test.ts`: `ownTypes` from `types`, `typings` and a
`types` export, and the `deprecated` message kept in `manifests`.
- `format-check.test.ts`: the removal line on a breaking row and on a
safe row.

**Real runs on synthetic repositories.** Each is a single `src/index.ts`
against the real npm registry, installed with scripts off. They are
throwaway fixtures, not public repositories.
- ioredis ^4.28.5 with `@types/ioredis` ^4.28.10, before: `not analyzed:
@types/ioredis@5.0.0: no type declarations in the target, cannot diff`.
After:
  ```
ioredis not analyzed: no pack yet · compiled against 6.0.0: 0 new type
errors
  ```
The JSON report has `companionRemovals: [{ name: "@types/ioredis", from:
"4.28.10", reason: "ioredis 6 ships its own types" }]` and `typesVia:
"@types/ioredis 4.28.10 → ioredis 6.0.0's own types"`.
- stripe ^7.15.0 with `@types/stripe` ^7.13.25, before: `not analyzed:
@types/stripe@8.0.417: no type declarations in the target, cannot diff`.
After:
  ```
  stripe   1 breaking · compiled against 23.0.0: 1 new type error
✗ apiVersion no longer matches the SDK index.ts:4 needs the agent (LLM)
− removed: @types/stripe, stripe 23 ships its own types npm deprecates
its new release as a stub; was 7.13.25
  ```
- **The real install and guard, with npm 11.** I called `upgradeInstall`
directly; `fix` itself stops earlier on these packless repositories
without an LLM.
- ioredis → 5.4.1, removing `@types/ioredis`, is allowed. The lockfile
lost `@types/ioredis`, `@types/node` and `undici-types`, which only the
stub reached, and `tsc` passes against ioredis 5.4.1's own types.
- stripe → 18.0.0, removing `@types/stripe`, is allowed. `tsc` then
reports exactly the `apiVersion` error check predicted.
- The same removals, with the guard not told, are refused: `Install
changed resolutions outside ioredis's dependency subtree: lockfile
metadata/importers; importer . resolutions; node_modules/@types/ioredis
(removed, was 4.28.10); …`.

**Re-scored every registered pack, against main `0881185` and this
branch.** Same truth, `--offline` after one `packs:fetch`, scripts off.
Every repository's result is byte-identical on both sides: no pack
repository has a deprecated stub in its plan.

| Pack | Breaking P / R (tp fp fn), main → branch | Overall P / R | Gate
|
| --- | --- | --- | --- |
| ai | 100% / 82% (9 0 2) → same | 100% / 89% → same | PASS → PASS |
| lucide-react | 100% / 100% (11 0 0) → same | 100% / 100% → same | PASS
→ PASS |
| next | 100% / 85% (11 0 2) → same | 100% / 80% → same | PASS → PASS |
| react | 100% / 69% (89 0 40) → same | 94% / 70% → same | PASS → PASS |
| react-router | 100% / 88% (14 0 2) → same | 5% / 89% → same | FAIL →
FAIL ¹ |
| @sentry/browser | 100% / 100% (3 0 0) → same | 100% / 100% → same |
PASS → PASS |
| @sentry/nextjs | 100% / 100% (6 0 0) → same | 100% / 100% → same |
FAIL → FAIL ¹ |
| @sentry/react | n/a (0 0 0) → same | → same | FAIL → FAIL ¹ |
| stripe | 100% / 90% (18 0 2) → same | 100% / 90% → same | verified →
verified ² |
| vitest | 100% / 50% (16 0 16) → same | 0% / 50% → same | PASS → PASS |
| zod | 100% / 74% (59 0 21) → same | 100% / 80% → same | verified →
verified ² |

**Breaking precision does not drop for any pack.**

¹ These fail the same way on both sides because one truth repository
each could not be fetched here: Arize-ai/phoenix,
xdoubleu/tools.xdoubleu.com and owine/youth-activity-scheduler. Each
truth entry has a `directory`, and `packs:fetch` reads the repository
root's `package.json` before honoring it (`ENOENT …/package.json`). That
is already the case on main and is outside this PR. unkeyed/unkey logs
the same fetch error but was already in the cache and scored.

² Scored with `fixtures/repos/storefront` installed (`pnpm install
--ignore-scripts --frozen-lockfile` in it, on both sides). Without it,
both sides fail stripe and zod the same way: `has no node_modules`.

**Repository checks:**
- `pnpm lint`: only the warning and info that were already there.
- `pnpm typecheck` passes.
- Tests, per package: core 1,737 passed / 31 skipped; CLI 456 passed.
- In the parallel `pnpm test` run, CLI smoke tests (`reports container
startup failure…`, `still fails a missing denylist…`) and four
`assisted.test.ts` tests hit their timeouts under load. Each passes when
run on its own, here and on main.

## Checklist

- [x] Tests for the change (a bug fix starts with a failing test)
- [x] `pnpm lint && pnpm typecheck && pnpm test` pass (see the timeout
note above)
- [x] README and docs updated if behavior changed
(`docs/architecture.md` Companions and Types via @types, README one
sentence)
- [x] Changeset added if a user would notice
(`.changeset/types-stub-removal.md`, minor)
- [x] No new dependency, or it was agreed in an issue
- [x] No private code, names or paths in fixtures, tests or docs
(synthetic registries and lockfiles modeled on npm's public releases)
- [x] The rules in CONTRIBUTING.md ("Rules that do not bend") still hold
(`check` runs no repository code; installs with scripts off; nothing is
removed that the plan does not name)

Not in this PR:
- `pack test` does not report removals. A truth `with` entry that
upgraded to a stub would read as "check leaves it"; no current truth has
one.
- In `check`'s terminal output, a row where nothing breaks still hides
its release-group companions and #142's blockers (it now shows
removals). That predates this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lucasbraum added a commit that referenced this pull request Oct 15, 2026
## Pack

`ioredis` `>=5 <6` → `>=6 <7` (queue entry `ioredis`, now `blocked`).
**Candidate, not verified.** No breaking false positive on five public
merged upgrades. But none of them has a compiler diagnostic or a
breaking site in application code; the only breaking expected sites are
two lines of test data, which the pack doesn't predict. So the gate (≥1
predicted breaking finding) can't be met honestly. Evidence:
`packages/core/src/packs/ioredis/RESEARCH.md`.

**Rebased on main `3c8902d` (2026-10-15).** The only conflicts were
`registry.ts` (both entries kept, alphabetical) and the generated README
table (regenerated). The rebase also picks up #145's re-recorded
react-router `verification.json`; the Packs workflow had been red on
main since #143 without it.

After the rebase, #143 and #144 changed scoring and the ground-truth
rule:
- I re-ran `pnpm packs:truth` on all five repositories: still no
compiler diagnostic.
- I added the four ioredis insertion anchors it now drafts, unchanged.
- I dropped one anchor from the @sentry/node bump.
- No earlier site was moved or removed.
- The revision is recorded in the truth's `$comment` and in RESEARCH.md.

## Sources

All read 2026-10-14 and pinned:
- the official [Upgrading from v5 to
v6](https://github.com/redis/ioredis/wiki/Upgrading-from-v5-to-v6) (wiki
`e859962`)
- the CHANGELOG and README at `v6.0.0`
- #2127 (RESP3), #2160 (defaults), #2206 (String.prototype, only in
6.1.0-beta.1)
- the BullMQ 6.0.0 changelog
- the published `ioredis` 5.11.1/6.0.0 and `@ioredis/commands`
1.10.0/2.0.0 tarballs, diffed
- the dependents' registry manifests

v6 removes **no** command (373 → 382 methods) and no public export.
Reply shapes and types are unchanged for default (`"legacy"`) clients,
and so are event names and `exec()` types. Breaking changes: Node ≥20,
RESP3 by default, plus new `retryStrategy`/`keepAlive` defaults.

## Rules and behavior notes

**Assisted rules** (no rewrite):
- `bundled-ioredis`: TS2322/2345/2739-41 naming `Redis<"legacy">` not
assignable, i.e. a dependent that installs its own ioredis 5. The guide
gives options (a)–(d), so the agent's choice lands in *Decisions for
you*. Never a cast.
- `duplicate-signature`: TS2416/2430 on `'duplicate'` overrides.

**Finding notes** (syntactic, binding-aware):
- `client-defaults` (deprecated, also a decision): constructions that
leave `protocol`/`retryStrategy`/`keepAlive` at the changed defaults.
Tests are skipped.
- `subscriber-mode` (breaking): `mode === 'subscriber'`.
- `offline-queue-connect` (breaking): a `connect` listener sending a
command when `enableOfflineQueue: false`.
- `handshake-auth` (breaking): AUTH recognized by command name. v6 sends
credentials inside `HELLO 3 AUTH`.

**Decision notes** (with `applies()` evidence):
- `node-20`
- `bundled-copies`: bullmq <6, bull, @hocuspocus/extension-redis,
@chat-adapter/state-ioredis, cache-manager-ioredis-yet
- `instrumentation`: OTel `instrumentation-ioredis` <0.70 /
`auto-instrumentations-node` <0.80
- `stale-types`: `@types/ioredis`
- `string-prototype`: target exactly 6.0.0
- `also-review`

**Not declared:**
- No release group: ioredis ships alone.
- No `requiresBuild`: no case of a build failing where tsc passes.
- Explicit peers rejecting 6 (typeorm, ioredis-mock,
@liaoliaots/nestjs-redis) are left to the engine's peer-conflict report.

## Ground-truth repositories

| Repository | Parent | Upgrade | Findings |
| --- | --- | --- | --- |
| ferni-ai/voiceai | `c59ed09` | `c6838d5` (#703) | 7 `client-defaults`
(protocol: 2 pins, 2 of them insertion anchors) |
| syzygyhack/open-foundry | `7391001` | `a466e0d` | 2 `client-defaults`
(1 anchor) |
| simondietschi/dokunc | `83681ed` | `c475390` (#15) | 2
`client-defaults` (v5 retryStrategy, 1 anchor), 2 `handshake-auth` |
| safe-global/safe-client-gateway | `14bd05b` | `80135d2` (#3409) | 0
(control, Yarn Berry) |
| kazumadev619-dev/FishingConditionsApp | `8ba3f0d` | `cdecc03` (#235) |
0 (control, npm) |

Screened out: elie222/inbox-zero#3821. Its TS2322 (two copies via bullmq
5) is a `packs:truth` linking artifact; a real scripts-off install
compiles it. Its upgrade also moved bullmq 5 → 6.

## `uptide pack test` output

```
uptide pack test ioredis · candidate
fixtures      6 cases, 19 sites · pass
ground truth
  ferni-ai/voiceai@c59ed0982f96                ioredis 5.8.2 → 6.0.0  predicted 6, expected 7, false positives 2, false negatives 3
  syzygyhack/open-foundry@73910018b5e8         ioredis 5.10.1 → 6.0.0  predicted 1, expected 2, false positives 0, false negatives 1
  simondietschi/dokunc@83681ed0ccc5            ioredis 5.11.1 → 6.0.0  predicted 2, expected 4, false positives 0, false negatives 2
    ✗ blocked: @hocuspocus/extension-redis 4.7.0 depends on ioredis ~5.6.1; no release accepts ioredis 6.0.0
  safe-global/safe-client-gateway@14bd05b98bf4 ioredis 5.11.1 → 6.0.0  predicted 0, expected 0, false positives 0, false negatives 0
  kazumadev619-dev/FishingConditionsApp@8ba3f0d24bc7 ioredis 5.11.1 → 6.0.0  predicted 1, expected 0, false positives 1, false negatives 0
    with @types/ioredis 4.28.10 → 5.0.0

  rule                         precision  recall    tp   fp   fn
  client-defaults                    70%     64%     7    3    4
  handshake-auth                     n/a      0%     0    0    2
  overall (sites)                    70%     54%     7    3    6
  behavior notes (sites)             70%     54%     7    3    6
  breaking (sites)                   n/a      0%     0    0    2

PASS ioredis: candidate
```

The dokunc "blocked" line is the engine's own report (#142) of the
bundled hocuspocus copy that `bundled-copies` describes; the repository
upgraded anyway, keeping both copies.

**Remaining false positives:**
- voiceai `:101`/`:92` against truth `:98`/`:89`: the anchor is at the
first line of the rewritten run.
- FishingConditionsApp `client.ts:13`: a client deliberately left on
RESP3.

**Remaining false negatives:**
- voiceai `embedding-cache.ts:134`: the module name is hidden from
static scans.
- voiceai `:98`/`:89` and open-foundry `:136`: anchor offsets.
- dokunc `log.test.ts:20/28`: `handshake-auth`, test data.

**Engine gaps still open** (RESEARCH.md):
1. A runtime-only major can't reach `verified`.
2. Detection reaches only files with static usages.
3. There is no "remove a package" outcome (`@types/ioredis`).
4. `with` can't model a required dependent major.
5. `packs:truth` links per workspace, and `--directory` fails with a
root lockfile.

Gaps 2 and 3 of the original list were resolved by #143 and #144.

## Checklist

- [x] Every rule and note comes from a source listed in `meta.sources`
- [x] Every rule that rewrites or detects has a fixture case, with a
site it must leave alone (`keep`). The two compiler-claim rules are
unit-tested instead, because a `message:` marker can't contain `"`.
- [x] Ground truth: public repositories at the commit before their
upgrade; expected findings read from their own upgrade commit and the
compiler, never copied from Uptide's output
- [ ] At least two public repositories for `verified`; otherwise the
pack ships as a candidate (5 repos, still a candidate: no predicted
breaking site)
- [x] `pnpm uptide pack test ioredis` passes and its output is pasted
above; `verification.json` written with `--write`
- [x] `pnpm lint && pnpm typecheck && pnpm test` pass: core 1727 passed
/ 32 network-skipped, CLI 454 passed; lint at baseline; `docs:packs
--check` current. All 10 CI checks pass on `30579da`, Pack test
included.
- [x] Changeset added
- [x] No private code, names or paths anywhere: fixtures are synthetic,
ground truth is public

No third-party repository was pushed to, commented on or modified. Every
install ran with scripts off, and no bare `npx` was used.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017BYqQRCpXk6jz9vcCnx21H

---------

Signed-off-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
lucasbraum pushed a commit that referenced this pull request Oct 15, 2026
…quired-move fixes

Rebased on main after #138, #142 and #154 and re-ran pack test --write
on the unchanged ground truth: breaking 3 TP / 0 FP / 17 FN (100%
precision, 15% recall). It stays a candidate: check reports
openai-guardrails-js blocked on a required @openai/agents major and
prompty blocked on @azure/ai-projects' openai ^6 dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX2nBoNEvDh9v71yDhEzxL
Signed-off-by: Claude <noreply@anthropic.com>
lucasbraum pushed a commit that referenced this pull request Oct 15, 2026
…quired-move fixes

Rebased on main after #138, #142 and #154 and re-ran pack test --write
on the unchanged ground truth: breaking 3 TP / 0 FP / 17 FN (100%
precision, 15% recall). It stays a candidate: check reports
openai-guardrails-js blocked on a required @openai/agents major and
prompty blocked on @azure/ai-projects' openai ^6 dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX2nBoNEvDh9v71yDhEzxL
Signed-off-by: Claude <noreply@anthropic.com>
lucasbraum pushed a commit that referenced this pull request Oct 15, 2026
…quired-move fixes

Rebased on main after #138, #142 and #154 and re-ran pack test --write
on the unchanged ground truth: breaking 3 TP / 0 FP / 17 FN (100%
precision, 15% recall). It stays a candidate: check reports
openai-guardrails-js blocked on a required @openai/agents major and
prompty blocked on @azure/ai-projects' openai ^6 dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX2nBoNEvDh9v71yDhEzxL
Signed-off-by: Claude <noreply@anthropic.com>
lucasbraum pushed a commit that referenced this pull request Oct 15, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## uptide@0.13.0

### Minor Changes

- [#134](#134)
[`b795705`](b795705)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Packs can name
alternate leads: packages a repository declares instead of the pack's
own (`react-router-dom` for `react-router`). `check` and `fix` asked for
either name use the pack; where only the alternate is declared, the lead
is analyzed at the version installed through it, pnpm stores included,
and `fix` declares it beside the alternate at the target before
upgrading both.

- [#149](#149)
[`3303118`](3303118)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a candidate
express 4 to 5 migration pack. Route strings that path-to-regexp 8
rejects or reads differently are found on calls of an Express app or
router (including lists registered in a loop); `req.params` values
widened to `string | string[]`, `req.param`, `res.sendfile`,
`express.static.mime` and `express.query` are claimed from the compiler;
`res.redirect(url, status)`, `app.param(fn)`, `res.redirect('back')` and
`res.send(<number>)` are found in the source; runtime defaults (async
errors, `req.body`, the query parser, `urlencoded`, dotfiles, Node 18)
go to decisions. No rule rewrites. With the pack in use, `check` no
longer adds its text-matched express 5 runtime findings.

- [#148](#148)
[`5143c46`](5143c46)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a candidate
joi 17 to 18 migration pack. `new ValidationError(message, {},
original)`, which stops compiling for code written against joi
17.0–17.6, is rewritten to pass `[]`; the Node.js 20 requirement and the
top-level domains joi 18 no longer accepts in `email()`, `domain()` and
`uri()` are reported as decisions. Scored against two public
repositories, one of them a negative control.

- [#131](#131)
[`413d3da`](413d3da)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a verified
lucide-react 0.x to 1.x migration pack. Brand icons removed in 1.x are
found at their imports and every use, with a guide whose replacement
choice goes to the owner; icon names dropped during 0.x are renamed at
the import or member; deep `dist/` imports, untyped icon lookups, the
`absoluteStrokeWidth` and `IconNode` deprecations, and the `aria-hidden`
default for repositories before 0.489.0 are reported. Lucide's lockstep
packages move together and the consumer's build is required. Verified
against three public repositories, one of them a negative control.

- [#132](#132)
[`107c107`](107c107)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a verified
React Router 6 → 7 migration pack, which also runs for applications that
declare only `react-router-dom`. It moves `react-router-dom` imports to
`react-router`. It reports, with a guide for the agent, the rest of the
upgrade: the removed `future` prop and router option,
`react-router-dom/server`, lowercase `formMethod` comparisons and
`json`/`defer` calls (found by syntax where the compiler is blind), the
other removed exports, and `generatePath` calls that pass `null` for a
required parameter. The six v6 future flags that become defaults are
reported as decisions where the repository's router setup has not
enabled them. React Router 7 → 8 is a separate upgrade.

- [#142](#142)
[`9c57934`](9c57934)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check` and `fix`
plan what the target version requires: a package the target, or anything
moving with it, declares as a peer or dependency at a range the
installed copy rejects moves to the lowest release of its major that
satisfies it (openai 7 moves zod 3.24.2 to 3.25.0), shown apart from
release-group companions ("moved because openai 7.4.0 requires zod ^3.25
|| ^4.0"). A move only a new major makes is never made: it is reported
as a blocker with the exact range in `check`, the PR body and `pack
test`. The npm install scope guard no longer lets a declared root
dependency change because a moved package peers on it.

- [#128](#128)
[`06f11ab`](06f11ab)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add verified
Sentry JavaScript SDK 10 to 11 migration packs for `@sentry/nextjs` and
`@sentry/browser`, with the SDK's lockstep release group, a required
build for Next.js, and inventory-scoped decisions for data collection,
span streaming, sampling and platform minimums. Mechanical rules move
`withSentryConfig` to `@sentry/nextjs/config` and the removed build
options under `webpack`, remove `enableLogs`/`enableMetrics: true`, and
rename exports, integration names and `httpIntegration` options;
privacy, sampling, profiling and OpenTelemetry choices stay assisted and
are reported for the owner. Fix `uptide pack new` dropping registry
entries long enough to be wrapped.

- [#155](#155)
[`922c5ab`](922c5ab)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide pack
test` applies a stricter verified gate: at least five expected breaking
sites across the public ground-truth repositories, besides two
repositories and no breaking false positive. Upgrades the compiler
cannot see are gated on their behavior notes instead. Mechanical rules
no real site exercises are recorded as unexercised, and `check` and
`fix` run their sites as assisted. `@sentry/browser` and `@sentry/react`
become candidates (fewer than five real breaking sites). A pack can
lower the minimum only with a written reason in its RESEARCH.md. The
README packs table shows each pack's breaking-site count. The output
shows the gate's class, site counts and every reason a pack is a
candidate.

- [#147](#147)
[`0b88170`](0b88170)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check` and `fix`
remove a deprecated `@types` stub instead of upgrading to it. When the
release the upgrade would move `@types/<pkg>` to is deprecated on npm,
and `<pkg>`'s target declares its own types (`types`, `typings` or a
`types` export), the plan removes it: "Removed: @types/ioredis, ioredis
5 ships its own types", in `check` and in the PR body. `check` now diffs
such a package against its own target types instead of failing on a stub
with no declarations, and `fix` removes the entry in the same install,
with the install scope guard allowing exactly that removal.

- [#150](#150)
[`4c3212b`](4c3212b)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check
typescript` judges a TypeScript 5 to 6 upgrade with both compilers:
every project of the workspace is compiled with the installed TypeScript
and with the target's, and what is new at the target is a finding; the
bundled compiler never stands in, and a target without a JavaScript API
(TypeScript 7) is reported as not compiled. A candidate typescript 5 to
6 pack reports the tsconfig options 6.0 deprecates where they are set,
the rootDir default, module-keyword namespaces and import assertions. A
peer that rejects the target (typescript-eslint on TypeScript 6) is now
reported as a peer conflict even when it is not grouped with the
package.

- [#137](#137)
[`21894d7`](21894d7)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add
`verify.build` to uptide.yml: the package.json script a migration pack's
required build runs. Unset, Uptide runs `build:ci`, then `build-ci`,
then `build`. When a build fails before the framework's build starts
because it needs an environment variable or a database (`prisma db push`
without `DATABASE_URL`), the pull request and the report say so ("build
needs DATABASE_URL; set verify.build") instead of a bare "build failed".
`uptide init` writes the setting as a comment.

### Patch Changes

- [#141](#141)
[`9debb6d`](9debb6d)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check` no longer
reports a pre-existing type error as a breaking site when the upgrade
only changes its wording, such as a type the message prints in full. An
error at the same place with a different reason is still reported.

- [#129](#129)
[`e796e4c`](e796e4c)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a candidate
ioredis 5 to 6 migration pack: client constructions that inherit the
RESP3, reconnect and keep-alive defaults ioredis 6 changed,
compiler-blind sites (`mode === 'subscriber'`, commands in a `connect`
listener with the offline queue off, AUTH recognized by command name),
dependents that bundle ioredis 5, instrumentation that stops patching
ioredis 6, and Node 20. It is scored against five public upgrades and
stays a candidate: none of them has a breaking site to verify it with.

- [#139](#139)
[`9ba005a`](9ba005a)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Allow local check
and fix smoke tests of registered candidate packs with
--allow-candidate, with an explicit unverified banner. Reject candidate
opt-in in the Action and with publication flags, and prevent stored
candidate runs from being published. Label pack-claimed assisted sites
by rule id even with --no-llm.

- [#157](#157)
[`f3af6a1`](f3af6a1)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check` no longer
reports a blocker for a direct dependency whose plain dependency range
rejects the target (`@azure/ai-projects` on `openai ^6`, verdaccio
pinning express 4): the package manager gives it its own nested copy, so
it is listed as staying, with the new major that would accept the target
when there is one. `pack test` counts a `with` move that `check` leaves
and explains (a blocker, a new-major stay or a peer conflict) as
explained, and one of a package that does not depend on the lead as the
repository's choice; neither fails the run.

- [#130](#130)
[`5ced450`](5ced450)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a candidate
openai 4 to 7 migration pack: named path parameters, `del` → `delete`,
promoted beta chat helpers, renamed runner events, removed entry points
and request-option overloads are rewritten where unambiguous; tool-call
unions, `httpAgent`, `fileFromPath` and the other removals get agent
guides; `APIError.headers`, Web `Response` bodies and double-encoded
path ids are detected even in code typed `any`; Node 22, zod, fetch and
client-subclass decisions carry repository evidence. It stays a
candidate (the dependency is still checked and fixed as generic) until
two engine gaps its ground truth exposed are closed.

- [#136](#136)
[`578a042`](578a042)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Fold implicit-any
errors (TS7006, TS7031, TS7019) inside an option the target removed into
the pack finding on that option, as downstream evidence: the callbacks
lost the types the option gave them, and the same edit resolves them.
They no longer stand as separate breaking sites.

- [#143](#143)
[`883e587`](883e587)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide pack
test` scores each expected site at the severity of the rule or behavior
note it belongs to, so a missed note that only needs a look, or a
decision, no longer counts as a missed break. Behavior-note sites get
their own precision and recall in the output and in `verification.json`
(`behavior`, with the counts behind each rate), false negatives show
their severity, and a rate with nothing to measure prints `n/a` instead
of 100%, in `pack test` and in the README's packs table.

- [#154](#154)
[`7aa3d58`](7aa3d58)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide pack
test` fails a pack's `verification.json` only when it claims more than
the run measures (recorded verified, measured candidate). A pack
recorded as candidate on purpose passes, with `recorded candidate by
choice; measures verified`, and a record whose numbers differ gets a
note instead of a failure. A `generic` ground-truth site now counts as a
true positive for the rule that claims it.

- [#152](#152)
[`ca31aee`](ca31aee)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide pack
test` takes several packs: `uptide pack test zod ai`.

- [#159](#159)
[`1e157fd`](1e157fd)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide pack test
--json` times each ground-truth repository: `timing` gives the fetch,
install and check wall time and the process's peak memory.

- [#144](#144)
[`0881185`](0881185)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Ground truth can
name a site for an upgrade that only adds lines inside an existing call,
such as an option added to a constructor: the site is the call's start
line, so a pack finding there scores as a true positive instead of a
false positive. `pnpm packs:truth` drafts these anchors from insert-only
hunks for review, and docs/packs.md and CONTRIBUTING describe the rule.

- [#156](#156)
[`9039e3c`](9039e3c)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Public ground
truth now exercises three rules that ran as assisted: zod
`error-params`, stripe `api-version-unpinned` and next
`stable-cache-exports`. Their rewrites run mechanically again. All three
packs stay verified.

- [#138](#138)
[`0db5640`](0db5640)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Respect
repository tsconfig file selection, recursive project references, and
workspace project options when checking dependency upgrades. Compiler
diagnostics outside every TypeScript project no longer produce breaking
findings; pack rules and build-configuration findings retain their own
scope. Repositories without a tsconfig retain importer-based
compilation, with that fallback stated in the report.
  
Anchor missing members supplied by dependency augmentations at their
setup import, retaining affected calls as evidence instead of counting
each call as a separate breaking edit.

- [#151](#151)
[`9ddaaff`](9ddaaff)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Add a candidate
redis (node-redis) 4 to 5 and 5 to 6 migration pack, one rule set per
major: an upgrade gets the rules of the majors it crosses, so 4 to 6
gets both. 4 to 5 covers `legacyMode`, `commandOptions` and isolation,
scan iterators that yield batches, string SCAN cursors, boolean replies
that became numbers, enums that became constants, `keepAlive`,
`@redis/graph` and internal `dist` imports; 5 to 6 covers the RESP3
client type, `unstableResp3`, and the client defaults 6 changes (RESP3,
command timeout, keep-alive delay, maintenance notifications) with a
decision per client. Scored against five public upgrades; it stays a
candidate until the stricter verified gate lands, since the core 4 to 5
API changes have no real site yet.

- [#135](#135)
[`42a3b54`](42a3b54)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Move only
packages released with the upgraded one. A pack's declared release group
now bounds the lead's npm scope, and without one a same-scope package
moves only when a pin, a peer or a release at the target relates it to
the group. Packages of the scope that are versioned on their own
(`@sentry/vite-plugin` 5.x, `@sentry/cli`, the old `@sentry/utils` 8.x)
are reported as "stays at X" in `check`, the pull request and `pack
test` instead of making `fix` refuse the whole upgrade. Registers the
verified `@sentry/react` 10 to 11 pack.

- [#146](#146)
[`09b8682`](09b8682)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Report Stripe
clients pinned to different API versions at the top of `check` and of
the PR body, as a decision for the owner: every `new Stripe(...)` in
TypeScript and JavaScript sources, scripts included, with the version it
speaks after the upgrade (an unpinned client speaks the SDK default).
The PR's decisions list, for each client behind the target, the
changelog entries it would cross for the resources it calls. No pin is
rewritten.

- [#140](#140)
[`bc428d1`](bc428d1)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - Keep migration PR
bodies within their size budget while preserving summaries,
representative diffs, decisions, review warnings and verification ahead
of changelog and transitive detail. Keep every evidenced Stripe change
visible and uncapped; group and cap only other changes to used
resources, link the full changelog, and identify omitted text in the
retained report.

- [#145](#145)
[`3c8902d`](3c8902d)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - The zod pack
migrates `ZodError#errors` to `.issues` by rule: reads
(`error.errors[0].message`, `error?.errors`) and destructuring (`const {
errors } = error` becomes `const { issues: errors } = error`) where the
type checker resolves the receiver to `ZodError`, and reads off an
untyped catch binding of a zod parse. Writes are left to the agent.

- [#127](#127)
[`c4ead4f`](c4ead4f)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - zod: `check` now
reports `ZodError#formErrors` and `#errors` read off an untyped catch
binding of a zod `parse`/`parseAsync` (`catch (error: any) { return
error.formErrors.fieldErrors }`), which zod 4 removed and the compiler
cannot see. Files that only import a schema from the repository's own
module (`@/lib/zod`) are covered too. `fix` rewrites
`<err>.formErrors.fieldErrors` and `<err>.formErrors.formErrors` to
`z.flattenError(<err>)`, importing `z` where it is missing; every other
read goes to the agent.

Co-authored-by: uptide-release[bot] <338861287+uptide-release[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants