diff --git a/.changeset/redis-candidate-pack.md b/.changeset/redis-candidate-pack.md new file mode 100644 index 00000000..7ff231c5 --- /dev/null +++ b/.changeset/redis-candidate-pack.md @@ -0,0 +1,5 @@ +--- +'uptide': patch +--- + +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. diff --git a/README.md b/README.md index 5768ce85..15feb9a3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ verified with two or more public repositories and no false positive. The contrac | `next` | 15.x → 16.x | 100% | 85% | sambecker/exif-photo-blog, stoczec/wortkarte | verified | | `react` | 18.x → 19.x | 100% | 69% | excalidraw/excalidraw, tldraw/tldraw | verified | | `react-router` | 6.x → 7.x | 100% | 88% | Arize-ai/phoenix, getlago/lago-front | verified | +| `redis` | >=4 <6 → >=5 <7 | n/a | n/a | deanmoses/tacocat-gallery-sam, swoofer/mcp-coordinator, deanmoses/tacocat-gallery-sam, UNN-Devotek/FCM-Fallout-Chat-Mod, tearingItUp786/blog | candidate | | `@sentry/browser` | 10.x → 11.x | 100% | 100% | Automancer-Ltd/automancer-site, sbomify/sbomify | verified | | `@sentry/nextjs` | 10.x → 11.x | 100% | 100% | cohstats/coh3-stats, xdoubleu/tools.xdoubleu.com, maximunited/yomu | verified | | `@sentry/react` | 10.x → 11.x | 100% | 100% | owine/youth-activity-scheduler, successible/cleanslate | verified | diff --git a/packages/core/src/packs/redis/RESEARCH.md b/packages/core/src/packs/redis/RESEARCH.md new file mode 100644 index 00000000..e922a3da --- /dev/null +++ b/packages/core/src/packs/redis/RESEARCH.md @@ -0,0 +1,150 @@ +# redis (node-redis) 4 → 5 → 6: evidence and verification limits + +The npm package `redis` (node-redis), not ioredis (a separate pack). Two majors, two rule sets in +one pack: an upgrade gets the rules and notes of the majors it crosses. 4.7 → 6.x in one step is +what Dependabot proposes to every 4.x user, and three of the six real upgrades found did exactly +that, so splitting the majors into two packs would have left the most common upgrade with no pack +(and pack lookup is one pack per package). `appliesTo` in `index.ts` gates detection, `ruleOf` and +notes on the crossed majors; `transform` refuses a 4 → 5 rewrite on code already on 5. + +## Source ledger + +All read 2026-10-14. + +| Source | Pin | +|---|---| +| `docs/v4-to-v5.md`, `docs/v5-to-v6.md`, `docs/RESP.md` | node-redis `b64bacbd85642223f0a98c2d91cbcffdb9fb2052` (master) | +| Release notes `redis@5.0.0` (2025-04-30), `redis@6.0.0` (2026-05-28) | GitHub releases | +| Published declarations and builds | @redis/client 1.6.1, 5.12.1, 6.3.0; @redis/search 1.2.0, 5.12.1; @redis/time-series 1.1.0, 5.12.1 (npm tarballs) | +| `redis` manifests 4.7.0 … 6.3.0 | npm registry: dependencies, `engines` | + +Release group: every `redis` release depends on `@redis/client`, `@redis/bloom`, `@redis/json`, +`@redis/search` and `@redis/time-series` at its own exact version (4.x: the 1.x line plus +`@redis/graph`; 5.0.0 onward: all five at the `redis` version). `@redis/graph` is not in 5. + +Node: 5.0–5.11 `>= 18`, 5.12 `>= 18.19.0`, 6.x `>= 20.0.0`. + +## Classified changes + +Compiler-visible = a typed consumer gets a diagnostic. "Rewrite" only where the edit is 1:1 for +every surrounding code shape the rule accepts; anything else is assisted. + +### 4 → 5 + +| Change | Compiler | Rule / note | Fix | +|---|---|---|---| +| `legacyMode` removed, `client.legacy()` | yes (excess property) | `legacy-mode` + detect | assisted | +| `commandOptions()` first argument, `isolated`, `executeIsolated`, `isolationPoolOptions` | yes | `command-options` + detect | assisted (`withCommandOptions`, `createClientPool`) | +| scan iterators yield batches | only downstream, in typed code | `scan-iterators`, detect only | rewrite: nested loop; declined when the body `break`s/`continue`s the loop | +| `socket.keepAlive: number` → `keepAlive` + `keepAliveInitialDelay` | yes | `keep-alive` + detect | rewrite for a numeric literal | +| SCAN/HSCAN/SSCAN/ZSCAN cursor is a string | yes when typed; `cursor !== 0` loops forever in JS | `scan-cursor` + detect | assisted | +| boolean replies → number (SETNX, EXPIRE, HEXISTS, SISMEMBER, …) | only where used as boolean | `number-replies` | assisted | +| enums → constants (17), `SearchOptions` → `FtSearchOptions` | yes (TS2305/TS2724) | `renamed-exports` | rewrite: import under the old local name; declined when the file uses a member that changed (`TO_LIST`, `FM_PT`, `AVERAGE`/`MINIMUM`/`MAXIMUM`, the reducers' `RANGE` value, `HIGH`/`MID`) or uses the name as a type | +| `@redis/*/dist/...` internals moved (`RedisCommandArgument`) | yes | `deep-imports`, compiler only (some dist paths still exist) | assisted | +| `@redis/graph` removed | yes | `graph-removed` + detect | assisted, a decision for the owner | +| `quit()`/`disconnect()` deprecated → `close()`/`destroy()` | deprecation | `quit-disconnect` | rewrite | +| SET `EX`/`PX`/`EXAT`/`PXAT`/`KEEPTTL` deprecated → `expiration` (not in the guide; declarations) | deprecation | `set-expiration` | assisted | +| search queries send `DIALECT 2` by default (release notes) | no | note `search-dialect-2`, detects query calls without `DIALECT` | — | +| `execAsPipeline` discards unwritten commands | no | note (decision) | — | +| Node 18 | no | note `node-18` (decision when engines/.nvmrc allow lower) | — | +| Reply map keys no longer camelCased (CLUSTER LINKS, XINFO, FUNCTION STATS, …), `LASTSAVE`/`TIME` types, `ZMPOP`, `XADD INCR`, `GEORADIUSSTORE` renames, JSON path options, `CLUSTER RESET/FAILOVER` option objects, `cluster.multi().addCommand` | yes, rarely used | none: `generic`, the agent works from the compiler and the guide | — | + +### 5 → 6 + +| Change | Compiler | Rule / note | Fix | +|---|---|---|---| +| `RedisClientType` invariant in RESP, RESP3 default (not in the guide; seen in all four upgrades that reached 6) | yes (TS2322/TS2345 naming `RedisClientType<…, 3, …>`) | `client-type` | assisted | +| `unstableResp3`, `unstableResp3Modules` removed | yes | `unstable-resp3` + detect | rewrite: delete the property | +| `commandOptions` removed from sentinel `nodeClientOptions`/`sentinelClientOptions` | yes | `sentinel-node-command-options` | assisted | +| RESP3 default, 5 s command timeout, keepAliveInitialDelay 30 s, maintNotifications `auto` | no | note `client-defaults`: a finding at each inline-options client that leaves one to the default, and a decision | the instructions pin the 5 defaults per client (the ioredis approach) | +| Node 20 | no | note `node-20` | — | +| null-prototype → plain object replies | no | note (decision) | — | +| RESP3-only reply stabilization (FT.SEARCH, XREAD, TS.MRANGE GROUPBY `sources`, …) | under RESP3 only | none: `generic` | — | + +Commands called through `any` or in JavaScript: `legacy-mode`, `command-options`, `scan-iterators`, +`keep-alive`, `scan-cursor`, `graph-removed`, `unstable-resp3` and both detecting notes find their +sites syntactically, in files that import `redis` or `@redis/*` only (a `.quit()` or `keepAlive` of +another library is never a site: fixture `other-libraries`). + +## Independent ground truth + +Drafted with `pnpm packs:truth` from each repository's own compiler and merged upgrade, reviewed by +hand and committed (ffb4f90) before the first score. Kept: the line the compiler reports, else the +line the upgrade changed because of redis. Not kept: lines changed only as a consequence of the +repository's own fix, and deprecations the code already had at the installed version. + +| Repository | Hop | Upgrade | Sites | +|---|---|---|---| +| deanmoses/tacocat-gallery-sam (`app`) | 4.6.12 → 5.10.0 | PR #83 | 2 renamed-exports, 2 generic (FT.SEARCH reply union) | +| swoofer/mcp-coordinator | 5.12.1 → 6.2.0 | PR #251 | 2 client-type | +| deanmoses/tacocat-gallery-sam (`app`) | 5.12.1 → 6.2.1 | PR #133 | 2 client-type | +| UNN-Devotek/FCM-Fallout-Chat-Mod (`backend`) | 4.7.1 → 6.0.0 | PR #125 | 5 scan-cursor, 2 client-type, 2 client-defaults (RESP: 2 pinned) | +| tearingItUp786/blog | 4.7.0 → 6.0.0 | #223 + 092ea34 | 5 scan-cursor | + +### Screened out + +- **FalkorDB/falkordb-ts** 4.7.1 → 5.10.0 (#495), the richest 4 → 5 upgrade found (executeIsolated + ×7, isolationPoolOptions ×2, ten `@redis/client/dist` deep imports): in the committed truth, then + removed after the first run, which could not score it. Its `package.json` declares + `workspaces: ["./packages/*"]` matching no directory, and `check` refuses to analyze it. No site + of it was ever scored; an engine follow-up, not a pack change. +- **snapshot-labs/stamp** 4.7.1 → 6.2.1 (#713): requires Node >= 24.15; the install fails on Node 22. +- **semaphore-chat** 5.11 → 6.2.1 (#530): lockfile at the root, project in `backend/`; not + installable by `packs:truth`. +- Bump-only upgrades with no source change (umami, vercel/chatbot and about 30 Dependabot PRs), + 3.x starts, latent bugs written on 5.x, and LibreChat (depends on `@redis/client`, not `redis`). +- In the issue's OSS sample, karakeep and appsmith adopted node-redis at 5/6 (no upgrade); twenty, + n8n and budibase are still on 4.x. + +## Measured pack test + +`uptide pack test redis`, 2026-10-14: + +``` + rule precision recall tp fp fn + client-defaults 0% 0% 0 4 2 + client-type 100% 33% 2 0 4 + generic 50% 100% 2 2 0 + renamed-exports 100% 100% 2 0 0 + scan-cursor 100% 30% 3 0 7 + breaking (sites) 100% 61% 11 0 7 + behavior notes (sites) 50% 50% 2 2 2 +``` + +Breaking misses, none of them a rule that did not match: + +- blog (5 scan-cursor): `refresh-cache.ts` and `utils.ts` reach the client through a local module + and a local `RedisScanClient` type and never import `redis`; `check` reports no compile error + there, and runtime detection reads importing files only. +- FCM and tacocat 5 → 6 (4 client-type): the repositories' own `tsc` reports TS2322 at these lines, + `check` reports nothing there. Both lines already had a different TS2322 at the installed version + (`ReturnType` against a configured client) and `check` subtracts errors it + finds at the installed version, which is most likely why (not confirmed in the engine). +- FCM `cursor !== 0` (2): the files import a local wrapper, not `redis`. + +Behavior: the two `client-defaults` false positives are clients whose repositories accepted the +RESP3 default without pinning (mcp-coordinator, blog). The defaults did change for them; the ground +truth rule (only what the repository changed) counts them against the note. FCM pins RESP2 in an +options function the detector cannot read (it reads inline literals only). + +### Per-rule evidence (for the UPT-48 gate) + +Exercised by a real site: `renamed-exports` (its rewrite too), `scan-cursor`, `client-type`. +Rewrites with no real site: `scan-iterators`, `keep-alive`, `quit-disconnect`, `unstable-resp3`; +under UPT-48 they run as assisted. Rules and notes with no real site: `legacy-mode`, +`command-options`, `number-replies`, `deep-imports`, `graph-removed`, `set-expiration`, +`sentinel-node-command-options`, `search-dialect-2` and the decision-only notes. + +By hop: the 4 → 5 rule set rests on one 4 → 5 repository (4 sites) and the 4 → 5 sites of the two +4 → 6 repositories (scan-cursor). The core 4 → 5 API changes (`command-options`, `legacy-mode`, +`scan-iterators`, `number-replies`, `keep-alive`) have no real site at all. The 5 → 6 rule set +has `client-type` in four repositories, two of them scored. + +## What the engine cannot express + +- One pack per package: the two majors are one pack with a per-major gate in the pack itself. +- Fixtures run every rewrite at the lowest versions of the pack's ranges (4.0.0 → 5.0.0), so the + gate in `transform` only refuses rewrites of a major the upgrade already passed. +- A site reported but deliberately not rewritten (a scan loop with `break`) cannot be a fixture + marker; it is a unit test. +- Compile errors in files that use the client without importing the package are not reported (blog). diff --git a/packages/core/src/packs/redis/behavior.ts b/packages/core/src/packs/redis/behavior.ts new file mode 100644 index 00000000..37649c94 --- /dev/null +++ b/packages/core/src/packs/redis/behavior.ts @@ -0,0 +1,96 @@ +import { minVersion, satisfies, validRange } from '../../fetch/range.js'; +import type { BehaviorNote } from '../contract.js'; +import type { PackContext } from '../types.js'; +import { clientDefaultSites, searchDialectSites, V6_DEFAULTS } from './detect.js'; + +/** Whether an upgrade from `from` to `to` crosses into `major` (4 → 6 crosses 5 and 6). */ +export function crosses(major: number, from: string, to: string): boolean { + return satisfies(from, `<${major}.0.0-0`) && satisfies(to, `>=${major}.0.0-0`); +} + +/** Where the repository says it runs Node below `version`: `engines.node`, `.nvmrc`. */ +function nodeBelow(context: PackContext, version: string): string | false { + const hits: string[] = []; + for (const { file, text } of context.inventory ?? []) { + let range: string | undefined; + if (/(?:^|\/)package\.json$/.test(file)) { + try { + range = (JSON.parse(text) as { engines?: { node?: string } }).engines?.node; + } catch { + range = undefined; + } + } else if (/(?:^|\/)\.(?:nvmrc|node-version)$/.test(file)) + range = text.trim().replace(/^v/, ''); + const lowest = range && validRange(range) ? minVersion(range) : undefined; + if (lowest && !satisfies(lowest, `>=${version}`)) + hits.push(`\`${file}\` allows Node ${JSON.stringify(range)}`); + } + return hits.length ? hits.join('; ') : false; +} + +/** Sources read 2026-10-14 at node-redis b64bacb; see RESEARCH.md for each line. */ +export const redisBehavior: BehaviorNote[] = [ + // ---- 4 → 5 ---- + { + id: 'node-18', + summary: 'redis 5 requires Node.js 18 or later (`engines`: ">= 18", ">= 18.19.0" from 5.12)', + reported: ['decision'], + applies: (context) => { + if (!crosses(5, context.from, context.to) || crosses(6, context.from, context.to)) + return false; + const below = nodeBelow(context, '18.19.0'); + return below ? `redis 5 requires Node.js >= 18.19.0: ${below}` : false; + }, + }, + { + id: 'search-dialect-2', + summary: + "redis 5 sends DIALECT 2 with search queries (FT.SEARCH, FT.AGGREGATE, FT.EXPLAIN, FT.PROFILE, FT.SPELLCHECK) that pass no DIALECT, which can change their results where the server default is dialect 1. Pass `DIALECT: 1` to keep 4's queries, or confirm the results under dialect 2", + reported: ['finding'], + severity: 'deprecated', + detect: (text, file) => searchDialectSites(text, file), + }, + { + id: 'pipeline-discards-unwritten', + summary: + '`multi().execAsPipeline()` in redis 5 discards the commands it had not written when the socket drops; 4 rejected but still ran them on the server', + reported: ['decision'], + applies: (context) => crosses(5, context.from, context.to), + }, + // ---- 5 → 6 ---- + { + id: 'client-defaults', + summary: `redis 6 changes client defaults: RESP3 instead of RESP2 (reply shapes of some commands, legacy callback replies), a 5 s command timeout instead of none, keepAliveInitialDelay 30 s instead of 5 s, and maintNotifications "auto" under RESP3. To keep 5's behavior, set ${V6_DEFAULTS.map((d) => `\`${d.keep}\``).join(', ')}`, + reported: ['finding', 'decision'], + severity: 'deprecated', + detect: (text, file) => clientDefaultSites(text, file), + applies: (context) => crosses(6, context.from, context.to), + }, + { + id: 'node-20', + summary: 'redis 6 requires Node.js 20 or later (`engines`: ">= 20.0.0")', + reported: ['decision'], + applies: (context) => { + if (!crosses(6, context.from, context.to)) return false; + const below = nodeBelow(context, '20.0.0'); + return below ? `redis 6 requires Node.js >= 20: ${below}` : false; + }, + }, + { + id: 'plain-object-replies', + summary: + 'redis 6 returns object replies (HGETALL, CONFIG GET, XRANGE messages, FT.SEARCH documents and others) as plain objects instead of null-prototype objects: only code or tests that check the prototype see it', + reported: ['decision'], + applies: (context) => crosses(6, context.from, context.to), + }, +]; + +/** The major each note belongs to: a 5 → 6 upgrade never reports the 4 → 5 notes. */ +export const NOTE_MAJOR: Record = { + 'node-18': 5, + 'search-dialect-2': 5, + 'pipeline-discards-unwritten': 5, + 'client-defaults': 6, + 'node-20': 6, + 'plain-object-replies': 6, +}; diff --git a/packages/core/src/packs/redis/detect.ts b/packages/core/src/packs/redis/detect.ts new file mode 100644 index 00000000..634ceea6 --- /dev/null +++ b/packages/core/src/packs/redis/detect.ts @@ -0,0 +1,427 @@ +import { + type CallExpression, + type ForOfStatement, + Node, + type ObjectLiteralExpression, + Project, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; +import type { Finding } from '../../domain/report.js'; +import type { SourceSite } from '../contract.js'; +import type { TransformResult } from '../types.js'; + +/** + * Syntactic sites for node-redis: what the type diff cannot see in JavaScript, through `any`, or + * where both majors compile and the behavior differs. Only files that import `redis` or an + * `@redis/*` package are read, so a `.quit()` or `keepAlive` of another library is never a site. + */ +const ENTRY = /^(?:redis|@redis\/(?:client|search|json|time-series|bloom|graph))(?:\/.*)?$/; + +function parse(text: string, file = 'consumer.ts'): SourceFile { + const name = /\.[cm]?jsx?$/.test(file) ? 'consumer.js' : 'consumer.ts'; + return new Project({ + useInMemoryFileSystem: true, + compilerOptions: { allowJs: true }, + }).createSourceFile(name, text, { overwrite: true }); +} + +function site(source: SourceFile, node: Node, name?: string): SourceSite { + const { line, column } = source.getLineAndColumnAtPos(node.getStart()); + const snippet = source.getFullText().split('\n')[line - 1]?.trim() ?? ''; + return { line, column, snippet, ...(name ? { name } : {}) }; +} + +/** Local names bound to the packages: named, default, namespace imports and `require`. */ +interface Bindings { + /** Local → imported name. */ + named: Map; + /** Namespace or default imports, and `const redis = require('redis')`. */ + modules: Set; + /** Whether the file uses node-redis at all. */ + uses: boolean; +} + +function bindings(source: SourceFile): Bindings { + const named = new Map(); + const modules = new Set(); + let uses = false; + for (const declaration of source.getImportDeclarations()) { + if (!ENTRY.test(declaration.getModuleSpecifierValue())) continue; + uses = true; + const namespace = declaration.getNamespaceImport(); + if (namespace) modules.add(namespace.getText()); + const fallback = declaration.getDefaultImport(); + if (fallback) modules.add(fallback.getText()); + for (const element of declaration.getNamedImports()) + named.set((element.getAliasNode() ?? element.getNameNode()).getText(), element.getName()); + } + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + const [arg] = call.getArguments(); + if ( + (callee.getText() !== 'require' && callee.getKind() !== SyntaxKind.ImportKeyword) || + !arg || + !Node.isStringLiteral(arg) || + !ENTRY.test(arg.getLiteralValue()) + ) + continue; + uses = true; + const nameNode = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getNameNode(); + if (Node.isIdentifier(nameNode)) modules.add(nameNode.getText()); + else if (Node.isObjectBindingPattern(nameNode)) + for (const element of nameNode.getElements()) + named.set( + element.getNameNode().getText(), + element.getPropertyNameNode()?.getText() ?? element.getNameNode().getText(), + ); + } + return { named, modules, uses }; +} + +function using(text: string, file: string): { source: SourceFile; b: Bindings } | undefined { + // Cheap gate before parsing: most files never mention the packages. + if (!/['"](?:redis|@redis\/[\w-]+)(?:\/[^'"]*)?['"]/.test(text)) return undefined; + const source = parse(text, file); + const b = bindings(source); + return b.uses ? { source, b } : undefined; +} + +const lineOf = (source: SourceFile, node: Node) => + source.getLineAndColumnAtPos(node.getStart()).line; + +/** `createClient(...)` and friends, imported by name or reached through the module binding. */ +const FACTORIES = new Set(['createClient', 'createCluster', 'createSentinel', 'createClientPool']); + +function factoryOf(call: CallExpression, b: Bindings): string | undefined { + const callee = call.getExpression(); + if (Node.isIdentifier(callee)) { + const imported = b.named.get(callee.getText()); + return imported && FACTORIES.has(imported) ? imported : undefined; + } + if (Node.isPropertyAccessExpression(callee)) { + const name = callee.getName(); + return b.modules.has(callee.getExpression().getText()) && FACTORIES.has(name) + ? name + : undefined; + } + return undefined; +} + +const keyOf = (p: Node) => + Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p) + ? p + .getNameNode() + .getText() + .replace(/^['"]|['"]$/g, '') + : undefined; + +/** Every object literal property named one of `names` in a node-redis file. */ +function propertiesNamed(source: SourceFile, names: readonly string[]): Node[] { + return source + .getDescendantsOfKind(SyntaxKind.PropertyAssignment) + .filter((p) => names.includes(keyOf(p) ?? '')); +} + +// ---- v5 (from 4.x) ------------------------------------------------------------------------- + +/** `legacyMode: true` in client options: removed in 5, which has `client.legacy()` instead. */ +export function legacyModeSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + return propertiesNamed(found.source, ['legacyMode']).map((p) => site(found.source, p)); +} + +/** + * The v4 per-call options: `client.commandOptions({...})` or the exported `commandOptions(...)` + * as a command's first argument, and `executeIsolated`. 5 has `withCommandOptions` (a proxy + * client) and `createClientPool`. The site is the call that builds the options. + */ +export function commandOptionsSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + const { source, b } = found; + const sites: SourceSite[] = []; + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + const name = Node.isPropertyAccessExpression(callee) + ? callee.getName() + : Node.isIdentifier(callee) + ? b.named.get(callee.getText()) + : undefined; + if (name === 'commandOptions' && call.getArguments().length > 0) sites.push(site(source, call)); + else if (name === 'executeIsolated' && Node.isPropertyAccessExpression(callee)) + sites.push(site(source, call)); + } + return sites; +} + +const ITERATORS = /^(?:scan|hScan|sScan|zScan)Iterator$/; + +function scanLoops(source: SourceFile): ForOfStatement[] { + return source.getDescendantsOfKind(SyntaxKind.ForOfStatement).filter((loop) => { + const iterated = loop.getExpression(); + if (!loop.isAwaited() || !Node.isCallExpression(iterated)) return false; + const callee = iterated.getExpression(); + return Node.isPropertyAccessExpression(callee) && ITERATORS.test(callee.getName()); + }); +} + +/** `for await (const key of client.scanIterator())`: 5 yields one batch (an array) per step. */ +export function scanIteratorSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + return scanLoops(found.source).map((loop) => site(found.source, loop)); +} + +/** A `break` or `continue` that targets this loop: an inner loop would take it instead. */ +function exitsLoop(loop: ForOfStatement): boolean { + return loop + .getStatement() + .getDescendants() + .some((node) => { + if (!Node.isBreakStatement(node) && !Node.isContinueStatement(node)) return false; + if (node.getLabel()) return true; + const owner = node.getFirstAncestor( + (a) => + Node.isIterationStatement(a) || + (Node.isSwitchStatement(a) && Node.isBreakStatement(node)) || + Node.isFunctionLikeDeclaration(a), + ); + return owner === loop; + }); +} + +/** + * Keeps the loop body as it is, item by item: `for await (const keys of it) for (const key of + * keys) `. Declined when the body `break`s or `continue`s the loop (the inner loop would + * take it), or the loop does not declare one plain binding. + */ +export function rewriteScanIterator( + text: string, + finding: Pick, +): TransformResult { + const source = using(text, finding.usage.file)?.source; + if (!source) return { text, applied: false, reason: 'the file does not use node-redis' }; + const loop = scanLoops(source).find((l) => lineOf(source, l) === finding.usage.line); + if (!loop) return { text, applied: false, reason: 'no scan iterator loop at the reported line' }; + if (exitsLoop(loop)) + return { + text, + applied: false, + reason: 'the loop body breaks or continues the loop; an inner loop would change its target', + }; + const initializer = loop.getInitializer(); + if (!Node.isVariableDeclarationList(initializer) || initializer.getDeclarations().length !== 1) + return { text, applied: false, reason: 'the loop does not declare its own binding' }; + const keyword = initializer.getDeclarationKind(); + const binding = initializer.getDeclarations()[0]?.getNameNode().getText() ?? ''; + const batch = /^[A-Za-z_$][\w$]*$/.test(binding) ? `${binding}Batch` : 'batch'; + if (new RegExp(`(?`: 5 splits it into `keepAlive: true` and `keepAliveInitialDelay`. */ +export function keepAliveSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + return propertiesNamed(found.source, ['keepAlive']) + .filter((p) => keepAliveDelay(p) !== undefined) + .map((p) => site(found.source, p)); +} + +export function rewriteKeepAlive(text: string, finding: Pick): TransformResult { + const source = using(text, finding.usage.file)?.source; + if (!source) return { text, applied: false, reason: 'the file does not use node-redis' }; + const p = propertiesNamed(source, ['keepAlive']).find( + (n) => lineOf(source, n) === finding.usage.line, + ); + const delay = p && keepAliveDelay(p); + if (!p || delay === undefined) + return { text, applied: false, reason: 'no numeric `keepAlive` at the reported line' }; + const siblings = p.getParentIfKind(SyntaxKind.ObjectLiteralExpression)?.getProperties() ?? []; + if (siblings.some((s) => keyOf(s) === 'keepAliveInitialDelay')) + return { text, applied: false, reason: '`keepAliveInitialDelay` is already set here' }; + return { + text: `${text.slice(0, p.getStart())}keepAlive: true, keepAliveInitialDelay: ${delay}${text.slice(p.getEnd())}`, + applied: true, + reason: '`keepAlive: ` is `keepAlive: true` with `keepAliveInitialDelay: ` in 5', + }; +} + +const EQUALITY = new Set([ + SyntaxKind.EqualsEqualsEqualsToken, + SyntaxKind.ExclamationEqualsEqualsToken, + SyntaxKind.EqualsEqualsToken, + SyntaxKind.ExclamationEqualsToken, +]); + +/** + * A numeric SCAN cursor: `scan(0)`, `hScan(key, 0)`, or a cursor compared with a number. 5 takes + * and returns the cursor as a string, so `cursor !== 0` never ends the loop in JavaScript. + */ +export function scanCursorSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + const { source } = found; + const sites: SourceSite[] = []; + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee)) continue; + const name = callee.getName(); + const index = name === 'scan' ? 0 : /^(?:hScan|sScan|zScan)$/.test(name) ? 1 : -1; + const cursor = index >= 0 ? call.getArguments()[index] : undefined; + if (cursor && Node.isNumericLiteral(cursor)) sites.push(site(source, call)); + } + for (const binary of source.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (!EQUALITY.has(binary.getOperatorToken().getKind())) continue; + const [left, right] = [binary.getLeft(), binary.getRight()]; + const other = Node.isNumericLiteral(right) + ? left + : Node.isNumericLiteral(left) + ? right + : undefined; + if (other && /(?:^|\.)cursor$/i.test(other.getText())) sites.push(site(source, binary)); + } + const seen = new Set(); + return sites.filter((s) => !seen.has(s.line) && seen.add(s.line)); +} + +const QUERIES = + /^(?:search|SEARCH|searchNoContent|aggregate|AGGREGATE|aggregateWithCursor|explain|EXPLAIN|profileSearch|profileAggregate|spellCheck|SPELLCHECK)$/; + +/** `client.ft.search(...)` and the other query commands, with no `DIALECT` in their options. */ +export function searchDialectSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + const { source } = found; + return source + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((call) => { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || !QUERIES.test(callee.getName())) return false; + const owner = callee.getExpression(); + if (!Node.isPropertyAccessExpression(owner) || owner.getName() !== 'ft') return false; + // A non-literal options argument may set it: only a literal can be read. + const options = call.getArguments()[2]; + if (options && !Node.isObjectLiteralExpression(options)) return false; + return !options + ?.getProperties() + .some((p) => keyOf(p) === 'DIALECT' || Node.isSpreadAssignment(p)); + }) + .map((call) => site(source, call)); +} + +/** `@redis/graph`: not part of redis 5 (RedisGraph reached end of life). */ +export function graphSites(text: string, file: string): SourceSite[] { + if (!/['"]@redis\/graph['"]/.test(text)) return []; + const source = parse(text, file); + const sites: SourceSite[] = []; + for (const declaration of source.getImportDeclarations()) + if (declaration.getModuleSpecifierValue() === '@redis/graph') + sites.push(site(source, declaration)); + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const [arg] = call.getArguments(); + if ( + call.getExpression().getText() === 'require' && + arg && + Node.isStringLiteral(arg) && + arg.getLiteralValue() === '@redis/graph' + ) + sites.push(site(source, call)); + } + return sites; +} + +// ---- v6 (from 5.x) ------------------------------------------------------------------------- + +const UNSTABLE = ['unstableResp3', 'unstableResp3Modules']; + +/** `unstableResp3` and `unstableResp3Modules`: removed in 6, where those transforms are stable. */ +export function unstableResp3Sites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + return propertiesNamed(found.source, UNSTABLE).map((p) => site(found.source, p)); +} + +/** + * Removes the property at the reported line and nothing else: the whole line when the property + * is alone on it, else the property and its comma. + */ +export function rewriteUnstableResp3( + text: string, + finding: Pick, +): TransformResult { + const source = using(text, finding.usage.file)?.source; + if (!source) return { text, applied: false, reason: 'the file does not use node-redis' }; + const p = propertiesNamed(source, UNSTABLE).find((n) => lineOf(source, n) === finding.usage.line); + if (!p) return { text, applied: false, reason: 'no `unstableResp3` option at the reported line' }; + const reason = `\`${keyOf(p)}\` is removed in 6: the RESP3 transforms it gated are stable`; + const lines = text.split('\n'); + const line = lines[finding.usage.line - 1] ?? ''; + const comma = text.slice(p.getEnd()).match(/^\s*,/)?.[0] ?? ''; + const property = text.slice(p.getStart(), p.getEnd() + comma.length); + if (line.trim().replace(/\s*\/\/.*$/, '') === property.trim()) { + lines.splice(finding.usage.line - 1, 1); + return { text: lines.join('\n'), applied: true, reason }; + } + const after = text.slice(p.getEnd() + comma.length).replace(/^ /, ''); + return { text: `${text.slice(0, p.getStart())}${after}`, applied: true, reason }; +} + +/** + * The defaults 6 changes, and the option that keeps each at its 5 value. A client that sets + * none of them explicitly behaves differently after the upgrade. + */ +export const V6_DEFAULTS = [ + { option: 'RESP', keep: 'RESP: 2' }, + { option: 'commandOptions.timeout', keep: 'commandOptions: { timeout: undefined }' }, + { option: 'socket.keepAliveInitialDelay', keep: 'socket: { keepAliveInitialDelay: 5000 }' }, + { option: 'maintNotifications', keep: "maintNotifications: 'disabled'" }, +] as const; + +function sets(options: ObjectLiteralExpression | undefined, path: string): boolean { + if (!options) return false; + const [head, tail] = path.split('.') as [string, string | undefined]; + const p = options.getProperties().find((candidate) => keyOf(candidate) === head); + if (!p) return false; + if (!tail) return true; + const value = Node.isPropertyAssignment(p) ? p.getInitializer() : undefined; + // A nested value that is not a literal may set it: only a literal can be read. + return Node.isObjectLiteralExpression(value) ? sets(value, tail) : true; +} + +/** + * Each client a factory builds whose options leave a default 6 changes to the library. Options + * passed as a variable or with a spread may set them: those calls are not sites. + */ +export function clientDefaultSites(text: string, file: string): SourceSite[] { + const found = using(text, file); + if (!found) return []; + const { source, b } = found; + const sites: SourceSite[] = []; + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const factory = factoryOf(call, b); + if (!factory) continue; + const [first] = call.getArguments(); + if (first && !Node.isObjectLiteralExpression(first)) continue; + if (first?.getProperties().some((p) => Node.isSpreadAssignment(p))) continue; + // RESP 2 keeps maintNotifications disabled; the timeout and keep-alive change regardless. + const left = V6_DEFAULTS.filter( + (d) => !sets(first, d.option) && !(d.option === 'maintNotifications' && sets(first, 'RESP')), + ); + if (left.length) sites.push(site(source, call, factory)); + } + return sites; +} diff --git a/packages/core/src/packs/redis/fixtures/client-defaults/before.ts b/packages/core/src/packs/redis/fixtures/client-defaults/before.ts new file mode 100644 index 00000000..c09ba7c9 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/client-defaults/before.ts @@ -0,0 +1,15 @@ +import * as redis from 'redis'; +import { createClient, createCluster } from 'redis'; + +const shared = { url: process.env.REDIS_URL }; + +export const plain = createClient(); // @uptide client-defaults +export const tuned = createClient({ url: process.env.REDIS_URL, socket: { connectTimeout: 1000 } }); // @uptide client-defaults +export const resp2Only = createClient({ RESP: 2 }); // @uptide client-defaults +export const cluster = createCluster({ rootNodes: [] }); // @uptide client-defaults +export const viaNamespace = redis.createClient({ RESP: 3 }); // @uptide client-defaults +// Every changed default chosen: nothing left to decide. +export const pinned = createClient({ RESP: 2, commandOptions: { timeout: undefined }, socket: { keepAliveInitialDelay: 5000 } }); // @uptide client-defaults keep +// Options the pack cannot read: a variable, a spread. +export const fromVariable = createClient(shared); // @uptide client-defaults keep +export const spread = createClient({ ...shared }); // @uptide client-defaults keep diff --git a/packages/core/src/packs/redis/fixtures/command-options/before.ts b/packages/core/src/packs/redis/fixtures/command-options/before.ts new file mode 100644 index 00000000..764928c0 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/command-options/before.ts @@ -0,0 +1,19 @@ +import { commandOptions, type RedisClientOptions, type RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function read(key: string) { + const buffer = await client.get(commandOptions({ returnBuffers: true }), key); // @uptide command-options + const viaClient = await client.get(client.commandOptions({ isolated: true }), key); // @uptide command-options + // Through `any`: the compiler cannot see this one. + const untyped = await (client as any).hGetAll((client as any).commandOptions({ returnBuffers: true }), key); // @uptide command-options + return { buffer, viaClient, untyped }; +} + +export async function blocking() { + return client.executeIsolated((isolated) => isolated.blPop('queue', 0)); // @uptide command-options +} + +// v5's own options: a property and a method, not the v4 call. +export const v5: RedisClientOptions = { commandOptions: { timeout: 1000 } }; // @uptide command-options keep +export const proxy = client.withCommandOptions({ asap: true }); // @uptide command-options keep diff --git a/packages/core/src/packs/redis/fixtures/graph-removed/before.ts b/packages/core/src/packs/redis/fixtures/graph-removed/before.ts new file mode 100644 index 00000000..9197dc4c --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/graph-removed/before.ts @@ -0,0 +1,6 @@ +import type { RedisClientType } from 'redis'; +import { Graph } from '@redis/graph'; // @uptide graph-removed + +declare const client: RedisClientType; +export const graph = new Graph(client as never, 'social'); +export const search = '@redis/search'; // @uptide graph-removed keep diff --git a/packages/core/src/packs/redis/fixtures/keep-alive/after.ts b/packages/core/src/packs/redis/fixtures/keep-alive/after.ts new file mode 100644 index 00000000..01155e3c --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/keep-alive/after.ts @@ -0,0 +1,5 @@ +import type { RedisClientOptions } from 'redis'; + +export const delayed: RedisClientOptions = { socket: { host: 'cache', keepAlive: true, keepAliveInitialDelay: 10000 } }; // @uptide keep-alive +export const off: RedisClientOptions = { socket: { keepAlive: false } }; // @uptide keep-alive keep +export const fromEnv = { socket: { keepAlive: Number(process.env.KA) } }; // @uptide keep-alive keep diff --git a/packages/core/src/packs/redis/fixtures/keep-alive/before.ts b/packages/core/src/packs/redis/fixtures/keep-alive/before.ts new file mode 100644 index 00000000..acee90b8 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/keep-alive/before.ts @@ -0,0 +1,5 @@ +import type { RedisClientOptions } from 'redis'; + +export const delayed: RedisClientOptions = { socket: { host: 'cache', keepAlive: 10000 } }; // @uptide keep-alive +export const off: RedisClientOptions = { socket: { keepAlive: false } }; // @uptide keep-alive keep +export const fromEnv = { socket: { keepAlive: Number(process.env.KA) } }; // @uptide keep-alive keep diff --git a/packages/core/src/packs/redis/fixtures/legacy-mode/before.ts b/packages/core/src/packs/redis/fixtures/legacy-mode/before.ts new file mode 100644 index 00000000..86ff346a --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/legacy-mode/before.ts @@ -0,0 +1,10 @@ +import type { RedisClientOptions } from 'redis'; + +export const legacy: RedisClientOptions = { + url: process.env.REDIS_URL, + legacyMode: true, // @uptide legacy-mode +}; + +// A property of the same name in a file that uses node-redis is read too: `legacyMode` has no +// other meaning there. +export const flags = { legacyMode: false }; // @uptide legacy-mode diff --git a/packages/core/src/packs/redis/fixtures/other-libraries/before.ts b/packages/core/src/packs/redis/fixtures/other-libraries/before.ts new file mode 100644 index 00000000..26e6ac29 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/other-libraries/before.ts @@ -0,0 +1,12 @@ +// No node-redis import: the same names belong to other libraries and are never sites. +import net from 'node:net'; + +declare const queue: { quit(): Promise; scanIterator(): AsyncIterable }; + +export const socket = net.connect({ port: 1, keepAlive: true }); +export const options = { legacyMode: true, keepAlive: 1000, unstableResp3: true }; // @uptide legacy-mode keep +export async function stop() { + for await (const key of queue.scanIterator()) console.log(key); // @uptide scan-iterators keep + await queue.quit(); +} +export const gates = { unstableResp3: true }; // @uptide unstable-resp3 keep diff --git a/packages/core/src/packs/redis/fixtures/quit-disconnect/after.ts b/packages/core/src/packs/redis/fixtures/quit-disconnect/after.ts new file mode 100644 index 00000000..6e710667 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/quit-disconnect/after.ts @@ -0,0 +1,15 @@ +import type { RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function shutdown() { + await client.close(); // @uptide quit-disconnect at:quit +} + +export async function abort() { + await client.destroy(); // @uptide quit-disconnect at:disconnect +} + +export async function reply() { + return client.quit(); // @uptide quit-disconnect keep at:quit +} diff --git a/packages/core/src/packs/redis/fixtures/quit-disconnect/before.ts b/packages/core/src/packs/redis/fixtures/quit-disconnect/before.ts new file mode 100644 index 00000000..4e9f8f78 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/quit-disconnect/before.ts @@ -0,0 +1,15 @@ +import type { RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function shutdown() { + await client.quit(); // @uptide quit-disconnect at:quit +} + +export async function abort() { + await client.disconnect(); // @uptide quit-disconnect at:disconnect +} + +export async function reply() { + return client.quit(); // @uptide quit-disconnect keep at:quit +} diff --git a/packages/core/src/packs/redis/fixtures/renamed-exports/after.ts b/packages/core/src/packs/redis/fixtures/renamed-exports/after.ts new file mode 100644 index 00000000..b62030ce --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/renamed-exports/after.ts @@ -0,0 +1,12 @@ +import { SCHEMA_FIELD_TYPE as SchemaFieldTypes, FT_AGGREGATE_STEPS as AggregateSteps } from 'redis'; // @uptide renamed-exports at:SchemaFieldTypes +import { TimeSeriesAggregationType } from '@redis/time-series'; // @uptide renamed-exports keep at:TimeSeriesAggregationType +import { FtSearchOptions as QueryOptions } from 'redis'; // @uptide renamed-exports at:SearchOptions + +export const schema = { + title: { type: SchemaFieldTypes.TEXT }, + tags: { type: SchemaFieldTypes.TAG }, +}; +export const steps = [AggregateSteps.GROUPBY]; +// AVERAGE is AVG in 5: an alias would leave this member undefined. +export const avg = TimeSeriesAggregationType.AVERAGE; +export const query: QueryOptions = { LIMIT: { from: 0, size: 5 } }; diff --git a/packages/core/src/packs/redis/fixtures/renamed-exports/before.ts b/packages/core/src/packs/redis/fixtures/renamed-exports/before.ts new file mode 100644 index 00000000..cff73617 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/renamed-exports/before.ts @@ -0,0 +1,12 @@ +import { SchemaFieldTypes, AggregateSteps } from 'redis'; // @uptide renamed-exports at:SchemaFieldTypes +import { TimeSeriesAggregationType } from '@redis/time-series'; // @uptide renamed-exports keep at:TimeSeriesAggregationType +import { SearchOptions as QueryOptions } from 'redis'; // @uptide renamed-exports at:SearchOptions + +export const schema = { + title: { type: SchemaFieldTypes.TEXT }, + tags: { type: SchemaFieldTypes.TAG }, +}; +export const steps = [AggregateSteps.GROUPBY]; +// AVERAGE is AVG in 5: an alias would leave this member undefined. +export const avg = TimeSeriesAggregationType.AVERAGE; +export const query: QueryOptions = { LIMIT: { from: 0, size: 5 } }; diff --git a/packages/core/src/packs/redis/fixtures/scan-cursor/before.ts b/packages/core/src/packs/redis/fixtures/scan-cursor/before.ts new file mode 100644 index 00000000..43f9f2ea --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/scan-cursor/before.ts @@ -0,0 +1,16 @@ +import type { RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function keys(pattern: string) { + const found: string[] = []; + let cursor = 0; + do { + const reply = await client.scan(cursor, { MATCH: pattern, COUNT: 100 }); + cursor = reply.cursor; + found.push(...reply.keys); + } while (cursor !== 0); // @uptide scan-cursor + const first = await client.hScan('h', 0); // @uptide scan-cursor + const fromString = await client.sScan('s', '0'); // @uptide scan-cursor keep + return { found, first, fromString }; +} diff --git a/packages/core/src/packs/redis/fixtures/scan-iterators/after.ts b/packages/core/src/packs/redis/fixtures/scan-iterators/after.ts new file mode 100644 index 00000000..f8f15dc1 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/scan-iterators/after.ts @@ -0,0 +1,19 @@ +import type { RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function sweep() { + for await (const keyBatch of client.scanIterator({ MATCH: 'session:*' })) for (const key of keyBatch) { // @uptide scan-iterators + await client.del(key); + } + const seen: string[] = []; + for await (const batch of client.hScanIterator('h')) for (const { field, value } of batch) { // @uptide scan-iterators + seen.push(`${field}=${value}`); + } + for await (const memberBatch of client.sScanIterator('s')) for (const member of memberBatch) seen.push(member); // @uptide scan-iterators + return seen; +} + +export async function arrays(items: AsyncIterable) { + for await (const item of items) console.log(item); // not a scan iterator +} diff --git a/packages/core/src/packs/redis/fixtures/scan-iterators/before.ts b/packages/core/src/packs/redis/fixtures/scan-iterators/before.ts new file mode 100644 index 00000000..6770c6eb --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/scan-iterators/before.ts @@ -0,0 +1,19 @@ +import type { RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function sweep() { + for await (const key of client.scanIterator({ MATCH: 'session:*' })) { // @uptide scan-iterators + await client.del(key); + } + const seen: string[] = []; + for await (const { field, value } of client.hScanIterator('h')) { // @uptide scan-iterators + seen.push(`${field}=${value}`); + } + for await (const member of client.sScanIterator('s')) seen.push(member); // @uptide scan-iterators + return seen; +} + +export async function arrays(items: AsyncIterable) { + for await (const item of items) console.log(item); // not a scan iterator +} diff --git a/packages/core/src/packs/redis/fixtures/search-dialect/before.ts b/packages/core/src/packs/redis/fixtures/search-dialect/before.ts new file mode 100644 index 00000000..725d2905 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/search-dialect/before.ts @@ -0,0 +1,12 @@ +import type { RedisClientType } from 'redis'; + +declare const client: RedisClientType; + +export async function find(q: string) { + const hits = await client.ft.search('idx:docs', q, { LIMIT: { from: 0, size: 10 } }); // @uptide search-dialect-2 + const bare = await client.ft.search('idx:docs', q); // @uptide search-dialect-2 + const counts = await client.ft.aggregate('idx:docs', '*', { STEPS: [] }); // @uptide search-dialect-2 + const pinned = await client.ft.search('idx:docs', q, { DIALECT: 1 }); // @uptide search-dialect-2 keep + const created = await client.ft.create('idx:docs', {}); // @uptide search-dialect-2 keep + return { hits, bare, counts, pinned, created }; +} diff --git a/packages/core/src/packs/redis/fixtures/unstable-resp3/after.ts b/packages/core/src/packs/redis/fixtures/unstable-resp3/after.ts new file mode 100644 index 00000000..ae00de79 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/unstable-resp3/after.ts @@ -0,0 +1,6 @@ +import { createClient } from 'redis'; + +export const client = createClient({ // @uptide client-defaults + RESP: 3, + commandOptions: { timeout: undefined }, +}); diff --git a/packages/core/src/packs/redis/fixtures/unstable-resp3/before.ts b/packages/core/src/packs/redis/fixtures/unstable-resp3/before.ts new file mode 100644 index 00000000..ea232f29 --- /dev/null +++ b/packages/core/src/packs/redis/fixtures/unstable-resp3/before.ts @@ -0,0 +1,7 @@ +import { createClient } from 'redis'; + +export const client = createClient({ // @uptide client-defaults + RESP: 3, + unstableResp3: true, // @uptide unstable-resp3 + commandOptions: { timeout: undefined }, +}); diff --git a/packages/core/src/packs/redis/ground-truth.json b/packages/core/src/packs/redis/ground-truth.json new file mode 100644 index 00000000..15ba75b2 --- /dev/null +++ b/packages/core/src/packs/redis/ground-truth.json @@ -0,0 +1,175 @@ +{ + "$comment": "Expected findings drafted with pnpm packs:truth (each repository's own compiler, TypeScript 5.9.3, scripts off) and its merged upgrade commit, reviewed by hand and fixed before the pack was scored. One site per change the upgrade made because of redis: the line the compiler reports, else the line the upgrade changed. Lines the upgrade changed only as a consequence of its own fix (uses of a renamed import, a `let cursor = 0` whose comparison is the site) are not sites; neither are deprecations the code already had at the installed version (quit() on 5.x),. Three hops: 4 → 5, 5 → 6 and 4 → 6. See RESEARCH.md. FalkorDB/falkordb-ts (4.7.1 → 5.10.0, #495: executeIsolated ×7, isolationPoolOptions ×2, ten @redis/client/dist deep imports) was drafted and then left out unscored: its package.json declares workspaces (./packages/*) that match no directory, and check refuses to analyze it (RESEARCH.md).", + "package": "redis", + "repos": [ + { + "repo": "deanmoses/tacocat-gallery-sam", + "commit": "34e11760f6e767abef6afd53f973dc155f6f8c4b", + "directory": "app", + "migration": "https://github.com/deanmoses/tacocat-gallery-sam/pull/83", + "from": "4.6.12", + "to": "5.10.0", + "why": "Serverless photo gallery with RediSearch, npm in app/. Upgrade PR #83 (4.6.12 → 5.10.0) moves SchemaFieldTypes and SearchOptions to their v5 names and types the FT.SEARCH reply.", + "findings": [ + { + "file": "src/lib/gallery/resetAndReloadRedis/resetAndReloadRedis.ts", + "line": 6, + "rule": "renamed-exports", + "note": "SchemaFieldTypes → SCHEMA_FIELD_TYPE (TS2724); the seven uses follow the import" + }, + { + "file": "src/lib/redis_utils/redisSearch.ts", + "line": 1, + "rule": "renamed-exports", + "note": "SearchOptions → FtSearchOptions (TS2724)" + }, + { + "file": "src/lib/redis_utils/redisSearch.ts", + "line": 40, + "rule": "generic", + "note": "TS2339: the FT.SEARCH reply is a union under the generic client; the upgrade casts it to SearchReply" + }, + { + "file": "src/lib/redis_utils/redisSearch.ts", + "line": 41, + "rule": "generic" + } + ] + }, + { + "repo": "swoofer/mcp-coordinator", + "commit": "8b337f0a081d0d0e0c3724890b9cc847b6c5dd6f", + "migration": "https://github.com/swoofer/mcp-coordinator/pull/251", + "from": "5.12.1", + "to": "6.2.0", + "why": "Coordination service, pnpm. Upgrade PR #251 (5.12.1 → 6.2.0) derives the client type from a real createClient call because v6 made RedisClientType invariant in RESP (TS2322); it keeps the RESP3 default.", + "findings": [ + { + "file": "src/infra/redis.ts", + "line": 62, + "rule": "client-type" + }, + { + "file": "src/infra/redis.ts", + "line": 63, + "rule": "client-type" + } + ] + }, + { + "repo": "deanmoses/tacocat-gallery-sam", + "commit": "30724e21efd6db123a7385e73482cac237b1e9ba", + "directory": "app", + "migration": "https://github.com/deanmoses/tacocat-gallery-sam/pull/133", + "from": "5.12.1", + "to": "6.2.1", + "why": "The same gallery a year later, 5.12.1 → 6.2.1 in PR #133. The client type breaks (TS2322); the quit → close and cast removals in the same PR are cleanups of what 5 already allowed.", + "findings": [ + { + "file": "src/lib/redis_utils/redisClientUtils.ts", + "line": 16, + "rule": "client-type" + }, + { + "file": "src/lib/redis_utils/redisClientUtils.ts", + "line": 21, + "rule": "client-type" + } + ] + }, + { + "repo": "UNN-Devotek/FCM-Fallout-Chat-Mod", + "commit": "280190489fac1ac8b75bf5fda8fd54265f339d7e", + "directory": "backend", + "migration": "https://github.com/UNN-Devotek/FCM-Fallout-Chat-Mod/pull/125", + "from": "4.7.1", + "to": "6.0.0", + "why": "Express backend with connect-redis and rate-limit-redis, npm in backend/. Upgrade PR #125 jumps 4.7.1 → 6.0.0: SCAN cursors become strings, the client type breaks, and the options pin RESP2 to keep 4's reply shapes.", + "findings": [ + { + "file": "src/config/redis.ts", + "line": 10, + "rule": "client-defaults", + "note": "RESP: 2 pinned in the options the clients are built from" + }, + { + "file": "src/config/redis.ts", + "line": 11, + "rule": "client-defaults" + }, + { + "file": "src/config/redis.ts", + "line": 39, + "rule": "client-type" + }, + { + "file": "src/config/redis.ts", + "line": 46, + "rule": "client-type" + }, + { + "file": "src/controllers/adminNukeController.ts", + "line": 61, + "rule": "scan-cursor" + }, + { + "file": "src/controllers/adminNukeController.ts", + "line": 68, + "rule": "scan-cursor", + "note": "`cursor !== 0` would never end the loop with a string cursor" + }, + { + "file": "src/services/roleVerificationService.ts", + "line": 59, + "rule": "scan-cursor" + }, + { + "file": "src/services/roleVerificationService.ts", + "line": 60, + "rule": "scan-cursor" + }, + { + "file": "src/services/roleVerificationService.ts", + "line": 72, + "rule": "scan-cursor" + } + ] + }, + { + "repo": "tearingItUp786/blog", + "commit": "bb88f3afc40df07e29da461c6aed6c34709bc6bf", + "migration": "https://github.com/tearingItUp786/blog/commit/092ea344acb0068e2d067b84dd3c84d66ed8d1d0", + "from": "4.7.0", + "to": "6.0.0", + "why": "Remix blog, pnpm. PR #223 bumped 4.7.0 → 6.0.0 with package files only; the same-day follow-up 092ea34 makes the SCAN cursor a string, the only redis change the jump needed.", + "findings": [ + { + "file": "app/inngest/refresh-cache.ts", + "line": 204, + "rule": "scan-cursor", + "note": "the client no longer fits the local RedisScanClient type: scan takes a string cursor" + }, + { + "file": "app/inngest/refresh-cache.ts", + "line": 206, + "rule": "scan-cursor" + }, + { + "file": "app/inngest/refresh-cache.ts", + "line": 322, + "rule": "scan-cursor" + }, + { + "file": "app/inngest/refresh-cache.ts", + "line": 326, + "rule": "scan-cursor" + }, + { + "file": "app/inngest/utils.ts", + "line": 106, + "rule": "scan-cursor" + } + ] + } + ] +} diff --git a/packages/core/src/packs/redis/index.ts b/packages/core/src/packs/redis/index.ts new file mode 100644 index 00000000..79dc7f97 --- /dev/null +++ b/packages/core/src/packs/redis/index.ts @@ -0,0 +1,303 @@ +import { satisfies } from '../../fetch/range.js'; +import { definePack, type Pack, type PackRule, replaceAtSite } from '../contract.js'; +import type { TransformResult } from '../types.js'; +import { crosses, NOTE_MAJOR, redisBehavior } from './behavior.js'; +import { + commandOptionsSites, + graphSites, + keepAliveSites, + legacyModeSites, + rewriteKeepAlive, + rewriteScanIterator, + rewriteUnstableResp3, + scanCursorSites, + scanIteratorSites, + unstableResp3Sites, +} from './detect.js'; +import { CONSTANTS, renameConstant } from './names.js'; + +const repo = 'https://github.com/redis/node-redis'; +const docs = `${repo}/blob/b64bacbd85642223f0a98c2d91cbcffdb9fb2052/docs`; +const release = (tag: string) => `${repo}/releases/tag/${encodeURIComponent(tag)}`; +const read = 'read 2026-10-14'; +const OLD_CONSTANTS = [...CONSTANTS.keys()]; + +/** + * 4 → 5: the API redesign. These rules apply only to an upgrade that starts below 5. Sites are + * what the guide lists and the compiler reports, plus a syntactic detector where JavaScript or + * `any` hides them from the compiler. + */ +const V5: PackRule[] = [ + { + id: 'legacy-mode', + summary: '`legacyMode` is removed in 5: the callback API is `client.legacy()` on a v5 client', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /(?:legacyMode|^TS2353$|^TS2769$)/, + message: /(?:^$|legacyMode)/, + detect: legacyModeSites, + guide: + 'Remove `legacyMode: true` from the options, create and connect the client as usual, and use `client.legacy()` for the callback-style calls the code makes (`legacy.get(key, (err, reply) => …)`). Code that already awaits promises on that client uses the client itself. Hand libraries that expect a v3 client (connect-redis before 7, older rate limiters) the `legacy()` object, and keep `connect()`, `on("error")` and `close()` on the real client.', + }, + { + id: 'command-options', + summary: + '`commandOptions(...)` as a first argument and `executeIsolated` are removed in 5: per-call options are a proxy client (`withCommandOptions`), isolation is `createClientPool`', + severity: 'breaking', + kinds: ['removed', 'type', 'signature'], + symbols: /(?:commandOptions|executeIsolated|^TS2339$|^TS2305$|^TS2724$|^TS2345$)/, + message: /(?:^$|commandOptions|executeIsolated|CommandOptions)/, + detect: commandOptionsSites, + guide: + 'v4 `client.get(commandOptions({ returnBuffers: true }), key)` is v5 `client.withCommandOptions({ typeMapping: { [RESP_TYPES.BLOB_STRING]: Buffer } }).get(key)` (`RESP_TYPES` from "redis"); create the proxy once next to the client when several calls share it. `{ isolated: true }` and `client.executeIsolated(fn)` need a dedicated connection: use `createClientPool(options)` (or `client.createPool()`) and `pool.execute((client) => …)`, or `client.duplicate()` for a blocking command. `signal` and `asap` keep their meaning in `withCommandOptions`. Keep the arguments and the reply type the callers expect.', + }, + { + id: 'scan-iterators', + summary: + '`scanIterator`, `hScanIterator`, `sScanIterator` and `zScanIterator` yield a batch (an array) per step in 5, one item per step in 4', + severity: 'breaking', + // Found by syntax only: the compiler's errors downstream of a batch (a `string[]` passed + // as a key) name no iterator, and a reply union mentions `Array<` anyway. + kinds: [], + symbols: /$^/, + detect: scanIteratorSites, + rewrite: (text, finding) => rewriteScanIterator(text, finding), + guide: + 'Iterate each batch: `for await (const keys of client.scanIterator(o)) for (const key of keys) { … }`, or use the batch with multi-key commands (`mGet(keys)`, `unlink(keys)`, skipping an empty batch). hScanIterator yields `{ field, value }[]`, zScanIterator `{ value, score }[]`, sScanIterator `string[]`. A `break` or `continue` in the old body must still leave or skip the outer loop: use a label or a flag. Keep `MATCH`, `COUNT` and `TYPE` as they are.', + }, + { + id: 'keep-alive', + summary: + '`socket.keepAlive` is a boolean in 5: the initial delay that 4 took as its number is `keepAliveInitialDelay`', + severity: 'breaking', + kinds: ['type'], + symbols: /(?:keepAlive|^TS2322$|^TS2769$)/, + message: /(?:^$|keepAlive)/, + detect: keepAliveSites, + rewrite: (text, finding) => rewriteKeepAlive(text, finding), + guide: + "`keepAlive: N` (ms) is `keepAlive: true, keepAliveInitialDelay: N`; `keepAlive: false` is unchanged. 5's defaults are `true` and 5000, as 4's were.", + }, + { + id: 'scan-cursor', + summary: + 'SCAN, HSCAN, SSCAN and ZSCAN take and return the cursor as a string in 5: a loop that compares the cursor with `0` never ends', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /(?:cursor|^TS2345$|^TS2367$|^TS2322$)/, + message: + /(?:^$|cursor|'number' and 'string'|'string' and 'number'|'RedisArgument'|property 'scan' are incompatible|'string' is not assignable to type 'number')/, + detect: scanCursorSites, + guide: + 'Start at `"0"` and stop when `reply.cursor === "0"`; never convert the cursor to a number (cursors can exceed Number.MAX_SAFE_INTEGER, node-redis#2561). `reply.keys` (SCAN) and `reply.members` (SSCAN, ZSCAN) keep their names; HSCAN `tuples` is `entries`. Prefer the scan iterators when the loop only walks every key.', + }, + { + id: 'number-replies', + summary: + 'commands that replied `boolean` in 4 reply `number` (1 or 0) in 5: SETNX, EXPIRE, EXPIREAT, PEXPIRE, PEXPIREAT, HEXISTS, HSETNX, SISMEMBER, SMISMEMBER, SMOVE, RENAMENX, MOVE, COPY, PFADD, XACK, SCRIPT EXISTS', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2322|2367|2345|2365)$/, + message: + /(?:'number' is not assignable to type 'boolean'|'number' and 'boolean'|'boolean' and 'number'|NumberReply[^']*' is not assignable to type 'boolean')/, + guide: + "The reply is the number Redis sends (1 or 0, a count for PFADD, an array for SMISMEMBER). Compare it where the code needs a boolean (`(await client.sIsMember(k, m)) === 1`, `.map((r) => r === 1)`), keeping the function's declared return type. Truthiness checks (`if (await client.setNX(...))`) already work; `=== true` and `=== false` never match a number.", + }, + { + id: 'renamed-exports', + summary: `enums are constants in 5 (${OLD_CONSTANTS.slice(0, 3).join(', ')} and ${OLD_CONSTANTS.length - 4} more, some with renamed members), and \`SearchOptions\` is \`FtSearchOptions\``, + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: new RegExp( + `(?:(?:^|[:#.])(?:${OLD_CONSTANTS.join('|')})$|^TS2305$|^TS2724$|^TS2614$)`, + ), + message: new RegExp(`(?:^$|${OLD_CONSTANTS.join('|')})`), + rewrite: (text, finding) => renameConstant(text, finding), + guide: `Import the v5 constant under the local name the code uses (\`import { SCHEMA_FIELD_TYPE as SchemaFieldTypes } from "redis"\`), so its uses keep working; a use as a type is the exported type (\`SchemaFieldType\`) or \`(typeof X)[keyof typeof X]\`. Map: ${[ + ...CONSTANTS, + ] + .map(([old, c]) => `${old} → ${c.name}${c.members ? ` (${c.members})` : ''}`) + .join('; ')}.`, + }, + { + id: 'deep-imports', + summary: + 'paths inside `@redis/*/dist` moved in 5: `RedisCommandArgument`, `RedisCommandArguments` and other internals imported from them are gone', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /^TS(?:2305|2307|2614|2724)$/, + message: /@redis\/[\w-]+\/dist\//, + guide: + 'Import from the package root, never from `dist`: `RedisArgument` (was `RedisCommandArgument`), `Array` (was `RedisCommandArguments`), `CommandParser`, `RedisClientType`, `RedisClusterType`, `RESP_TYPES` and the reply types are exported by `redis` and `@redis/client`. A helper that has no public export (`promiseTimeout`) is a few local lines; do not deep-import the 5 build instead.', + }, + { + id: 'graph-removed', + summary: + '`@redis/graph` is not part of redis 5 (RedisGraph reached end of life): `client.graph` and its imports are gone', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /(?:@redis\/graph|graph|^TS2307$)/, + message: /(?:^$|@redis\/graph|'graph')/, + detect: graphSites, + guide: + 'There is no replacement in node-redis 5 and no rewrite: RedisGraph is end of life (FalkorDB, its community fork, has its own client). Record the decision for the owner with the files that use it: keep `@redis/graph` 1.x on a separate v4 client for now, move the queries to another graph store, or remove the feature. Do not stub the commands.', + }, + { + id: 'quit-disconnect', + summary: + '`quit()` and `disconnect()` are deprecated in 5: `close()` waits for pending commands, `destroy()` drops them', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: /(?:^|[#.:])(?:quit|QUIT|disconnect)$/, + rewrite: (text, finding) => { + const name = finding.usage.symbolPath.split(/[#.:]/).pop() ?? ''; + // `quit()` resolves "OK", `close()` undefined, `destroy()` is synchronous: only a call + // whose result is not used is the same statement after the rename. + const line = text.split('\n')[finding.usage.line - 1] ?? ''; + if (!/^\s*(?:await\s+)?[\w$.?]+\.(?:quit|QUIT|disconnect)\(\);?\s*(?:\/\/.*)?$/.test(line)) + return { text, applied: false, reason: `the result of \`${name}()\` is used` }; + return replaceAtSite(text, finding, name, name === 'disconnect' ? 'destroy' : 'close'); + }, + guide: + '`quit()` is `close()` (it waits for pending replies and resolves to undefined, not "OK"); `disconnect()` is `destroy()` (synchronous, rejects pending commands). Keep the surrounding await and error handling.', + }, + { + id: 'set-expiration', + summary: + '`SET` options `EX`, `PX`, `EXAT`, `PXAT` and `KEEPTTL` are deprecated in 5: the expiry is `expiration`', + severity: 'deprecated', + kinds: ['deprecated'], + symbols: /(?:(?:^|[#.:])(?:EX|PX|EXAT|PXAT|KEEPTTL)$|^TS6385$)/, + message: /(?:^$|'(?:EX|PX|EXAT|PXAT|KEEPTTL)' is deprecated)/, + guide: + "`{ EX: n }` is `{ expiration: { type: 'EX', value: n } }` (PX, EXAT and PXAT the same way); `{ KEEPTTL: true }` is `{ expiration: 'KEEPTTL' }`. `NX`/`XX` and `GET` stay as they are. The command sent is the same.", + }, +]; + +/** + * 5 → 6: few compiler-visible changes; most of it is defaults (behavior.ts). These rules apply + * only to an upgrade that starts below 6. + */ +const V6: PackRule[] = [ + { + id: 'unstable-resp3', + summary: + '`unstableResp3` and `unstableResp3Modules` are removed in 6: the RESP3 transforms they gated are stable', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /(?:unstableResp3|^TS2353$|^TS2769$)/, + message: /(?:^$|unstableResp3)/, + detect: unstableResp3Sites, + rewrite: (text, finding) => rewriteUnstableResp3(text, finding), + guide: + 'Remove the option. The replies it gated (FT.SEARCH, FT.AGGREGATE, FT.INFO, TS.INFO, XREAD and others; see the 6 guide) have stable types under RESP3: drop casts added for the unstable shapes only where the compiler shows the new type fits.', + }, + { + id: 'sentinel-node-command-options', + summary: + '`commandOptions` on `nodeClientOptions` and `sentinelClientOptions` is removed in 6 (5 ignored it there): it belongs on the sentinel options', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2353|2769)$/, + message: /commandOptions/, + guide: + 'Move `commandOptions` from `nodeClientOptions`/`sentinelClientOptions` to the top level of the `createSentinel` options. 5 used only the top-level value, so moving it changes behavior when the top level had none: say so in decisions.', + }, + { + id: 'client-type', + summary: + '`RedisClientType` is invariant in its RESP version in 6, and clients default to RESP3: a client typed `RedisClientType` or `ReturnType` no longer accepts the client `createClient()` returns', + severity: 'breaking', + // The compiler's TS2322/TS2345, reported at the type it names (`added RedisClientType`). + kinds: ['type', 'signature', 'added', 'removed'], + symbols: /(?:Redis(?:Client|Cluster|Sentinel|ClientPool)Type|^TS2322$|^TS2345$)$/, + message: /Redis(?:Client|Cluster|Sentinel|ClientPool)Type<[^>]*\b3\b/, + guide: + 'Type the client from the call that creates it, not from the generic factory: wrap creation in a function (`function makeClient(url: string) { return createClient({ url }); }`) and use `ReturnType`, or `typeof client` for a module-level client. Where the code needs a named type, write the RESP version the client uses (`RedisClientType`). When the client is pinned to `RESP: 2` (client-defaults), the type says `2`. Never cast the client to the wide type or to any.', + }, +]; + +// shared-state: not TS-dependent +const MAJOR = new Map([ + ...V5.map((r) => [r.id, 5] as const), + ...V6.map((r) => [r.id, 6] as const), + ...Object.entries(NOTE_MAJOR), +]); + +/** Whether the rule or note `id` belongs to a major the upgrade from `from` to `to` crosses. */ +export function appliesTo(id: string | undefined, from: string, to: string): boolean { + const major = id === undefined ? undefined : MAJOR.get(id); + return major === undefined || crosses(major, from, to); +} + +const base = definePack({ + meta: { + package: 'redis', + from: '>=4 <6', + to: '>=5 <7', + sources: [ + { + title: `node-redis v4 to v5 migration guide at b64bacb (${read})`, + url: `${docs}/v4-to-v5.md`, + }, + { + title: `node-redis v5 to v6 migration guide at b64bacb (${read})`, + url: `${docs}/v5-to-v6.md`, + }, + { title: `node-redis RESP type mapping at b64bacb (${read})`, url: `${docs}/RESP.md` }, + { title: `redis@5.0.0 release notes (${read})`, url: release('redis@5.0.0') }, + { title: `redis@6.0.0 release notes (${read})`, url: release('redis@6.0.0') }, + { + title: `Published declarations: @redis/client 1.6.1, 5.12.1, 6.3.0; @redis/search 1.2.0, 5.12.1; @redis/time-series 1.1.0, 5.12.1 (${read})`, + url: 'https://registry.npmjs.org/@redis/client', + }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '6.3.0', + inventory: ['package.json', '{.nvmrc,.node-version}'], + // `redis` depends on each at its own exact version: packages/redis/package.json at every tag. + releaseGroup: [ + '@redis/client', + '@redis/bloom', + '@redis/json', + '@redis/search', + '@redis/time-series', + ].map((name) => ({ name, source: `${repo}/blob/redis%406.3.0/packages/redis/package.json` })), + rules: [...V5, ...V6], + behavior: redisBehavior, + instructions: + "Sources read 2026-10-14 at node-redis b64bacb; see RESEARCH.md. Migrate only the reported site. An upgrade from 4 crosses both majors: apply the 5 changes, then the 6 ones. Keep 5's client behavior unless the decision for that client says otherwise: where a client is reported under client-defaults, pin the 5 defaults on it (`RESP: 2`, `commandOptions: { timeout: undefined }`, `socket: { keepAliveInitialDelay: 5000 }`) rather than adapting callers to RESP3 replies. Never cast a client or a reply to any, never convert a SCAN cursor to a number, and never suppress a diagnostic to get a clean compile.", +}); + +/** + * redis >=4 <6 → >=5 <7. One package, two majors: 4 → 5 and 5 → 6 are separate rule sets, and + * an upgrade gets the sets of the majors it crosses (4 → 6 gets both). Pack lookup stays one pack + * per package; the gate is here, on the versions every hook receives. + */ +export const redisPack: Pack = { + ...base, + supports: (from, to) => + satisfies(from, base.meta.from) && + satisfies(to, base.meta.to) && + (crosses(5, from, to) || crosses(6, from, to)), + ruleOf: (finding) => { + const id = base.ruleOf?.(finding); + return appliesTo(id, finding.change.from, finding.change.to) ? id : undefined; + }, + // Only findings of crossed majors reach a rewrite; what must never happen is a 4 → 5 rewrite + // on code that is already on 5. + transform(text, finding, context): TransformResult { + const id = base.ruleOf?.(finding); + const major = id === undefined ? undefined : MAJOR.get(id); + if (major !== undefined && satisfies(context.from, `>=${major}.0.0-0`)) + return { + text, + applied: false, + reason: `\`${id}\` is a change from ${major - 1} to ${major}`, + }; + return base.transform(text, finding, context); + }, + runtimeFindings: (input) => + (base.runtimeFindings?.(input) ?? []).filter((f) => appliesTo(f.rule, input.from, input.to)), +}; diff --git a/packages/core/src/packs/redis/names.ts b/packages/core/src/packs/redis/names.ts new file mode 100644 index 00000000..063c77e9 --- /dev/null +++ b/packages/core/src/packs/redis/names.ts @@ -0,0 +1,114 @@ +import type { Finding } from '../../domain/report.js'; +import type { TransformResult } from '../types.js'; + +/** + * v4 enums and the v5 constants that replace them, compared member by member in the published + * declarations and builds (@redis/client 1.6.1 → 5.12.1, @redis/search 1.2.0 → 5.12.1, + * @redis/time-series 1.1.0 → 5.12.1; read 2026-10-14). `members` says what differs; `changed` + * lists the v4 members with no v5 member of the same name and value. Without them, importing + * the constant under the old name changes nothing. + */ +// shared-state: not TS-dependent +export const CONSTANTS = new Map< + string, + { name: string; members?: string; changed?: string[]; type?: true } +>([ + ['SchemaFieldTypes', { name: 'SCHEMA_FIELD_TYPE' }], + ['AggregateSteps', { name: 'FT_AGGREGATE_STEPS' }], + [ + 'AggregateGroupByReducers', + { name: 'FT_AGGREGATE_GROUP_BY_REDUCERS', members: 'TO_LIST is TOLIST', changed: ['TO_LIST'] }, + ], + ['RedisSearchLanguages', { name: 'REDISEARCH_LANGUAGE' }], + [ + 'SchemaTextFieldPhonetics', + { name: 'SCHEMA_TEXT_FIELD_PHONETIC', members: 'FM_PT is DM_PT', changed: ['FM_PT'] }, + ], + ['VectorAlgorithms', { name: 'SCHEMA_VECTOR_FIELD_ALGORITHM' }], + ['TimeSeriesDuplicatePolicies', { name: 'TIME_SERIES_DUPLICATE_POLICIES' }], + ['TimeSeriesEncoding', { name: 'TIME_SERIES_ENCODING' }], + [ + 'TimeSeriesAggregationType', + { + name: 'TIME_SERIES_AGGREGATION_TYPE', + members: 'AVERAGE, MINIMUM and MAXIMUM are AVG, MIN and MAX', + changed: ['AVERAGE', 'MINIMUM', 'MAXIMUM'], + }, + ], + [ + 'TimeSeriesReducers', + { + name: 'TIME_SERIES_REDUCERS', + members: 'MINIMUM and MAXIMUM are MIN and MAX; RANGE sends "RANGE" instead of "range"', + changed: ['MINIMUM', 'MAXIMUM', 'RANGE'], + }, + ], + [ + 'TimeSeriesBucketTimestamp', + { + name: 'TIME_SERIES_BUCKET_TIMESTAMP', + members: 'HIGH is END, MID is MIDDLE', + changed: ['HIGH', 'MID'], + }, + ], + ['GeoReplyWith', { name: 'GEO_REPLY_WITH' }], + ['ClientKillFilters', { name: 'CLIENT_KILL_FILTERS' }], + ['FailoverModes', { name: 'FAILOVER_MODES' }], + ['ClusterSlotStates', { name: 'CLUSTER_SLOT_STATES' }], + ['RedisFlushModes', { name: 'REDIS_FLUSH_MODES' }], + ['FilterBy', { name: 'COMMAND_LIST_FILTER_BY' }], + // A type, renamed with the same shape. + ['SearchOptions', { name: 'FtSearchOptions', type: true }], +]); + +const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*['"](?:redis|@redis\/[\w-]+)['"].*)$/; + +/** + * At the reported import, `{ SchemaFieldTypes }` becomes `{ SCHEMA_FIELD_TYPE as SchemaFieldTypes }`, + * so every use keeps its name. Declined when the import is not on one line, the file uses a + * member whose name or value changed, or uses the name as a type (a constant is not one). + */ +export function renameConstant(text: string, finding: Pick): TransformResult { + const lines = text.split('\n'); + const index = finding.usage.line - 1; + const line = lines[index] ?? ''; + const match = IMPORT.exec(line); + if (!match) return { text, applied: false, reason: 'the reported line is not a one-line import' }; + const [, open, inner, close] = match as unknown as [string, string, string, string]; + const rest = lines.filter((_, i) => i !== index).join('\n'); + const renamed: string[] = []; + const specifiers: string[] = []; + for (const raw of inner.split(',')) { + const [imported = '', local] = raw.trim().split(/\s+as\s+/); + const target = CONSTANTS.get(imported); + if (!target) { + specifiers.push(raw); + continue; + } + const name = local ?? imported; + const member = [...rest.matchAll(new RegExp(`(? m[1] as string) + .find((m) => target.changed?.includes(m)); + if (member) + return { text, applied: false, reason: `${name}.${member} changed in ${target.name}` }; + if ( + !target.type && + new RegExp(`(?:[:<|]|\\bextends|\\bkeyof)\\s*${name}(?![\\w$.])`).test(rest) + ) + return { + text, + applied: false, + reason: `${name} is used as a type; ${target.name} is a value`, + }; + specifiers.push(raw.replace(raw.trim(), `${target.name} as ${name}`)); + renamed.push(name); + } + if (!renamed.length) + return { text, applied: false, reason: 'no v4 enum is imported on this line' }; + lines[index] = `${open}${specifiers.join(',')}${close}`; + return { + text: lines.join('\n'), + applied: true, + reason: `${renamed.join(', ')} imported from the v5 constant under the same local name`, + }; +} diff --git a/packages/core/src/packs/redis/redis.test.ts b/packages/core/src/packs/redis/redis.test.ts new file mode 100644 index 00000000..f690e6bf --- /dev/null +++ b/packages/core/src/packs/redis/redis.test.ts @@ -0,0 +1,171 @@ +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import type { Finding } from '../../domain/report.js'; +import type { Usage } from '../../domain/usage.js'; +import { runFixtures } from '../tooling/fixtures.js'; +import { rewriteScanIterator } from './detect.js'; +import { appliesTo, redisPack } from './index.js'; + +it('redis: every fixture rewrites and detects exactly its marked sites', () => { + const result = runFixtures(redisPack, 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([]); +}); + +const usage = (line: number, file = 'a.ts') => + ({ usage: { file, line, column: 1, symbolPath: '', snippet: '' } }) as unknown as Finding; + +it('redis: each upgrade gets the rule sets of the majors it crosses', () => { + expect(redisPack.supports('4.7.1', '5.12.1')).toBe(true); + expect(redisPack.supports('4.7.1', '6.3.0')).toBe(true); + expect(redisPack.supports('5.8.0', '6.3.0')).toBe(true); + // A minor inside 5 is not a migration. + expect(redisPack.supports('5.1.0', '5.12.1')).toBe(false); + expect(appliesTo('scan-iterators', '4.7.1', '5.12.1')).toBe(true); + expect(appliesTo('scan-iterators', '5.8.0', '6.3.0')).toBe(false); + expect(appliesTo('client-defaults', '4.7.1', '5.12.1')).toBe(false); + expect(appliesTo('client-defaults', '4.7.1', '6.3.0')).toBe(true); + expect(appliesTo('unstable-resp3', '5.8.0', '6.3.0')).toBe(true); + expect(appliesTo('generic', '5.8.0', '6.3.0')).toBe(true); +}); + +it('redis: a 5 → 6 check reports no 4 → 5 site, and a 4 → 5 check no 6 default', () => { + const text = [ + "import { createClient } from 'redis';", + 'const client = createClient({ legacyMode: true });', + 'for await (const key of client.scanIterator()) console.log(key);', + ].join('\n'); + const run = (from: string, to: string) => + ( + redisPack.runtimeFindings?.({ + root: '/r', + workspace: '/r', + from, + to, + installedDir: '', + targetDir: '', + usages: [{ file: 'a.ts' } as Usage], + read: () => text, + }) ?? [] + ).map((f) => `${f.rule}:${f.usage.line}`); + expect(run('4.7.1', '5.12.1')).toEqual(['legacy-mode:2', 'scan-iterators:3']); + expect(run('5.8.0', '6.3.0')).toEqual(['client-defaults:2']); + expect(run('4.7.1', '6.3.0').sort()).toEqual( + ['client-defaults:2', 'legacy-mode:2', 'scan-iterators:3'].sort(), + ); +}); + +it('redis: a 4 → 5 rewrite never runs on code already on 5', () => { + const text = + "import { createClient } from 'redis';\nconst o = { socket: { keepAlive: 1000 } };\n"; + const finding = { + ...usage(2), + rule: 'keep-alive', + change: { from: '5.8.0', to: '6.3.0' }, + } as Finding; + const out = redisPack.transform(text, finding, { + from: '5.8.0', + to: '6.3.0', + includeDeprecated: true, + }); + expect(out.applied).toBe(false); + const v4 = redisPack.transform(text, finding, { + from: '4.7.1', + to: '6.3.0', + includeDeprecated: true, + }); + expect(v4.applied).toBe(true); +}); + +it('redis: a scan loop that breaks or continues is reported but not rewritten', () => { + const text = [ + "import type { RedisClientType } from 'redis';", + 'declare const client: RedisClientType;', + 'export async function first() {', + ' for await (const key of client.scanIterator()) {', + " if (key.startsWith('x')) break;", + ' }', + ' for await (const key of client.scanIterator()) {', + ' for (const c of key) if (c) break;', + ' }', + '}', + ].join('\n'); + expect(rewriteScanIterator(text, usage(4)).applied).toBe(false); + // A `break` of a loop inside the body is its own. + expect(rewriteScanIterator(text, usage(7)).applied).toBe(true); +}); + +it('redis: compiler findings are claimed by the rule the diagnostic names', () => { + const at = (code: number, message: string, from = '5.12.1', to = '6.3.0') => + redisPack.ruleOf?.({ + change: { + package: 'redis', + from, + to, + path: `TS${code}`, + kind: 'type', + severity: 'breaking', + source: 'compiler', + confidence: 1, + }, + usage: { + file: 'a.ts', + line: 1, + column: 1, + symbolPath: `TS${code}`, + compileError: message, + compileCode: code, + }, + severity: 'breaking', + confidence: 1, + } as unknown as Finding); + const wide = + "Type 'RedisClientType' is not assignable to type 'RedisClientType'."; + expect(at(2322, wide)).toBe('client-type'); + // How check reports it: at the type the diagnostic names. + const named = redisPack.ruleOf?.({ + change: { + package: 'redis', + from: '5.12.1', + to: '6.2.0', + path: 'RedisClientType', + kind: 'added', + severity: 'breaking', + source: 'compiler', + confidence: 1, + }, + usage: { file: 'a.ts', line: 1, column: 1, symbolPath: 'RedisClientType', compileError: wide }, + severity: 'breaking', + confidence: 1, + } as unknown as Finding); + expect(named).toBe('client-type'); + // The same diagnostic is not a 4 → 5 change. + expect(at(2322, wide, '4.7.1', '5.12.1')).toBeUndefined(); + expect( + at( + 2345, + "Argument of type 'number' is not assignable to parameter of type 'RedisArgument'.", + '4.7.1', + '6.3.0', + ), + ).toBe('scan-cursor'); + expect( + at( + 2305, + `Module '"@redis/client/dist/lib/commands"' has no exported member 'RedisCommandArgument'.`, + '4.7.1', + '5.12.1', + ), + ).toBe('deep-imports'); + expect( + at( + 2724, + `'"redis"' has no exported member named 'SchemaFieldTypes'. Did you mean 'SchemaFieldType'?`, + '4.7.1', + '5.12.1', + ), + ).toBe('renamed-exports'); +}); diff --git a/packages/core/src/packs/redis/verification.json b/packages/core/src/packs/redis/verification.json new file mode 100644 index 00000000..ed27c4bf --- /dev/null +++ b/packages/core/src/packs/redis/verification.json @@ -0,0 +1,19 @@ +{ + "status": "candidate", + "truth": "sha256-8a955c4e896f7c89acebcb71f61869a533cdca703fd82a3d5da78af9e129fc6e", + "repos": 0, + "breaking": { + "precision": 1, + "recall": 1, + "falsePositives": 0, + "truePositives": 0, + "falseNegatives": 0 + }, + "behavior": { + "precision": 1, + "recall": 1, + "falsePositives": 0, + "truePositives": 0, + "falseNegatives": 0 + } +} diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index 8105e74d..6e8d33cb 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -14,6 +14,8 @@ import { reactPack } from './react/index.js'; import reactVerification from './react/verification.json' with { type: 'json' }; import { reactRouterPack } from './react-router/index.js'; import reactRouterVerification from './react-router/verification.json' with { type: 'json' }; +import { redisPack } from './redis/index.js'; +import redisVerification from './redis/verification.json' with { type: 'json' }; import { sentryBrowserPack } from './sentry-browser/index.js'; import sentryBrowserVerification from './sentry-browser/verification.json' with { type: 'json' }; import { sentryNextjsPack } from './sentry-nextjs/index.js'; @@ -42,6 +44,7 @@ export const REGISTRY: readonly RegisteredPack[] = [ pack: reactRouterPack, verification: reactRouterVerification as PackVerification, }, + { dir: 'redis', pack: redisPack, verification: redisVerification as PackVerification }, { dir: 'sentry-browser', pack: sentryBrowserPack,