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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/react-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"uptide": minor
---

A verified migration pack for React 18 → 19, scored against excalidraw/excalidraw and
tldraw/tldraw at the commit before their own upgrade. Rewrites by rule: `useRef<T>()` →
`useRef<T>(undefined)`, `ref={(el) => (x = el)}` → a block body, and `React.MutableRefObject`
→ `React.RefObject`. `RefObject<T | null>`, the removed global `JSX` namespace, untyped
`element.props`, the removed react-dom APIs and `PropsWithRef` go to the agent with the guide;
what the compiler cannot see (errors no longer re-thrown, removed legacy APIs, `act` moved to
"react", Strict Mode and Suspense changes) is listed for review. 2 public repositories, 100%
precision on breaking findings (89 sites, none false), recall 69% on breaking sites; the
sites it misses are listed in the pack test output.
5 changes: 5 additions & 0 deletions .github/workflows/packs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
name: Pack test
runs-on: ubuntu-latest
timeout-minutes: 60
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -55,6 +57,9 @@ jobs:
path: ~/.cache/uptide
key: packs-${{ runner.os }}-${{ hashFiles('packages/core/src/packs/*/ground-truth.json') }}
restore-keys: packs-${{ runner.os }}-
# Ground-truth repositories that pin Yarn Berry via packageManager (tldraw) install with it.
- if: steps.scope.outputs.run == 'true'
run: corepack enable
- name: uptide pack test
if: steps.scope.outputs.run == 'true'
run: |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ verified with two or more public repositories and no false positive. The contrac
| Package | Range | Precision | Recall | Ground-truth repositories | Status |
| --- | --- | ---: | ---: | --- | --- |
| `ai` | 6.x → 7.x | 100% | 36% | vercel/chatbot, miurla/morphic | verified |
| `react` | 18.x → 19.x | 100% | 69% | excalidraw/excalidraw, tldraw/tldraw | verified |
| `stripe` | 14 and newer | 100% | 90% | unkeyed/unkey, nextjs/saas-starter | verified |
| `zod` | 3.x → 4.x | 100% | 71% | ugurkocde/AwesomeIntune, howardyang2009/PATH, band-ai/band-sdk-typescript | verified |
<!-- packs:end -->
Expand Down
32 changes: 16 additions & 16 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,23 +317,23 @@ describe('uptide fix', () => {
it('a generic package without an agent: says what it cannot do, and nothing happens', async () => {
for (const [argv, env, why, next] of [
[
['fix', '--only', 'react'],
['fix', '--only', 'vitest'],
{},
'no selected-provider API key is set',
'Next: npx uptide fix react',
'Next: npx uptide fix vitest',
],
[
['fix', '--only', 'react', '--no-llm'],
['fix', '--only', 'vitest', '--no-llm'],
{ ANTHROPIC_API_KEY: 'test-key' },
'assisted fixes are off (--no-llm)',
'Next: npx uptide check react --details',
'Next: npx uptide check vitest --details',
],
] as const) {
const engine = fakeEngine();
const io = memoryIo({ cwd: pnpmGitRepo(), env });
expect(await run([...argv], io, engine)).toBe(2);
expect(io.stderr()).toContain(
`error: react has no migration pack, so every fix would come from the agent, and ${why}`,
`error: vitest has no migration pack, so every fix would come from the agent, and ${why}`,
);
expect(io.stderr()).toContain('Nothing was changed: no clone, no branch, no install.');
expect(io.stderr()).toContain(next);
Expand All @@ -346,18 +346,18 @@ describe('uptide fix', () => {
'package.json': JSON.stringify({
name: 'shop',
packageManager: 'pnpm@10.17.1',
dependencies: { react: '^18.0.0' },
dependencies: { vitest: '^4.0.0' },
}),
'node_modules/react/package.json': '{"name":"react","version":"18.3.1"}',
'node_modules/vitest/package.json': '{"name":"vitest","version":"4.1.11"}',
});
const engine = fakeEngine({
declared: async () => new Map([['react', '^18.0.0']]),
installed: async () => new Map([['react', '18.3.1']]),
declared: async () => new Map([['vitest', '^4.0.0']]),
installed: async () => new Map([['vitest', '4.1.11']]),
fix: async (request) => {
engine.calls.push(request);
return {
...fixReport(true),
package: 'react',
package: 'vitest',
tier: 'generic',
// One edit the agent made before the limit: the tier note is about its edits.
sites: [
Expand All @@ -366,8 +366,8 @@ describe('uptide fix', () => {
rule: 'agent',
reason: 'compiler error resolved',
finding: {
usage: { file: 'src/app.tsx', line: 1 },
change: { from: '18.3.1', path: 'FC' },
usage: { file: 'src/app.test.ts', line: 1 },
change: { from: '4.1.11', path: 'FC' },
},
} as unknown as FixReport['sites'][number],
],
Expand All @@ -382,10 +382,10 @@ describe('uptide fix', () => {
},
});
const io = memoryIo({ cwd, env: { ANTHROPIC_API_KEY: 'test-key' } });
await run(['fix', '--only', 'react'], io, engine);
expect(engine.calls).toEqual([expect.objectContaining({ only: 'react', llm: true })]);
await run(['fix', '--only', 'vitest'], io, engine);
expect(engine.calls).toEqual([expect.objectContaining({ only: 'vitest', llm: true })]);
expect(io.stderr()).toContain(
'react has no migration pack (generic tier): every fix comes from the agent, up to $1.00 (--max-cost)',
'vitest has no migration pack (generic tier): every fix comes from the agent, up to $1.00 (--max-cost)',
);
expect(io.stdout()).toContain(
" Tier generic: no migration pack; every edit is the agent's, kept on the compiler's word. Review each one.",
Expand All @@ -396,7 +396,7 @@ describe('uptide fix', () => {
expect(io.stderr()).toContain('LLM spend: $0.60\n');
// --max-cost reaches the engine; nonsense is refused.
await run(
['fix', '--only', 'react', '--max-cost', '2.5'],
['fix', '--only', 'vitest', '--max-cost', '2.5'],
memoryIo({ cwd, env: { ANTHROPIC_API_KEY: 'k' } }),
engine,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/fix/isolate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ it('refuses generic no-key fixes and unsafe config before creating any clone or
const before = snapshot(root);
const clones = existsSync(runsRoot()) ? readdirSync(runsRoot()) : [];
try {
await expect(isolatedFix({ cwd: root, only: 'react' }, services)).rejects.toMatchObject({
await expect(isolatedFix({ cwd: root, only: 'vitest' }, services)).rejects.toMatchObject({
code: 'NO_FIXER',
});
expect(snapshot(root)).toEqual(before);
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/packs/react/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { type ArrowFunction, Node, 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 an implicit return in a ref callback is: `ref={(el) => (inputRef.current = el)}`.
* React 19 lets a ref callback return a cleanup function, so its types reject any other
* return value, and an arrow function that assigns returns the assigned value. No type diff
* reports it (the prop's type is the same), and Text alone cannot tell a `ref` attribute from
* an object key named `ref`; the syntax tree can.
*/
const parsed = new Map<string, SourceFile>();
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;
}

interface RefAssignment {
attribute: Node;
arrow: ArrowFunction;
/** The assignment the arrow returns, without the parentheses around it. */
assignment: Node;
}

/** `ref={(el) => (x = el)}`: a `ref` attribute whose arrow function returns an assignment. */
function refAssignments(source: SourceFile): RefAssignment[] {
const found: RefAssignment[] = [];
for (const attribute of source.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
if (attribute.getNameNode().getText() !== 'ref') continue;
const initializer = attribute.getInitializer();
if (!initializer || !Node.isJsxExpression(initializer)) continue;
const arrow = initializer.getExpression();
if (!arrow || !Node.isArrowFunction(arrow)) continue;
let body: Node = arrow.getBody();
while (Node.isParenthesizedExpression(body)) body = body.getExpression();
if (
Node.isBinaryExpression(body) &&
body.getOperatorToken().getKind() === SyntaxKind.EqualsToken
)
found.push({ attribute, arrow, assignment: body });
}
return found;
}

export function refCallbackReturnSites(text: string): SourceSite[] {
if (!text.includes('ref')) return [];
const source = parse(text);
return refAssignments(source).map(({ attribute }) => {
const { line, column } = source.getLineAndColumnAtPos(attribute.getStart());
return {
line,
column,
snippet: (text.split('\n')[line - 1] ?? '').trim(),
name: 'ref',
};
});
}

/** `(el) => (x = el)` becomes `(el) => { x = el; }`: the same assignment, nothing returned. */
export function dropRefCallbackReturn(
text: string,
finding: Pick<Finding, 'usage'>,
): TransformResult {
const source = parse(text);
for (const { attribute, arrow, assignment } of refAssignments(source)) {
const at = source.getLineAndColumnAtPos(attribute.getStart());
if (at.line !== finding.usage.line || at.column !== finding.usage.column) continue;
const body = arrow.getBody();
return {
text: `${text.slice(0, body.getStart())}{ ${assignment.getText()}; }${text.slice(body.getEnd())}`,
applied: true,
reason: 'a ref callback returns nothing: the assignment becomes a statement',
};
}
return { text, applied: false, reason: 'the reported site is not a ref callback that assigns' };
}
10 changes: 10 additions & 0 deletions packages/core/src/packs/react/fixtures/mutable-ref-object/after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import type { MutableRefObject } from 'react';

export type Handle = {
timer: React.RefObject<number | undefined>; // @uptide mutable-ref-object at:React.MutableRefObject kind:deprecated path:MutableRefObject
// Imported by name: the import needs RefObject beside it, which is not the reported site.
data: MutableRefObject<string>; // @uptide mutable-ref-object keep at:MutableRefObject kind:deprecated path:MutableRefObject
// Already the new type.
box: React.RefObject<HTMLDivElement | null>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import type { MutableRefObject } from 'react';

export type Handle = {
timer: React.MutableRefObject<number | undefined>; // @uptide mutable-ref-object at:React.MutableRefObject kind:deprecated path:MutableRefObject
// Imported by name: the import needs RefObject beside it, which is not the reported site.
data: MutableRefObject<string>; // @uptide mutable-ref-object keep at:MutableRefObject kind:deprecated path:MutableRefObject
// Already the new type.
box: React.RefObject<HTMLDivElement | null>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRef } from 'react';

export function Field({ onMount }: { onMount: (el: HTMLInputElement | null) => void }) {
const input = useRef<HTMLInputElement | null>(null);
let last: HTMLInputElement | null = null;
return (
<form>
<input
ref={(el) => { input.current = el; }} // @uptide ref-callback-return at:ref
/>
<input
ref={(el) => { last = el; }} // @uptide ref-callback-return keep at:ref
/>
<input
ref={(el) => { last = el; }} // @uptide ref-callback-return at:ref
name="b"
/>
<input ref={input} />
<input ref={(el) => onMount(el)} />
<input ref={(el) => (el ? (last = el) : null)} />
</form>
);
}

// An object key named ref is not a ref prop.
export const config = { ref: (el: unknown) => (el as { x: number }).x };
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRef } from 'react';

export function Field({ onMount }: { onMount: (el: HTMLInputElement | null) => void }) {
const input = useRef<HTMLInputElement | null>(null);
let last: HTMLInputElement | null = null;
return (
<form>
<input
ref={(el) => (input.current = el)} // @uptide ref-callback-return at:ref
/>
<input
ref={(el) => { last = el; }} // @uptide ref-callback-return keep at:ref
/>
<input
ref={(el) => (last = el)} // @uptide ref-callback-return at:ref
name="b"
/>
<input ref={input} />
<input ref={(el) => onMount(el)} />
<input ref={(el) => (el ? (last = el) : null)} />
</form>
);
}

// An object key named ref is not a ref prop.
export const config = { ref: (el: unknown) => (el as { x: number }).x };
13 changes: 13 additions & 0 deletions packages/core/src/packs/react/fixtures/use-ref-argument/after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import { useRef } from 'react';

export function Timer() {
const timer = useRef<number>(undefined); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const handler = React.useRef<(...args: string[]) => void>(undefined); // @uptide use-ref-argument at:React.useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const anything = useRef(undefined); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Already has an argument: nothing to add.
const input = useRef<HTMLInputElement>(null); // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Two calls on one line: the reported site is ambiguous, so the rule declines.
const pair = [useRef<number>(), useRef<string>()]; // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
return { timer, handler, anything, input, pair };
}
13 changes: 13 additions & 0 deletions packages/core/src/packs/react/fixtures/use-ref-argument/before.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import { useRef } from 'react';

export function Timer() {
const timer = useRef<number>(); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const handler = React.useRef<(...args: string[]) => void>(); // @uptide use-ref-argument at:React.useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const anything = useRef(); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Already has an argument: nothing to add.
const input = useRef<HTMLInputElement>(null); // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Two calls on one line: the reported site is ambiguous, so the rule declines.
const pair = [useRef<number>(), useRef<string>()]; // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
return { timer, handler, anything, input, pair };
}
Loading
Loading