From 6a0cc805931ba0e8f7ac37aeb752a947b275f6fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:22:21 +0000 Subject: [PATCH 1/3] feat(packs): add the ioredis 5 to 6 pack and its ground truth Rules, notes, fixtures and ground truth drafted from the ioredis 6 sources and five public upgrades, committed before the pack is scored against them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017BYqQRCpXk6jz9vcCnx21H Signed-off-by: Claude --- packages/core/src/packs/ioredis/behavior.ts | 232 ++++++++++ packages/core/src/packs/ioredis/detect.ts | 405 ++++++++++++++++++ .../fixtures/client-defaults-test/after.ts | 4 + .../fixtures/client-defaults-test/before.ts | 4 + .../client-defaults-test/filename.txt | 1 + .../ioredis/fixtures/client-defaults/after.ts | 31 ++ .../fixtures/client-defaults/before.ts | 31 ++ .../ioredis/fixtures/handshake-auth/after.ts | 17 + .../ioredis/fixtures/handshake-auth/before.ts | 17 + .../fixtures/offline-queue-connect/after.ts | 16 + .../fixtures/offline-queue-connect/before.ts | 16 + .../fixtures/offline-queue-enabled/after.ts | 6 + .../fixtures/offline-queue-enabled/before.ts | 6 + .../ioredis/fixtures/subscriber-mode/after.ts | 27 ++ .../fixtures/subscriber-mode/before.ts | 27 ++ .../core/src/packs/ioredis/ground-truth.json | 108 +++++ packages/core/src/packs/ioredis/index.ts | 69 +++ .../core/src/packs/ioredis/ioredis.test.ts | 13 + .../core/src/packs/ioredis/verification.json | 10 + packages/core/src/packs/registry.ts | 3 + 20 files changed, 1043 insertions(+) create mode 100644 packages/core/src/packs/ioredis/behavior.ts create mode 100644 packages/core/src/packs/ioredis/detect.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/client-defaults-test/after.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/client-defaults-test/before.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/client-defaults-test/filename.txt create mode 100644 packages/core/src/packs/ioredis/fixtures/client-defaults/after.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/client-defaults/before.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/handshake-auth/after.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/handshake-auth/before.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/offline-queue-connect/after.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/offline-queue-connect/before.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/after.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/before.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/subscriber-mode/after.ts create mode 100644 packages/core/src/packs/ioredis/fixtures/subscriber-mode/before.ts create mode 100644 packages/core/src/packs/ioredis/ground-truth.json create mode 100644 packages/core/src/packs/ioredis/index.ts create mode 100644 packages/core/src/packs/ioredis/ioredis.test.ts create mode 100644 packages/core/src/packs/ioredis/verification.json diff --git a/packages/core/src/packs/ioredis/behavior.ts b/packages/core/src/packs/ioredis/behavior.ts new file mode 100644 index 0000000..333dd07 --- /dev/null +++ b/packages/core/src/packs/ioredis/behavior.ts @@ -0,0 +1,232 @@ +import { parse } from 'yaml'; +import { minVersion, satisfies, validRange } from '../../fetch/range.js'; +import type { BehaviorNote } from '../contract.js'; +import type { PackContext } from '../types.js'; +import { + clientDefaultSites, + connectListenerSites, + handshakeAuthSites, + subscriberModeSites, +} from './detect.js'; + +/** Sources read 2026-10-14; see RESEARCH.md for each line. */ +const guide = 'https://github.com/redis/ioredis/wiki/Upgrading-from-v5-to-v6'; +const files = (context: PackContext) => context.inventory ?? []; +const manifests = (context: PackContext) => + files(context).flatMap((f) => { + if (!/(?:^|\/)package\.json$/.test(f.file)) return []; + try { + return [{ file: f.file, pkg: JSON.parse(f.text) as Record }]; + } catch { + return []; + } + }); +const declared = (pkg: Record, name: string): string | undefined => { + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const range = (pkg[field] as Record | undefined)?.[name]; + if (range) return range; + } + return undefined; +}; +/** The declared range admits a release below `version` (or cannot be read as a range). */ +const admitsBelow = (range: string, version: string) => { + if (!validRange(range)) return true; + const lowest = minVersion(range); + return lowest === undefined || !satisfies(lowest, `>=${version}`); +}; + +/** + * Packages that install their own ioredis 5 (an exact or caret dependency, not a peer): a v6 + * client handed to them is a second, incompatible class to TypeScript, and their own connections + * keep speaking v5. Versions from each package's published manifest, read 2026-10-14. + */ +const BUNDLES_V5: { name: string; below?: string; fix: string }[] = [ + { + name: 'bullmq', + below: '6.0.0', + fix: 'bullmq 6 makes ioredis an optional peer (a major upgrade of its own)', + }, + { name: 'bull', fix: 'every bull 4 release depends on ioredis ^5' }, + { name: '@hocuspocus/extension-redis', fix: 'it depends on ioredis ~5.6.1' }, + { name: '@chat-adapter/state-ioredis', fix: 'it depends on ioredis ^5.4.1' }, + { name: 'cache-manager-ioredis-yet', fix: 'deprecated; it depends on ioredis ^5.4.1' }, +]; + +export function bundledCopies(context: PackContext): string | false { + const hits = manifests(context).flatMap(({ file, pkg }) => + BUNDLES_V5.flatMap(({ name, below, fix }) => { + const range = declared(pkg, name); + if (!range || (below && !admitsBelow(range, below))) return []; + return [`\`${file}\` declares ${name} ${JSON.stringify(range)} (${fix})`]; + }), + ); + return hits.length ? hits.join('; ') : false; +} + +/** Instrumentation that patches ioredis only for the version ranges it lists. */ +const INSTRUMENTATION: { name: string; below: string; why: string }[] = [ + { + name: '@opentelemetry/instrumentation-ioredis', + below: '0.70.0', + why: 'releases before 0.70.0 patch ioredis >=2 <6 only', + }, + { + name: '@opentelemetry/auto-instrumentations-node', + below: '0.80.0', + why: 'releases before 0.80.0 bundle instrumentation-ioredis <0.70.0', + }, +]; + +export function instrumentationGaps(context: PackContext): string | false { + const hits = manifests(context).flatMap(({ file, pkg }) => + INSTRUMENTATION.flatMap(({ name, below, why }) => { + const range = declared(pkg, name); + return range && admitsBelow(range, below) + ? [`\`${file}\` declares ${name} ${JSON.stringify(range)} (${why})`] + : []; + }), + ); + return hits.length ? hits.join('; ') : false; +} + +export function staleTypes(context: PackContext): string | false { + const hits = manifests(context) + .filter(({ pkg }) => declared(pkg, '@types/ioredis')) + .map(({ file }) => `\`${file}\` declares @types/ioredis`); + return hits.length ? hits.join('; ') : false; +} + +const NODE = '20.0.0'; + +/** Declarations that permit Node below 20, or none at all; never a guess from the running Node. */ +export function nodeEvidence(context: PackContext): string | false { + const found: { where: string; version: string }[] = []; + for (const { file, text } of files(context)) { + if (/(?:^|\/)package\.json$/.test(file)) { + try { + const node = (JSON.parse(text) as { engines?: { node?: string } }).engines?.node; + if (node) found.push({ where: `\`${file}\` engines.node`, version: node }); + } catch { + /* An unreadable manifest is reported as unknown below. */ + } + } else if (/(?:^|\/)(?:\.nvmrc|\.node-version)$/.test(file)) { + found.push({ where: `\`${file}\``, version: text.trim() }); + } else if (/\.github\/workflows\/.*\.ya?ml$/.test(file)) { + try { + const workflow = parse(text) as { + jobs?: Record< + string, + { + steps?: { with?: Record }[]; + strategy?: { matrix?: Record }; + } + >; + }; + for (const job of Object.values(workflow?.jobs ?? {})) + for (const step of job.steps ?? []) { + const value = step.with?.['node-version']; + if (value === undefined) continue; + const matrix = /^\$\{\{\s*matrix\.([\w-]+)\s*\}\}$/.exec(String(value)); + const versions = matrix ? job.strategy?.matrix?.[matrix[1] as string] : value; + for (const version of Array.isArray(versions) ? versions : [versions]) + if (version !== undefined) + found.push({ where: `\`${file}\` node-version`, version: String(version) }); + } + } catch { + /* As above. */ + } + } + } + const readable = found + .map((hit) => ({ ...hit, lowest: minVersion(hit.version.replace(/^v(?=\d)/, '')) })) + .filter((hit) => hit.lowest); + const low = readable.filter((hit) => !satisfies(hit.lowest as string, `>=${NODE}`)); + if (low.length) + return low + .map((hit) => `${hit.where} ${JSON.stringify(hit.version)} permits Node below 20`) + .join('; '); + return readable.length + ? false + : 'No Node version is declared in package.json engines, .nvmrc/.node-version or CI; ioredis 6 requires Node 20 or newer'; +} + +function note( + id: string, + summary: string, + evidence: (context: PackContext) => string | false, +): BehaviorNote { + return { + id, + summary, + reported: ['decision'], + applies(context) { + const hit = evidence(context); + return hit && `${hit}: ${summary}`; + }, + }; +} + +export const ioredisBehavior: BehaviorNote[] = [ + { + id: 'client-defaults', + summary: + 'clients built without explicit protocol, retryStrategy and keepAlive change behavior in ioredis 6: RESP3 handshake (HELLO 3, AUTH inside it), exponential reconnect backoff capped at 5 s plus jitter instead of linear up to 2 s, and TCP keep-alive after 30 s instead of off. Choose per client: keep the v6 defaults, or pin protocol: 2 / the v5 retryStrategy (Math.min(times * 50, 2000)) / keepAlive: 0. Never change them silently.', + reported: ['finding', 'decision'], + severity: 'deprecated', + detect: clientDefaultSites, + }, + { + id: 'subscriber-mode', + summary: + 'under RESP3 a subscribed connection keeps mode "normal" and accepts regular commands: a comparison with mode \'subscriber\' silently stops matching unless the client pins protocol: 2', + reported: ['finding'], + severity: 'breaking', + detect: subscriberModeSites, + }, + { + id: 'offline-queue-connect', + summary: + 'with enableOfflineQueue: false, commands sent from a "connect" listener can reject before the RESP3 handshake finishes; send them from "ready", or keep the offline queue', + reported: ['finding'], + severity: 'breaking', + detect: connectListenerSites, + }, + { + id: 'handshake-auth', + summary: + 'ioredis 6 authenticates inside HELLO 3 (command name "hello", credentials in its arguments): code that recognizes the AUTH command by name to redact or classify it misses the password', + reported: ['finding'], + severity: 'breaking', + detect: handshakeAuthSites, + }, + note( + 'node-20', + 'ioredis 6 requires Node.js 20 or newer; raise the declared runtime and CI before upgrading.', + nodeEvidence, + ), + note( + 'bundled-copies', + 'these packages install their own ioredis 5: a v6 client passed to them fails to type-check as a second class, and their own connections keep v5 defaults. Choose: upgrade the dependent to a release that peers on ioredis (a migration of its own), pass connection options instead of a client, or keep ioredis 5 until it does. Never cast the client to silence the compiler.', + bundledCopies, + ), + note( + 'instrumentation', + 'tracing instrumentation that only patches ioredis <6 keeps compiling and silently stops recording Redis spans; upgrade it with ioredis.', + instrumentationGaps, + ), + note( + 'stale-types', + '@types/ioredis is a deprecated stub (ioredis ships its own types since v5); remove it rather than let it shadow or confuse the bundled declarations.', + staleTypes, + ), + note( + 'string-prototype', + 'ioredis 6.0.0 switches String.prototype out of V8 fast mode when it defines VerbatimString, slowing string-heavy code process-wide; the fix (redis/ioredis#2206) is not in a stable release at the read date. Measure, or wait for the release that carries it.', + (context) => (context.to === '6.0.0' ? `the target is ${context.to}` : false), + ), + { + id: 'also-review', + summary: `Also review the [ioredis v5 to v6 upgrade guide](${guide}): replyMapping "resp3" is opt-in and changes reply shapes and TypeScript return types; Cluster takes RESP options under redisOptions, Sentinel at the top level.`, + reported: ['decision'], + }, +]; diff --git a/packages/core/src/packs/ioredis/detect.ts b/packages/core/src/packs/ioredis/detect.ts new file mode 100644 index 0000000..9ba1469 --- /dev/null +++ b/packages/core/src/packs/ioredis/detect.ts @@ -0,0 +1,405 @@ +import { ts } from 'ts-morph'; +import type { SourceSite } from '../contract.js'; + +/** + * Syntax-only detectors for what ioredis 6 changes where the compiler is blind: client options + * left at a changed default, and strings the code compares with (`mode`, the handshake command). + * Each one starts from the bindings an `ioredis` import, require or dynamic import creates, so a + * local class that happens to be called `Redis` is never reported. + */ + +const SOURCE = /\.[cm]?[jt]sx?$/; +/** Clients built by tests talk to a test server: their wire defaults are not the application's. */ +const TEST = /(?:^|\/)(?:__tests__|__mocks__|tests?|e2e)\/|\.(?:test|spec|e2e-spec)\.[cm]?[jt]sx?$/; +const MODULE = 'ioredis'; + +const parse = (text: string) => + ts.createSourceFile('input.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + +function walk(node: ts.Node, visit: (node: ts.Node) => void): void { + visit(node); + ts.forEachChild(node, (child) => walk(child, visit)); +} + +function site(source: ts.SourceFile, node: ts.Node, name: string): SourceSite { + const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source)); + return { + line: line + 1, + column: character + 1, + snippet: source.text.split('\n')[line]?.trim() ?? '', + name, + }; +} + +const unwrap = (node: ts.Expression): ts.Expression => + ts.isAwaitExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isNonNullExpression(node) + ? unwrap(node.expression) + : node; + +const isModuleCall = (node: ts.Expression): boolean => { + const call = unwrap(node); + if (!ts.isCallExpression(call) || call.arguments.length !== 1) return false; + const [specifier] = call.arguments; + const callee = call.expression; + return ( + specifier !== undefined && + ts.isStringLiteralLike(specifier) && + specifier.text === MODULE && + (callee.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(callee) && callee.text === 'require')) + ); +}; + +/** + * Local names bound to the module object (`import * as io`, `require('ioredis')`, `await + * import('ioredis')`) and to its client classes (the default export, `Redis`, `Cluster`). + */ +export function ioredisBindings(source: ts.SourceFile): { + modules: Set; + clients: Map; +} { + const modules = new Set(); + const clients = new Map(); + const member = (name: string): 'Redis' | 'Cluster' | undefined => + name === 'default' || name === 'Redis' ? 'Redis' : name === 'Cluster' ? 'Cluster' : undefined; + const bindPattern = (pattern: ts.ObjectBindingPattern) => { + for (const element of pattern.elements) { + const imported = (element.propertyName ?? element.name).getText(source); + const kind = member(imported); + if (kind && ts.isIdentifier(element.name)) clients.set(element.name.text, kind); + } + }; + walk(source, (node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === MODULE && + node.importClause && + !node.importClause.isTypeOnly + ) { + const clause = node.importClause; + if (clause.name) clients.set(clause.name.text, 'Redis'); + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) modules.add(bindings.name.text); + if (bindings && ts.isNamedImports(bindings)) + for (const element of bindings.elements) { + if (element.isTypeOnly) continue; + const kind = member((element.propertyName ?? element.name).text); + if (kind) clients.set(element.name.text, kind); + } + } + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + ts.isStringLiteral(node.moduleReference.expression) && + node.moduleReference.expression.text === MODULE + ) + clients.set(node.name.text, 'Redis'); + if (ts.isVariableDeclaration(node) && node.initializer) { + const init = unwrap(node.initializer); + if (isModuleCall(init)) { + if (ts.isIdentifier(node.name)) { + // `require` returns the class itself (module.exports = Redis); `import()` the namespace. + modules.add(node.name.text); + if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) + clients.set(node.name.text, 'Redis'); + } else if (ts.isObjectBindingPattern(node.name)) bindPattern(node.name); + } else if (ts.isPropertyAccessExpression(init) && ts.isIdentifier(node.name)) { + const kind = member(init.name.text); + const target = unwrap(init.expression); + if (kind && (isModuleCall(target) || (ts.isIdentifier(target) && modules.has(target.text)))) + clients.set(node.name.text, kind); + } else if ( + ts.isIdentifier(init) && + modules.has(init.text) && + ts.isObjectBindingPattern(node.name) + ) + bindPattern(node.name); + } + }); + return { modules, clients }; +} + +/** The client class a `new` expression constructs, when it is one of ioredis's. */ +function constructed( + expression: ts.Expression, + bindings: ReturnType, +): 'Redis' | 'Cluster' | undefined { + const callee = unwrap(expression); + if (ts.isIdentifier(callee)) return bindings.clients.get(callee.text); + if (!ts.isPropertyAccessExpression(callee)) return undefined; + const owner = unwrap(callee.expression); + const name = callee.name.text; + const known = + (ts.isIdentifier(owner) && + (bindings.modules.has(owner.text) || bindings.clients.has(owner.text))) || + isModuleCall(owner); + if (!known) return undefined; + return name === 'Cluster' + ? 'Cluster' + : name === 'Redis' || name === 'default' + ? 'Redis' + : undefined; +} + +export interface ClientConstruction { + node: ts.NewExpression; + kind: 'Redis' | 'Cluster'; + /** Option names written in object literals passed to it, nested ones included (`redisOptions.protocol`). */ + options: Set; + /** An argument whose keys cannot be read (a variable, a spread): its options are unknown. */ + opaque: boolean; +} + +function optionNames(node: ts.Expression, names: Set, prefix = ''): boolean /* opaque */ { + const value = unwrap(node); + if (!ts.isObjectLiteralExpression(value)) return true; + let opaque = false; + for (const property of value.properties) { + if (ts.isSpreadAssignment(property)) { + opaque = true; + continue; + } + const name = property.name; + if (!name || !(ts.isIdentifier(name) || ts.isStringLiteral(name))) continue; + names.add(`${prefix}${name.text}`); + if ( + ts.isPropertyAssignment(property) && + ts.isObjectLiteralExpression(unwrap(property.initializer)) + ) + opaque = optionNames(property.initializer, names, `${prefix}${name.text}.`) || opaque; + } + return opaque; +} + +export function clientConstructions(source: ts.SourceFile): ClientConstruction[] { + const bindings = ioredisBindings(source); + if (bindings.clients.size === 0 && bindings.modules.size === 0) return []; + const found: ClientConstruction[] = []; + walk(source, (node) => { + if (!ts.isNewExpression(node)) return; + const kind = constructed(node.expression, bindings); + if (!kind) return; + const options = new Set(); + let opaque = false; + // Redis(port, host, options) / Redis(url, options) / Redis(options); Cluster(nodes, options). + const args = [...(node.arguments ?? [])].slice(kind === 'Cluster' ? 1 : 0); + for (const arg of args) { + const value = unwrap(arg); + if ( + ts.isStringLiteralLike(value) || + ts.isNumericLiteral(value) || + ts.isTemplateExpression(value) + ) + continue; + if (ts.isObjectLiteralExpression(value)) opaque = optionNames(value, options) || opaque; + // A URL or port held in a variable is the usual case for a non-final argument. + else if (arg === args[args.length - 1]) opaque = true; + } + found.push({ node, kind, options, opaque }); + }); + return found; +} + +/** The defaults ioredis 6 changed that a client inherits unless it sets them. */ +const PINNED = { + Redis: ['protocol', 'retryStrategy', 'keepAlive'], + Cluster: ['redisOptions.protocol', 'redisOptions.keepAlive'], +} as const; + +/** + * Clients that inherit at least one default ioredis 6 changed: RESP3 (`protocol`), the + * reconnect backoff (`retryStrategy`) and TCP keep-alive (`keepAlive`). Test files are skipped. + */ +export function clientDefaultSites(text: string, file: string): SourceSite[] { + if (!SOURCE.test(file) || TEST.test(file)) return []; + const source = parse(text); + return clientConstructions(source) + .filter((c) => c.opaque || PINNED[c.kind].some((option) => !c.options.has(option))) + .map((c) => site(source, c.node, c.kind)); +} + +const SUBSCRIBER = 'subscriber'; +const EQUALITY = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); +const isModeAccess = (node: ts.Expression) => { + const value = unwrap(node); + return ( + (ts.isPropertyAccessExpression(value) && value.name.text === 'mode') || + (ts.isElementAccessExpression(value) && + ts.isStringLiteralLike(value.argumentExpression) && + value.argumentExpression.text === 'mode') + ); +}; + +/** + * `client.mode === 'subscriber'`: under RESP3 a subscribed connection keeps `mode` "normal", + * so the branch silently stops being taken. Only in files that import ioredis. + */ +export function subscriberModeSites(text: string, file: string): SourceSite[] { + if (!SOURCE.test(file)) return []; + const source = parse(text); + const bindings = ioredisBindings(source); + if ( + bindings.clients.size === 0 && + bindings.modules.size === 0 && + !/from\s+['"]ioredis['"]/.test(text) + ) + return []; + const sites: SourceSite[] = []; + walk(source, (node) => { + if (ts.isBinaryExpression(node) && EQUALITY.has(node.operatorToken.kind)) { + const [left, right] = [unwrap(node.left), unwrap(node.right)]; + const literal = ts.isStringLiteralLike(right) + ? right + : ts.isStringLiteralLike(left) + ? left + : undefined; + const other = literal === right ? left : right; + if (literal?.text === SUBSCRIBER && isModeAccess(other)) + sites.push(site(source, node, 'mode')); + } + if (ts.isSwitchStatement(node) && isModeAccess(node.expression)) + for (const clause of node.caseBlock.clauses) + if ( + ts.isCaseClause(clause) && + ts.isStringLiteralLike(clause.expression) && + clause.expression.text === SUBSCRIBER + ) + sites.push(site(source, clause, 'mode')); + }); + return sites; +} + +/** EventEmitter and lifecycle members: calling them in a listener sends nothing to Redis. */ +const NOT_COMMANDS = new Set([ + 'on', + 'once', + 'off', + 'emit', + 'addListener', + 'prependListener', + 'removeListener', + 'removeAllListeners', + 'listeners', + 'listenerCount', + 'setMaxListeners', + 'connect', + 'disconnect', + 'duplicate', +]); + +const offlineQueueOff = (c: ClientConstruction) => { + let off = false; + walk(c.node, (node) => { + if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + node.name.text === 'enableOfflineQueue' && + unwrap(node.initializer).kind === ts.SyntaxKind.FalseKeyword + ) + off = true; + }); + return off; +}; + +/** + * A `connect` listener that sends a command on its client, in a file that builds a client with + * `enableOfflineQueue: false`: in ioredis 6 the command may reject because the RESP3 handshake + * has not finished ("Stream isn't writeable and enableOfflineQueue options is false"). + */ +export function connectListenerSites(text: string, file: string): SourceSite[] { + if (!SOURCE.test(file) || !/enableOfflineQueue/.test(text)) return []; + const source = parse(text); + if (!clientConstructions(source).some(offlineQueueOff)) return []; + const sites: SourceSite[] = []; + walk(source, (node) => { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return; + const method = node.expression.name.text; + const [event, listener] = node.arguments; + if ( + !(method === 'on' || method === 'once' || method === 'addListener') || + !event || + !ts.isStringLiteralLike(event) || + event.text !== 'connect' || + !listener || + !(ts.isArrowFunction(listener) || ts.isFunctionExpression(listener)) + ) + return; + const receiver = node.expression.expression.getText(source); + let sends = false; + walk(listener.body, (inner) => { + if ( + ts.isCallExpression(inner) && + ts.isPropertyAccessExpression(inner.expression) && + inner.expression.expression.getText(source) === receiver && + !NOT_COMMANDS.has(inner.expression.name.text) + ) + sends = true; + }); + if (sends) sites.push(site(source, node, 'connect')); + }); + return sites; +} + +const isCommandName = (node: ts.Expression) => { + const value = unwrap(node); + return ( + ts.isPropertyAccessExpression(value) && + value.name.text === 'name' && + /\bcommand\b/i.test(value.expression.getText()) + ); +}; +const AUTH = /^auth$/i; + +/** + * Code that recognizes the authentication command by name (`err.command.name === 'auth'`), to + * redact or classify it. ioredis 6 authenticates with `HELLO 3 AUTH `, so the + * credentials now travel as the arguments of a command named `hello` and slip past such checks. + */ +export function handshakeAuthSites(text: string, file: string): SourceSite[] { + if (!SOURCE.test(file) || !/auth/i.test(text)) return []; + const source = parse(text); + const sites: SourceSite[] = []; + walk(source, (node) => { + if (ts.isBinaryExpression(node) && EQUALITY.has(node.operatorToken.kind)) { + const [left, right] = [unwrap(node.left), unwrap(node.right)]; + if ( + (ts.isStringLiteralLike(right) && AUTH.test(right.text) && isCommandName(left)) || + (ts.isStringLiteralLike(left) && AUTH.test(left.text) && isCommandName(right)) + ) + sites.push(site(source, node, 'auth')); + } + if (ts.isSwitchStatement(node) && isCommandName(node.expression)) + for (const clause of node.caseBlock.clauses) + if ( + ts.isCaseClause(clause) && + ts.isStringLiteralLike(clause.expression) && + AUTH.test(clause.expression.text) + ) + sites.push(site(source, clause, 'auth')); + // ['auth', ...].includes(err.command.name) + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'includes' && + node.arguments[0] && + isCommandName(node.arguments[0]) + ) { + const list = unwrap(node.expression.expression); + if ( + ts.isArrayLiteralExpression(list) && + list.elements.some((e) => ts.isStringLiteralLike(e) && AUTH.test(e.text)) + ) + sites.push(site(source, node, 'auth')); + } + }); + return sites; +} diff --git a/packages/core/src/packs/ioredis/fixtures/client-defaults-test/after.ts b/packages/core/src/packs/ioredis/fixtures/client-defaults-test/after.ts new file mode 100644 index 0000000..83c43ef --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/client-defaults-test/after.ts @@ -0,0 +1,4 @@ +import Redis from 'ioredis'; + +// A test's client talks to a test server: its wire defaults are not the application's. +export const client = new Redis(); // @uptide client-defaults keep at:new diff --git a/packages/core/src/packs/ioredis/fixtures/client-defaults-test/before.ts b/packages/core/src/packs/ioredis/fixtures/client-defaults-test/before.ts new file mode 100644 index 0000000..83c43ef --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/client-defaults-test/before.ts @@ -0,0 +1,4 @@ +import Redis from 'ioredis'; + +// A test's client talks to a test server: its wire defaults are not the application's. +export const client = new Redis(); // @uptide client-defaults keep at:new diff --git a/packages/core/src/packs/ioredis/fixtures/client-defaults-test/filename.txt b/packages/core/src/packs/ioredis/fixtures/client-defaults-test/filename.txt new file mode 100644 index 0000000..68459e1 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/client-defaults-test/filename.txt @@ -0,0 +1 @@ +src/__tests__/redis.test.ts diff --git a/packages/core/src/packs/ioredis/fixtures/client-defaults/after.ts b/packages/core/src/packs/ioredis/fixtures/client-defaults/after.ts new file mode 100644 index 0000000..62edc74 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/client-defaults/after.ts @@ -0,0 +1,31 @@ +import Redis, { Cluster } from 'ioredis'; +import * as io from 'ioredis'; + +const url = process.env.REDIS_URL ?? 'redis://localhost:6379'; +const shared = { lazyConnect: true }; + +export const plain = new Redis(url); // @uptide client-defaults at:new +export const tuned = new Redis(url, { maxRetriesPerRequest: 1 }); // @uptide client-defaults at:new +export const partly = new Redis(url, { protocol: 2 }); // @uptide client-defaults at:new +export const spread = new io.Redis({ ...shared, protocol: 2, retryStrategy: () => 50, keepAlive: 0 }); // @uptide client-defaults at:new +export const cluster = new Cluster([{ host: 'a', port: 6379 }]); // @uptide client-defaults at:new +export const clusterViaDefault = new Redis.Cluster([], { scaleReads: 'slave' }); // @uptide client-defaults at:new +// Every changed default chosen explicitly: nothing left to decide. +export const pinned = new Redis(url, { protocol: 2, retryStrategy: (times: number) => Math.min(times * 50, 2000), keepAlive: 0 }); // @uptide client-defaults keep at:new +export const pinnedCluster = new Cluster([], { redisOptions: { protocol: 2, keepAlive: 0 } }); // @uptide client-defaults keep at:new + +export async function lazily() { + const { Redis: Client } = await import('ioredis'); + return new Client(url, { lazyConnect: true }); // @uptide client-defaults at:new +} + +export async function viaDefault() { + const Client = (await import('ioredis')).default; + return new Client(url); // @uptide client-defaults at:new +} + +// Not ioredis: a class of the same shape from elsewhere. +class Pool { + constructor(readonly url: string) {} +} +export const pool = new Pool(url); // @uptide client-defaults keep at:new diff --git a/packages/core/src/packs/ioredis/fixtures/client-defaults/before.ts b/packages/core/src/packs/ioredis/fixtures/client-defaults/before.ts new file mode 100644 index 0000000..62edc74 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/client-defaults/before.ts @@ -0,0 +1,31 @@ +import Redis, { Cluster } from 'ioredis'; +import * as io from 'ioredis'; + +const url = process.env.REDIS_URL ?? 'redis://localhost:6379'; +const shared = { lazyConnect: true }; + +export const plain = new Redis(url); // @uptide client-defaults at:new +export const tuned = new Redis(url, { maxRetriesPerRequest: 1 }); // @uptide client-defaults at:new +export const partly = new Redis(url, { protocol: 2 }); // @uptide client-defaults at:new +export const spread = new io.Redis({ ...shared, protocol: 2, retryStrategy: () => 50, keepAlive: 0 }); // @uptide client-defaults at:new +export const cluster = new Cluster([{ host: 'a', port: 6379 }]); // @uptide client-defaults at:new +export const clusterViaDefault = new Redis.Cluster([], { scaleReads: 'slave' }); // @uptide client-defaults at:new +// Every changed default chosen explicitly: nothing left to decide. +export const pinned = new Redis(url, { protocol: 2, retryStrategy: (times: number) => Math.min(times * 50, 2000), keepAlive: 0 }); // @uptide client-defaults keep at:new +export const pinnedCluster = new Cluster([], { redisOptions: { protocol: 2, keepAlive: 0 } }); // @uptide client-defaults keep at:new + +export async function lazily() { + const { Redis: Client } = await import('ioredis'); + return new Client(url, { lazyConnect: true }); // @uptide client-defaults at:new +} + +export async function viaDefault() { + const Client = (await import('ioredis')).default; + return new Client(url); // @uptide client-defaults at:new +} + +// Not ioredis: a class of the same shape from elsewhere. +class Pool { + constructor(readonly url: string) {} +} +export const pool = new Pool(url); // @uptide client-defaults keep at:new diff --git a/packages/core/src/packs/ioredis/fixtures/handshake-auth/after.ts b/packages/core/src/packs/ioredis/fixtures/handshake-auth/after.ts new file mode 100644 index 0000000..3fbd563 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/handshake-auth/after.ts @@ -0,0 +1,17 @@ +interface CommandError extends Error { + command?: { name: string; args: unknown[] }; +} + +export function redact(err: CommandError, user: { name: string }): CommandError { + if (err.command?.name === 'auth') err.command.args = []; // @uptide handshake-auth at:err.command + if ('AUTH' == err.command?.name) return err; // @uptide handshake-auth at:'AUTH' + switch (err.command?.name) { + case 'auth': // @uptide handshake-auth at:case + break; + } + if (err.command && ['auth', 'config'].includes(err.command.name)) return err; // @uptide handshake-auth at:['auth' + // Other names, and an "auth" that is not a Redis command, are left alone. + if (err.command?.name === 'hello') return err; // @uptide handshake-auth keep at:err.command + if (user.name === 'auth') return err; // @uptide handshake-auth keep at:user.name + return err; +} diff --git a/packages/core/src/packs/ioredis/fixtures/handshake-auth/before.ts b/packages/core/src/packs/ioredis/fixtures/handshake-auth/before.ts new file mode 100644 index 0000000..3fbd563 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/handshake-auth/before.ts @@ -0,0 +1,17 @@ +interface CommandError extends Error { + command?: { name: string; args: unknown[] }; +} + +export function redact(err: CommandError, user: { name: string }): CommandError { + if (err.command?.name === 'auth') err.command.args = []; // @uptide handshake-auth at:err.command + if ('AUTH' == err.command?.name) return err; // @uptide handshake-auth at:'AUTH' + switch (err.command?.name) { + case 'auth': // @uptide handshake-auth at:case + break; + } + if (err.command && ['auth', 'config'].includes(err.command.name)) return err; // @uptide handshake-auth at:['auth' + // Other names, and an "auth" that is not a Redis command, are left alone. + if (err.command?.name === 'hello') return err; // @uptide handshake-auth keep at:err.command + if (user.name === 'auth') return err; // @uptide handshake-auth keep at:user.name + return err; +} diff --git a/packages/core/src/packs/ioredis/fixtures/offline-queue-connect/after.ts b/packages/core/src/packs/ioredis/fixtures/offline-queue-connect/after.ts new file mode 100644 index 0000000..a6dc551 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/offline-queue-connect/after.ts @@ -0,0 +1,16 @@ +import Redis from 'ioredis'; + +const redis = new Redis({ enableOfflineQueue: false }); // @uptide client-defaults at:new + +redis.on('connect', () => { // @uptide offline-queue-connect at:redis.on + void redis.subscribe('updates'); +}); + +redis.once('connect', function () { // @uptide offline-queue-connect at:redis.once + redis.client('SETNAME', 'worker'); +}); + +// Listeners that send nothing, and commands sent once the client is ready, are left alone. +redis.on('connect', () => console.log('connected')); // @uptide offline-queue-connect keep at:redis.on +redis.on('connect', () => redis.removeAllListeners('message')); // @uptide offline-queue-connect keep at:redis.on +redis.on('ready', () => void redis.subscribe('updates')); // @uptide offline-queue-connect keep at:redis.on diff --git a/packages/core/src/packs/ioredis/fixtures/offline-queue-connect/before.ts b/packages/core/src/packs/ioredis/fixtures/offline-queue-connect/before.ts new file mode 100644 index 0000000..a6dc551 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/offline-queue-connect/before.ts @@ -0,0 +1,16 @@ +import Redis from 'ioredis'; + +const redis = new Redis({ enableOfflineQueue: false }); // @uptide client-defaults at:new + +redis.on('connect', () => { // @uptide offline-queue-connect at:redis.on + void redis.subscribe('updates'); +}); + +redis.once('connect', function () { // @uptide offline-queue-connect at:redis.once + redis.client('SETNAME', 'worker'); +}); + +// Listeners that send nothing, and commands sent once the client is ready, are left alone. +redis.on('connect', () => console.log('connected')); // @uptide offline-queue-connect keep at:redis.on +redis.on('connect', () => redis.removeAllListeners('message')); // @uptide offline-queue-connect keep at:redis.on +redis.on('ready', () => void redis.subscribe('updates')); // @uptide offline-queue-connect keep at:redis.on diff --git a/packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/after.ts b/packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/after.ts new file mode 100644 index 0000000..260cfe2 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/after.ts @@ -0,0 +1,6 @@ +import Redis from 'ioredis'; + +// The offline queue is on (the default): commands from "connect" wait for the handshake. +const redis = new Redis({ lazyConnect: true }); // @uptide client-defaults at:new + +redis.on('connect', () => void redis.subscribe('updates')); // @uptide offline-queue-connect keep at:redis.on diff --git a/packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/before.ts b/packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/before.ts new file mode 100644 index 0000000..260cfe2 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/offline-queue-enabled/before.ts @@ -0,0 +1,6 @@ +import Redis from 'ioredis'; + +// The offline queue is on (the default): commands from "connect" wait for the handshake. +const redis = new Redis({ lazyConnect: true }); // @uptide client-defaults at:new + +redis.on('connect', () => void redis.subscribe('updates')); // @uptide offline-queue-connect keep at:redis.on diff --git a/packages/core/src/packs/ioredis/fixtures/subscriber-mode/after.ts b/packages/core/src/packs/ioredis/fixtures/subscriber-mode/after.ts new file mode 100644 index 0000000..4ac6352 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/subscriber-mode/after.ts @@ -0,0 +1,27 @@ +import type { Redis } from 'ioredis'; + +declare const subscriber: Redis; + +export function isSubscribed(): boolean { + return subscriber.mode === 'subscriber'; // @uptide subscriber-mode at:subscriber.mode +} + +export function canQuery(): boolean { + if ('subscriber' !== subscriber['mode']) return true; // @uptide subscriber-mode at:'subscriber' + return false; +} + +export function describe(): string { + switch (subscriber.mode) { + case 'subscriber': // @uptide subscriber-mode at:case + return 'subscribed'; + case 'normal': // @uptide subscriber-mode keep at:case + return 'normal'; + default: + return 'other'; + } +} + +// Other properties and plain objects that happen to say "subscriber" are left alone. +export const job = { mode: 'subscriber' }; // @uptide subscriber-mode keep at:mode +export const waiting = subscriber.status === 'subscriber'; // @uptide subscriber-mode keep at:subscriber.status diff --git a/packages/core/src/packs/ioredis/fixtures/subscriber-mode/before.ts b/packages/core/src/packs/ioredis/fixtures/subscriber-mode/before.ts new file mode 100644 index 0000000..4ac6352 --- /dev/null +++ b/packages/core/src/packs/ioredis/fixtures/subscriber-mode/before.ts @@ -0,0 +1,27 @@ +import type { Redis } from 'ioredis'; + +declare const subscriber: Redis; + +export function isSubscribed(): boolean { + return subscriber.mode === 'subscriber'; // @uptide subscriber-mode at:subscriber.mode +} + +export function canQuery(): boolean { + if ('subscriber' !== subscriber['mode']) return true; // @uptide subscriber-mode at:'subscriber' + return false; +} + +export function describe(): string { + switch (subscriber.mode) { + case 'subscriber': // @uptide subscriber-mode at:case + return 'subscribed'; + case 'normal': // @uptide subscriber-mode keep at:case + return 'normal'; + default: + return 'other'; + } +} + +// Other properties and plain objects that happen to say "subscriber" are left alone. +export const job = { mode: 'subscriber' }; // @uptide subscriber-mode keep at:mode +export const waiting = subscriber.status === 'subscriber'; // @uptide subscriber-mode keep at:subscriber.status diff --git a/packages/core/src/packs/ioredis/ground-truth.json b/packages/core/src/packs/ioredis/ground-truth.json new file mode 100644 index 0000000..c80dec6 --- /dev/null +++ b/packages/core/src/packs/ioredis/ground-truth.json @@ -0,0 +1,108 @@ +{ + "$comment": "Ground truth for the ioredis pack (docs/packs.md, \"Ground truth\"). Each public repository is pinned at the commit before its own merged ioredis 5 to 6 upgrade. Expected findings come from two sources that are not Uptide, drafted by pnpm packs:truth on 2026-10-14 and read by hand before any pack test run: the repository's own TypeScript with ioredis 6.0.0 linked (no new diagnostic in any of the five), and the lines the upgrade changed for ioredis. Anchor rule, fixed before scoring and applied to every repository: one site per client construction the upgrade changed, at the first changed line of code of the run that contains it; a second construction in the same run starts its own site; edits that only serve that construction (a moved import, a widened constructor type) belong to it; pure insertions (an option added to an unchanged call), comments, and other packages' changes (@sentry/node, mermaid, vitest) are not sites. Unrelated packages bumped in the same commit or branch are not listed under `with`: they share no dependency edge with ioredis. RESEARCH.md has the evidence for each entry and the repositories screened out.", + "package": "ioredis", + "repos": [ + { + "repo": "ferni-ai/voiceai", + "commit": "c59ed0982f96e23d2b2f5728711da5b55e07483a", + "migration": "https://github.com/ferni-ai/voiceai/commit/c6838d5ecdc0bd319c706621b2bfc9d058a2dda0", + "from": "5.8.2", + "to": "6.0.0", + "why": "pins protocol: 2 at every client construction to keep RESP2 after the upgrade (PR #703); the same commit bumps @sentry/node 10 to 11, whose changes are not scored", + "findings": [ + { + "file": "src/memory/storage/redis-cache/redis-cache.ts", + "line": 98, + "rule": "client-defaults", + "note": "url branch rebuilt with protocol: 2; the run starts at the removed let declaration" + }, + { + "file": "src/memory/storage/redis-cache/redis-cache.ts", + "line": 108, + "rule": "client-defaults", + "note": "host/port construction, same run, its own client" + }, + { + "file": "src/services/workflows/jobs/redis-job-queue.ts", + "line": 89, + "rule": "client-defaults", + "note": "url branch rebuilt with protocol: 2" + }, + { + "file": "src/services/workflows/jobs/redis-job-queue.ts", + "line": 98, + "rule": "client-defaults", + "note": "host/port construction, same run, its own client" + }, + { + "file": "src/memory/vectors/embedding-cache.ts", + "line": 134, + "rule": "client-defaults", + "note": "construction through a module name hidden from bundlers; the constructor type widened at :126 serves it" + } + ] + }, + { + "repo": "syzygyhack/open-foundry", + "commit": "73910018b5e8119cf4db99a81ff145726451aa3b", + "migration": "https://github.com/syzygyhack/open-foundry/commit/a466e0d364c2e2dd0fae8bbcf30ef9de25978b18", + "from": "5.10.1", + "to": "6.0.0", + "why": "rate-limiter client with enableOfflineQueue: false keeps RESP2 (protocol: 2) for Redis-compatible proxies; the dynamic-import constructor is retyped so the option is checked", + "findings": [ + { + "file": "packages/api/src/server.ts", + "line": 136, + "rule": "client-defaults", + "note": "constructor acquisition rewritten; protocol: 2 inserted into the call at :138" + } + ] + }, + { + "repo": "simondietschi/dokunc", + "commit": "83681ed0ccc527fa1dd5c802f554f26a5eac575c", + "migration": "https://github.com/simondietschi/dokunc/commit/c47539089296efdb02a3093bb650185b4861f297", + "from": "5.11.1", + "to": "6.0.0", + "why": "merged in PR #15 (03ae5da): bump bb72cd7 extends the credential-redaction test to HELLO 3 AUTH; c475390 restores the v5 retryStrategy at the collab client after measuring 10-15 s command stalls; @hocuspocus/extension-redis keeps its own ioredis 5.6.1", + "findings": [ + { + "file": "apps/collab/src/server.ts", + "line": 108, + "rule": "client-defaults", + "note": "construction moved to createRedisClient with the v5 retryStrategy; the import removed at :9 serves it" + }, + { + "file": "apps/web/src/lib/log.test.ts", + "line": 20, + "rule": "handshake-auth", + "note": "the fake ioredis error now takes the command, to cover hello [3, AUTH, user, password]" + }, + { + "file": "apps/web/src/lib/log.test.ts", + "line": 28, + "rule": "handshake-auth", + "note": "the redaction assertion runs for hello and auth" + } + ] + }, + { + "repo": "safe-global/safe-client-gateway", + "commit": "14bd05b98bf41df509de0bfee8bc662cb9d95d1e", + "migration": "https://github.com/safe-global/safe-client-gateway/commit/80135d22e8006fec71e7da270efdff65ec092c89", + "from": "5.11.1", + "to": "6.0.0", + "why": "manifest-only upgrade (PR #3409) of a Yarn Berry service that already runs bullmq 6: no source change, no compiler diagnostic", + "findings": [] + }, + { + "repo": "kazumadev619-dev/FishingConditionsApp", + "commit": "8ba3f0d24bc709169b79df11daa87307df32e77c", + "migration": "https://github.com/kazumadev619-dev/FishingConditionsApp/commit/cdecc03c3c04d0e4f08351ceef6b10df6f1bfd81", + "from": "5.11.1", + "to": "6.0.0", + "why": "npm upgrade merged in PR #235 (ebf69b5) that removes the stale @types/ioredis stub and keeps its own retryStrategy: no source change, no compiler diagnostic", + "findings": [] + } + ] +} diff --git a/packages/core/src/packs/ioredis/index.ts b/packages/core/src/packs/ioredis/index.ts new file mode 100644 index 0000000..90ce20c --- /dev/null +++ b/packages/core/src/packs/ioredis/index.ts @@ -0,0 +1,69 @@ +import { definePack } from '../contract.js'; +import { ioredisBehavior } from './behavior.js'; + +const guide = 'https://github.com/redis/ioredis/wiki/Upgrading-from-v5-to-v6'; +const tag = 'https://github.com/redis/ioredis/blob/v6.0.0'; +const commit = (sha: string) => `https://github.com/redis/ioredis/commit/${sha}`; + +/** + * ioredis >=5 <6 → >=6 <7. Sources read 2026-10-14; the classification of every change, the + * ground truth and what the engine cannot express are in RESEARCH.md. ioredis 6 keeps the v5 + * reply shapes and types for default clients ("legacy" mapping), removes no command, and changes + * connection behavior the compiler cannot see: almost everything here is a note, never a rewrite. + */ +export const ioredisPack = definePack({ + meta: { + package: 'ioredis', + from: '>=5 <6', + to: '>=6 <7', + sources: [ + { title: 'Upgrading from v5 to v6, ioredis wiki at e859962 (read 2026-10-14)', url: guide }, + { title: 'ioredis 6.0.0 changelog (read 2026-10-14)', url: `${tag}/CHANGELOG.md` }, + { title: 'ioredis 6.0.0 README, RESP3 Protocol (read 2026-10-14)', url: `${tag}/README.md` }, + { + title: 'Add RESP3 (#2127), the breaking commit', + url: commit('5d0862e078c1252470872fa3a96d7435bb34f078'), + }, + { + title: 'Improve default connection resilience (#2160): retryStrategy and keepAlive', + url: commit('6d0716ef9b3c7c5676c8d866388075e927f6b8b7'), + }, + { + title: 'Keep String.prototype in fast mode (#2206), after 6.0.0', + url: commit('90e1261ce71f4e77ad00f54690eda54123c34bc6'), + }, + { + title: 'BullMQ 6.0.0 changelog: ioredis becomes an optional peer (read 2026-10-14)', + url: 'https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/changelog.md', + }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '6.0.0', + inventory: ['package.json', '{.nvmrc,.node-version}', '.github/workflows/*.{yml,yaml}'], + rules: [ + { + id: 'bundled-ioredis', + summary: 'a v6 client passed where a dependency expects the ioredis 5 it installs itself', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2322|2345|2739|2740|2741)$/, + message: /\b(?:Redis|Cluster)<"(?:legacy|resp3)">'? is not assignable to/, + guide: + 'The dependency at this site installs its own ioredis 5 (bullmq before 6, bull, @hocuspocus/extension-redis and others: see Decisions), so TypeScript sees two Redis classes. Options: (a) upgrade the dependent to a release that peers on ioredis, such as bullmq 6, which is a major migration of its own and must be reported as one; (b) pass connection options instead of the client instance, which hands connection ownership to the dependent; (c) keep ioredis 5 in this workspace until the dependent accepts 6; (d) where the workspace already forces one ioredis copy (an override), confirm the dependent supports 6 before relying on it. Say which you chose. Never cast the client (as any, as unknown as) to silence the error.', + }, + { + id: 'duplicate-signature', + summary: 'Redis#duplicate and Cluster#duplicate became generic over the reply mapping', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2416|2430)$/, + message: /'duplicate'/, + guide: + 'ioredis 6 declares duplicate(override?) returning Redis, and Cluster#duplicate takes ClusterNode[] instead of any[]. A subclass override must match: make it generic the same way and return the base result, keeping the options it passes. Do not widen it to any.', + }, + ], + behavior: ioredisBehavior, + instructions: + 'Sources read 2026-10-14; see RESEARCH.md. ioredis 6 keeps default reply shapes ("legacy" mapping) and removes no command. Migrate only reported sites. Do not change protocol, retryStrategy, keepAlive, enableOfflineQueue or replyMapping unless the decision for that client says so, and never opt into replyMapping "resp3" as part of the upgrade. Preserve credential redaction, error handling, reconnect behavior and pub/sub semantics. Never cast a client to any or suppress a diagnostic to get a clean compile.', +}); diff --git a/packages/core/src/packs/ioredis/ioredis.test.ts b/packages/core/src/packs/ioredis/ioredis.test.ts new file mode 100644 index 0000000..9391d25 --- /dev/null +++ b/packages/core/src/packs/ioredis/ioredis.test.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { runFixtures } from '../tooling/fixtures.js'; +import { ioredisPack } from './index.js'; + +it('ioredis: every fixture rewrites and detects exactly its marked sites', () => { + const result = runFixtures(ioredisPack, fileURLToPath(new URL('.', import.meta.url))); + expect(result.cases.length).toBeGreaterThan(0); + expect(result.unknown).toEqual([]); + expect(result.rewriteFailures).toEqual([]); + expect(result.falsePositives).toEqual([]); + expect(result.falseNegatives).toEqual([]); +}); diff --git a/packages/core/src/packs/ioredis/verification.json b/packages/core/src/packs/ioredis/verification.json new file mode 100644 index 0000000..250fb89 --- /dev/null +++ b/packages/core/src/packs/ioredis/verification.json @@ -0,0 +1,10 @@ +{ + "status": "candidate", + "truth": "sha256-6b9013a4677b5c9f41d07866bc89268fef500a257f33466efc36f363b8fd5de8", + "repos": 0, + "breaking": { + "precision": 1, + "recall": 1, + "falsePositives": 0 + } +} diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index 05fcda5..8105e74 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -4,6 +4,8 @@ import { aiPack } from './ai/index.js'; import aiVerification from './ai/verification.json' with { type: 'json' }; import type { PackVerification, RegisteredPack } from './contract.js'; +import { ioredisPack } from './ioredis/index.js'; +import ioredisVerification from './ioredis/verification.json' with { type: 'json' }; import { lucideReactPack } from './lucide-react/index.js'; import lucideReactVerification from './lucide-react/verification.json' with { type: 'json' }; import { nextPack } from './next/index.js'; @@ -27,6 +29,7 @@ import zodVerification from './zod/verification.json' with { type: 'json' }; export const REGISTRY: readonly RegisteredPack[] = [ { dir: 'ai', pack: aiPack, verification: aiVerification as PackVerification }, + { dir: 'ioredis', pack: ioredisPack, verification: ioredisVerification as PackVerification }, { dir: 'lucide-react', pack: lucideReactPack, From 7559f8c29e07d1b0251e5a57b28d86d48fa19a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:33:33 +0000 Subject: [PATCH 2/3] feat(packs): score the ioredis pack and record why it stays a candidate pack test against the five public upgrades: no breaking false positive and no breaking prediction, since none of them has a breaking site. Records the candidate status, the research ledger, the blocked queue entry and the evidence and decision tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017BYqQRCpXk6jz9vcCnx21H Signed-off-by: Claude --- .changeset/ioredis-candidate-pack.md | 5 + packages/core/src/packs/ioredis/RESEARCH.md | 223 ++++++++++++++++++ .../core/src/packs/ioredis/ioredis.test.ts | 132 ++++++++++- .../core/src/packs/ioredis/verification.json | 6 +- packs/queue.json | 3 +- 5 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 .changeset/ioredis-candidate-pack.md create mode 100644 packages/core/src/packs/ioredis/RESEARCH.md diff --git a/.changeset/ioredis-candidate-pack.md b/.changeset/ioredis-candidate-pack.md new file mode 100644 index 0000000..d83058a --- /dev/null +++ b/.changeset/ioredis-candidate-pack.md @@ -0,0 +1,5 @@ +--- +'uptide': patch +--- + +Add a candidate ioredis 5 to 6 migration pack: client constructions that inherit the RESP3, reconnect and keep-alive defaults ioredis 6 changed, compiler-blind sites (`mode === 'subscriber'`, commands in a `connect` listener with the offline queue off, AUTH recognized by command name), dependents that bundle ioredis 5, instrumentation that stops patching ioredis 6, and Node 20. It is scored against five public upgrades and stays a candidate: none of them has a breaking site to verify it with. diff --git a/packages/core/src/packs/ioredis/RESEARCH.md b/packages/core/src/packs/ioredis/RESEARCH.md new file mode 100644 index 0000000..29cedb1 --- /dev/null +++ b/packages/core/src/packs/ioredis/RESEARCH.md @@ -0,0 +1,223 @@ +# ioredis 5 → 6: evidence and verification limits + +Read date for every source below: **2026-10-14**. Scope: ioredis `>=5 <6` to `>=6 <7`; the only +stable 6.x release at the read date is **6.0.0** (2026-07-31; `6.1.0-beta.1` is a prerelease). +Status: **candidate**. The pack has no breaking false positive on five public upgrades, but none +of them has a breaking site to predict, so the gate (at least one predicted breaking finding) +cannot be met. The queue entry is `blocked` with the same reason. + +## Source ledger + +- [Upgrading from v5 to v6](https://github.com/redis/ioredis/wiki/Upgrading-from-v5-to-v6), + the official guide linked from the README, wiki commit `e859962747634a89e1792cfe5a246fc9fc4c7916` + (last edit 2026-07-10). +- [CHANGELOG at v6.0.0](https://github.com/redis/ioredis/blob/v6.0.0/CHANGELOG.md): one breaking + entry, "ioredis now requires Node.js 20 or newer and uses RESP3 by default. Set `protocol: 2` + to retain the v5 wire protocol." +- [README at v6.0.0](https://github.com/redis/ioredis/blob/v6.0.0/README.md), "RESP3 Protocol" + and "Auto-reconnect". +- Commits in `v5.11.1..v6.0.0` (tag `v6.0.0` = `8ed2946504a36ae9b1e186b9dccc56afcd046d78`), read + in full for the two that change behavior: [Add RESP3 #2127](https://github.com/redis/ioredis/commit/5d0862e078c1252470872fa3a96d7435bb34f078), + [default connection resilience #2160](https://github.com/redis/ioredis/commit/6d0716ef9b3c7c5676c8d866388075e927f6b8b7). +- Fixes after 6.0.0, only in `6.1.0-beta.1`: + [String.prototype fast mode #2206](https://github.com/redis/ioredis/commit/90e1261ce71f4e77ad00f54690eda54123c34bc6), + and #2187, #2194, #2196, #2204 (rejections on resubscribe, dropped commands on reconnect, + subscriber mode with mixed channel kinds, retries after `connect()`). +- Published declarations: `ioredis@5.11.1` and `ioredis@6.0.0` tarballs from the npm registry, + `built/**/*.d.ts` diffed file by file; `@ioredis/commands@1.10.0` and `@2.0.0` `commands.json`. +- Dependents' published manifests (registry, 2026-10-14): bullmq (every 5.x and 6.x), bull 4.16.5, + @hocuspocus/extension-redis 4.7.1, @chat-adapter/state-ioredis 4.41.1, cache-manager-ioredis-yet + 2.1.2, typeorm 1.1.2, ioredis-mock 8.13.1, @liaoliaots/nestjs-redis 10.0.0, + @nestjs-modules/ioredis 2.3.1, @sesamecare-oss/redlock 1.4.0, @types/ioredis 5.0.0, + @opentelemetry/instrumentation-ioredis 0.69.0–0.71.0, @opentelemetry/auto-instrumentations-node + 0.78.0–0.81.0, @sentry/node 10.76.2 and 11.x. +- [BullMQ changelog](https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/changelog.md), + 6.0.0 section (2026-07-30): "ioredis is no longer installed as a direct dependency and is now an + optional peer dependency." + +No codemod exists for this upgrade. Nothing was executed from any downloaded package. + +## Classified changes + +**M** mechanical rule · **A** assisted rule with a guide · **B** behavior note (finding or +decision) · **N** not compiler-visible and not detectable, documentation only. + +| Change | Class and treatment | Source | +| --- | --- | --- | +| Node.js 20 or newer (`engines.node >=20.0.0`, was `>=12.22.0`) | B `node-20`, decision with evidence from `engines.node`, `.nvmrc`/`.node-version` and CI `node-version` | changelog, guide, manifest | +| RESP3 by default: `HELLO 3` on connect, RESP2 retry on `NOPROTO`/unknown command, other errors fail the connection | B `client-defaults`, a finding at each client construction that leaves `protocol` unset, and a decision: keep RESP3 or pin `protocol: 2` per client | guide, #2127 | +| `retryStrategy` default: `min(2^(n-1)·50, 5000)` + 0–199 ms jitter (was `min(n·50, 2000)`) | B `client-defaults`, same site; never changed silently | #2160, README | +| `keepAlive` default 30000 ms (was 0, off) | B `client-defaults`, same site | #2160 | +| Credentials sent inside `HELLO 3 AUTH user pass`; a failed login's error carries `command { name: "hello", args: [...] }` | B `handshake-auth`, breaking finding where code recognizes AUTH by command name (`=== 'auth'`, `case 'auth'`, `['auth'].includes(...)`) | guide ("including AUTH") | +| Subscribed RESP3 connections accept regular commands; `redis.mode` stays `"normal"` | B `subscriber-mode`, breaking finding at `mode === 'subscriber'` comparisons and `case 'subscriber'` | guide | +| With `enableOfflineQueue: false`, commands sent from a `connect` listener (subscribe included) may reject | B `offline-queue-connect`, breaking finding at `on/once('connect', …)` listeners that send a command on their client, in files that build a client with the queue off | guide | +| `replyMapping: "resp3"` (opt-in): maps as objects, doubles as numbers, booleans; RESP3 return types selected by the literal; Buffer variants get string map keys | N; `also-review` decision and the instructions forbid opting in during the upgrade | guide, README | +| RESP options belong under `redisOptions` for Cluster, top level for Sentinel | N (only matters when opting in); `also-review` | guide | +| `Redis`/`Cluster` generic over the reply mapping, `Commander`, `ChainableCommander`, `Transaction`, `RespShape` return types | N for default clients: `ReplyMapping` defaults to `"legacy"` and every return type resolves to the v5 one. No diagnostic in any of the five repositories | d.ts diff | +| `duplicate(override?)` returns `Redis<…>`; `Cluster#duplicate(ClusterNode[], …)` (was `any[]`) | A `duplicate-signature`, claims TS2416/TS2430 that name `'duplicate'` (subclass overrides) | d.ts diff | +| A v6 client passed to a dependent that installs its own ioredis 5 is a second class to TypeScript (TS2322/TS2345 `Redis<"legacy">' is not assignable to …`) | A `bundled-ioredis`; guide with options (a) upgrade the dependent, (b) pass options instead of the client, (c) keep ioredis 5, (d) confirm a forced single copy; the agent must say which (`sdk-agent.ts` puts guide choices under "Decisions for you"); never a cast | d.ts, bullmq #4211/#3793, measured on inbox-zero | +| Dependents bundling ioredis 5: bullmq <6 (exact `5.x` pin since 5.66.1), bull 4, @hocuspocus/extension-redis (`~5.6.1`), @chat-adapter/state-ioredis (`^5.4.1`), cache-manager-ioredis-yet (`^5.4.1`) | B `bundled-copies`, decision with evidence from package.json; peer risk, not a companion: bullmq 6 is its own major (16 breaking entries) | registry manifests, BullMQ changelog | +| Explicit peers rejecting 6: typeorm `^5.0.4` (optional), ioredis-mock `^5`, @liaoliaots/nestjs-redis `^5.0.0` | Engine peer-conflict report (left in place, listed as possible impact); nothing pack-specific | registry manifests | +| `@opentelemetry/instrumentation-ioredis` <0.70.0 patches `ioredis >=2 <6` only; `auto-instrumentations-node` <0.80.0 bundles it | B `instrumentation`, decision with evidence: spans stop silently | registry tarballs | +| `@types/ioredis` 5.0.0 is a deprecated stub; 4.x declares the v4 API | B `stale-types`, decision; `check` itself moves `@types/ioredis` 4 → 5 as a types companion (see limits) | registry | +| 6.0.0 puts `String.prototype` in V8 slow mode (VerbatimString); fixed by #2206, not in a stable release | B `string-prototype`, decision when the target is exactly 6.0.0 | #2206 | +| Removed or renamed commands | **None.** 373 → 382 command methods in `RedisCommander`, zero removed; `@ioredis/commands` 1.10.0 → 2.0.0 adds 14 commands and removes none. `vsim` became generic (`VsimReply`). No syntactic detect is needed for `redis[cmd]()` or `any`-typed clients: no name stops working | d.ts and commands.json diff | +| Removed public exports | **None** that consumers import. `utils.getPackageMeta` (internal, `@internal`) is gone; `RedisStatus`, `ClusterStatus`, `ScanStreamOptions`, `HimportFieldset` are newly exported | d.ts diff | +| Event names, `exec()` result type, `Callback` | Unchanged (same emitted event-name set in `built/**/*.js`; identical `exec` signature) | d.ts and built JS | +| New: `HIMPORT` managed fieldsets (experimental), LMOVEM/BLMOVEM, SDIFFCARD/SUNIONCARD, stream MAXCOUNT/MAXSIZE | N, additive | changelog | +| Bug fixes: stale socket timeout, circular MOVED, MOVED slot validation, decimal big integers, reconnect during setup, sentinel priority 0, tracing redaction | N, no consumer change; noted for review only | changelog | + +**Release group:** none. ioredis is published alone; `@ioredis/commands` is an exact dependency +(2.0.0), never declared by consumers, and the queue entry lists ioredis as its only member. +**requiresBuild:** not set. No source and no measured repository shows a build failing where +`tsc` passes. inbox-zero added `ioredis` to Next's `serverExternalPackages` in its upgrade, but its +PR says why: bullmq 6 made ioredis a peer. That is bullmq's change, not ioredis 6's. + +## Ground truth + +Anchor rule, fixed before the first `pack test` run (commit `0f767f4` holds the truth before +any score) and applied to every repository: one site per client construction the upgrade +changed, at the first changed line of code of the run that contains it. A second construction in +the same run starts its own site. Edits that only serve that construction (a moved import, a +widened constructor type) belong to it. Pure insertions, comments and other packages' changes are +not sites (docs/packs.md). Compiler evidence: `pnpm packs:truth` with each repository's own +TypeScript and ioredis 6.0.0 linked: **no new diagnostic in any of the five**. Installs ran with +scripts off; nothing from a repository was executed. + +| Repository | Parent | Upgrade | Versions | Expected | +| --- | --- | --- | --- | --- | +| [ferni-ai/voiceai](https://github.com/ferni-ai/voiceai/pull/703) | `c59ed0982f96e23d2b2f5728711da5b55e07483a` | `c6838d5ecdc0bd319c706621b2bfc9d058a2dda0` | 5.8.2 → 6.0.0 | 5 `client-defaults` (protocol: 2 pins) | +| [syzygyhack/open-foundry](https://github.com/syzygyhack/open-foundry/commit/a466e0d364c2e2dd0fae8bbcf30ef9de25978b18) | `73910018b5e8119cf4db99a81ff145726451aa3b` | `a466e0d364c2e2dd0fae8bbcf30ef9de25978b18` | 5.10.1 → 6.0.0 | 1 `client-defaults` | +| [simondietschi/dokunc](https://github.com/simondietschi/dokunc/pull/15) | `83681ed0ccc527fa1dd5c802f554f26a5eac575c` | `c47539089296efdb02a3093bb650185b4861f297` (in merge `03ae5da`) | 5.11.1 → 6.0.0 | 1 `client-defaults` (v5 retryStrategy), 2 `handshake-auth` (redaction test) | +| [safe-global/safe-client-gateway](https://github.com/safe-global/safe-client-gateway/pull/3409) | `14bd05b98bf41df509de0bfee8bc662cb9d95d1e` | `80135d22e8006fec71e7da270efdff65ec092c89` | 5.11.1 → 6.0.0 | none (manifest only; Yarn Berry) | +| [kazumadev619-dev/FishingConditionsApp](https://github.com/kazumadev619-dev/FishingConditionsApp/pull/235) | `8ba3f0d24bc709169b79df11daa87307df32e77c` | `cdecc03c3c04d0e4f08351ceef6b10df6f1bfd81` (in merge `ebf69b5`) | 5.11.1 → 6.0.0 | none (manifest only; npm; drops `@types/ioredis`) | + +Per repository: + +- **voiceai.** Its commit message: "Pin protocol: 2 at every client construction site so the + upgrade does not change what goes over the wire." Seven constructions were pinned. Two were + pure insertions (`redis-pubsub.ts:140`, `rate-limiter.ts:57`), so they are not sites. Two runs + rebuilt two constructions each: `redis-cache.ts` 98–118 and `redis-job-queue.ts` 89–106, at 98/108 + and 89/98. `embedding-cache.ts:134` builds its client through a module name hidden from bundlers; + `:126` widens its constructor type for the option. The same commit bumps @sentry/node 10 → 11, + which shares no dependency edge with ioredis, so it is not listed under `with` and its changed + lines are not scored. The commit message claims Redis < 6 fails the connection; the guide says + the client retries with RESP2, and the pack follows the guide. +- **open-foundry.** A rate limiter with `enableOfflineQueue: false` pins `protocol: 2` "for + Redis-compatible proxies". The changed run is the constructor acquisition (`:136–137`). The + pin itself is inserted into the unchanged call at `:138`. +- **dokunc.** The branch of merged PR #15 bumps ioredis (`bb72cd7`), dedupes the lockfile + (`b4d6782`) and restores the v5 `retryStrategy` (`c475390`) after measuring 10–15 s command + stalls during an outage with the new default. The range also carries vitest 5 and mermaid 12 + commits, whose lines are not scored. `apps/collab/src/server.ts:108` is the construction moved + to `createRedisClient` (the import at `:9` serves it). `apps/web/src/lib/redis.ts` gets + `retryStrategy` by pure insertion, so it is not a site. `log.test.ts:20` and `:28` extend the + redaction test to `hello [3, AUTH, user, password]`. Later commits (`c8491ea`, `2a75967`) fix + bugs their messages say already existed with ioredis 5, so they are not scored. + `@hocuspocus/extension-redis` keeps its own ioredis 5.6.1; the repository already casts the + client it passes in. +- **safe-client-gateway** and **FishingConditionsApp.** Clean upgrades: no source line, no + diagnostic. They are negative controls for every detector. + +### Screened out + +- [elie222/inbox-zero#3821](https://github.com/elie222/inbox-zero/pull/3821) (`5724468` → + `9bf2b39`, 5.10.1 → 6.0.0): a real upgrade, but bullmq 5.79.2 → 6.3.8 moved with it, and its + only ioredis-relevant diagnostic was an artifact. `packs:truth` linked ioredis 6 into `apps/web` + only and reported TS2322 at `apps/web/utils/queue/bullmq.ts:65` (`Redis<"legacy">` not + assignable to bullmq 5's `ConnectionOptions`). A real scripts-off install of the parent with + only ioredis and the workspace override bumped resolves bullmq 5.79.2 to the same ioredis 6.0.0, + and that line compiles. So the repository has no ioredis site. Honest `with: { bullmq }` would + fail `pack test` as "left behind": `check` does not move bullmq, and must not, since bullmq 6 is + its own major migration. Scoring it would only add a failure the pack cannot fix. The TS2322 + message is kept as the `bundled-ioredis` unit-test case. + `packs:truth --directory apps/web` also fails (`UNSUPPORTED_PACKAGE_MANAGER`): the installer + looks for the lockfile in the directory, not the workspace root. +- Merged, manifest-only bumps not needed as further controls: AHSAL-innovationz/hendrix + (#5, `70ba290`), ribato22/MultiWA (#169, `3202806`), saeedmoh4444/galaxy-of-beauty (#314), + joacominatel/lazyit (#1241). +- Not merged, or bundled with other majors: robbeverhelst/routess #408 (closed; folded into a + Nest 12 upgrade), tauinbox/client-server-starter-app (BullMQ 6, cache-manager 7 and others), + AaraikAI/ComplyEasyAI #564 (held back over the #2206 slowdown), and Dependabot PRs still open + (brahim1205/c2p-website #55, DealAppSeo/repid-engine #1294, others). +- From the queue's 52-repository sample, only inbox-zero has an ioredis 6 bump since 2026-07-25 + (checked by `git log -G'"ioredis": *"[~^]?6'` over each repository's package.json files; + midday-ai/midday could not be cloned). + +## Measured: `pnpm uptide pack test ioredis --write` + +| Rule / scoring group | Precision | Recall | TP | FP | FN | +| --- | ---: | ---: | ---: | ---: | ---: | +| client-defaults (deprecated: needs a look) | 30% | 43% | 3 | 7 | 4 | +| handshake-auth | n/a | 0% | 0 | 0 | 2 | +| Overall sites | 30% | 33% | 3 | 7 | 6 | +| Breaking sites | n/a (0 predicted) | 0% | 0 | 0 | 6 | + +`n/a` means no prediction; the CLI and README print 100% for that zero denominator. Status: +**candidate**, 5 public repositories, 0 breaking false positives, 0 breaking predictions. + +False positives, all non-breaking `client-defaults`: + +- voiceai `redis-cache.ts:101` and `redis-job-queue.ts:92`: the first construction of each + rebuilt run, which the anchor rule puts at the run's first line (98, 89). Those two lines are + the matching false negatives. +- voiceai `redis-pubsub.ts:140`, `rate-limiter.ts:57`; dokunc `apps/web/src/lib/redis.ts:54`: real + constructions the upgrade changed, but by pure insertion, which is not a site. +- open-foundry `server.ts:138`: the construction; the truth anchors the change at the rewritten + acquisition, `:136`. +- FishingConditionsApp `src/lib/cache/client.ts:13`: a client that sets its own `retryStrategy` + and was left on RESP3 by choice. That is the decision this finding asks for, and the + repository answered it without a code change. + +False negatives: + +- voiceai `embedding-cache.ts:134`: built through `new Function('m', 'return import(m)')`, which + no static scan can resolve, in a file `check` never lists as an ioredis user. +- voiceai `:98`, `:89`, open-foundry `:136`: anchor mismatches, above. +- dokunc `log.test.ts:20`, `:28`: test data and assertions modeling the v5 error shape + (`command: { name: "auth" }`, `toBe("auth")`). `handshake-auth` finds code that *recognizes* + AUTH by name, not fixtures that *construct* it. Widening it to every `"auth"` literal in tests + would trade these two for false positives elsewhere. + +All six expected sites belong to behavior notes, which are not breaking. The scorer still counts +their misses as breaking false negatives, because it reads severity from `rules` only. + +## What the engine cannot express (follow-up work, not part of this pack) + +1. **A runtime-only major cannot be verified.** `statusOf` requires a predicted breaking finding. + ioredis 6's breaking changes (RESP3, defaults, Node 20) are behavior. With perfect detection + the pack would still be a candidate, and so would any upgrade that breaks nothing the compiler + sees. +2. **Behavior-note severity is ignored when scoring misses.** `pack-test.ts` classifies a false + negative as breaking unless a *rule* with that id is deprecated. `BehaviorNote.severity` is not + read, so `client-defaults` (deprecated) misses count as breaking. +3. **Pure insertions are not sites.** The most common form of the v6 fix, adding `protocol` or + `retryStrategy` to an existing call, has no old line, so a correct finding at that call scores + as a false positive. An anchor at the enclosing call would make such changes scorable. +4. **Run anchoring vs. construction anchoring.** The truth convention anchors a rewritten run at + its first line. A finding anchors at the construction it is about, so rewrites that restructure + around a call always miss by a few lines. +5. **Detection sees only files with static usages.** `runtimeFindings` scans usage files and the + inventory, so a client built through a hidden module name is unreachable. `detect` has no type + information either: it cannot follow a client built in a factory and returned, or recognize a + `Redis` parameter. +6. **No "remove a package" outcome.** `check` moves `@types/ioredis` 4.28.10 → 5.0.0 as a types + companion, while the real upgrade (FishingConditionsApp) removed the deprecated stub. The pack + can only say so in a decision. +7. **`with` cannot tell an unrelated co-bump from a required one, nor model "this dependent must + move a major".** inbox-zero's bullmq 5 → 6 is required by bundling but must not become an + automatic companion. voiceai's @sentry/node and dokunc's vitest/mermaid are unrelated. +8. **`packs:truth` links per workspace.** With a pnpm override that forces one ioredis copy, it + fabricates a two-copies TS2322 a real install does not produce (inbox-zero), the same class of + artifact as next RESEARCH item 4. `--directory` with a root lockfile fails to install. +9. **Fixture markers cannot carry `"` in `message:`.** v6 diagnostics name `Redis<"legacy">`, so + the two compiler-claim rules are covered by unit tests instead of fixtures. + +## Reproduce + +```sh +pnpm install --frozen-lockfile --ignore-scripts +pnpm uptide pack test ioredis --fixtures-only +pnpm uptide pack test ioredis # fetches the five repositories once, scripts off +pnpm packs:truth ioredis ferni-ai/voiceai c59ed0982f96e23d2b2f5728711da5b55e07483a c6838d5ecdc0bd319c706621b2bfc9d058a2dda0 --to 6.0.0 +``` diff --git a/packages/core/src/packs/ioredis/ioredis.test.ts b/packages/core/src/packs/ioredis/ioredis.test.ts index 9391d25..94931aa 100644 --- a/packages/core/src/packs/ioredis/ioredis.test.ts +++ b/packages/core/src/packs/ioredis/ioredis.test.ts @@ -1,6 +1,9 @@ import { fileURLToPath } from 'node:url'; -import { expect, it } from 'vitest'; -import { runFixtures } from '../tooling/fixtures.js'; +import { describe, expect, it } from 'vitest'; +import { packDecisions } from '../decisions.js'; +import { fixtureFinding, runFixtures } from '../tooling/fixtures.js'; +import type { PackContext } from '../types.js'; +import { bundledCopies, instrumentationGaps, nodeEvidence, staleTypes } from './behavior.js'; import { ioredisPack } from './index.js'; it('ioredis: every fixture rewrites and detects exactly its marked sites', () => { @@ -11,3 +14,128 @@ it('ioredis: every fixture rewrites and detects exactly its marked sites', () => expect(result.falsePositives).toEqual([]); expect(result.falseNegatives).toEqual([]); }); + +/** The diagnostic inbox-zero's own TypeScript gave with bullmq 5's private ioredis 5 in place. */ +const twoCopies = + "Type 'Redis<\"legacy\">' is not assignable to type 'ConnectionOptions'. Type 'Redis<\"legacy\">' is not assignable to type 'ClusterOptions | Redis | IRedisClient | Cluster'."; + +it.each([ + ['bundled-ioredis', 'TS2322', twoCopies], + [ + 'bundled-ioredis', + 'TS2345', + "Argument of type 'Cluster<\"legacy\">' is not assignable to parameter of type 'Cluster'.", + ], + [ + 'duplicate-signature', + 'TS2416', + "Property 'duplicate' in type 'TracedRedis' is not assignable to the same property in base type 'Redis<\"legacy\">'.", + ], +])('claims %s from compiler evidence only, and never rewrites it', (rule, path, message) => { + const text = 'reportedSite;'; + const finding = fixtureFinding( + ioredisPack, + { case: 'compiler', file: 'queue.ts', line: 1, rule, keep: false, kind: 'type', path, message }, + text, + ); + delete finding.rule; + expect(ioredisPack.ruleOf?.(finding)).toBe(rule); + expect( + ioredisPack.transform(text, finding, { from: '5.11.1', to: '6.0.0', includeDeprecated: true }), + ).toMatchObject({ text, applied: false }); + finding.usage.compileError = "Type 'string' is not assignable to type 'number'."; + expect(ioredisPack.ruleOf?.(finding)).toBeUndefined(); +}); + +it('offers the bundled-copy options in its guide, so the agent reports which one it chose', () => { + const rule = ioredisPack.rules.find((r) => r.id === 'bundled-ioredis'); + expect(rule?.guide).toMatch(/Options: \(a\).*\(b\).*\(c\).*\(d\).*Say which you chose/); + expect(rule?.guide).toMatch(/Never cast/); +}); + +const context = (inventory: { file: string; text: string }[], to = '6.0.0'): PackContext => ({ + inventory, + from: '5.11.1', + to, + includeDeprecated: false, +}); +const manifest = (pkg: object, file = 'package.json') => ({ file, text: JSON.stringify(pkg) }); + +describe('repository evidence for the decisions', () => { + it('names Node declarations that permit a runtime below 20, and none when they do not', () => { + expect( + nodeEvidence( + context([manifest({ engines: { node: '>=18.0.0' } }), { file: '.nvmrc', text: '22\n' }]), + ), + ).toBe('`package.json` engines.node ">=18.0.0" permits Node below 20'); + expect( + nodeEvidence( + context([ + manifest({ engines: { node: '>=20' } }), + { + file: '.github/workflows/ci.yml', + text: `jobs:\n test:\n strategy:\n matrix:\n node: [18, 22]\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version: \${{ matrix.node }}\n`, + }, + ]), + ), + ).toBe('`.github/workflows/ci.yml` node-version "18" permits Node below 20'); + expect(nodeEvidence(context([manifest({ engines: { node: '^20.9 || >=22' } })]))).toBe(false); + expect(nodeEvidence(context([manifest({ name: 'app' })]))).toMatch(/No Node version/); + }); + + it('names dependents that install their own ioredis 5, and not those that peer on it', () => { + const hit = bundledCopies( + context([ + manifest({ dependencies: { bullmq: '^5.79.2', '@hocuspocus/extension-redis': '4.7.0' } }), + manifest({ dependencies: { bullmq: '^6.3.8' } }, 'apps/worker/package.json'), + ]), + ); + expect(hit).toContain('`package.json` declares bullmq "^5.79.2"'); + expect(hit).toContain('@hocuspocus/extension-redis'); + expect(hit).not.toContain('apps/worker'); + expect( + bundledCopies(context([manifest({ dependencies: { '@nestjs-modules/ioredis': '2.3.1' } })])), + ).toBe(false); + }); + + it('names instrumentation that only patches ioredis below 6, and stale @types', () => { + expect( + instrumentationGaps( + context([ + manifest({ dependencies: { '@opentelemetry/auto-instrumentations-node': '^0.79.0' } }), + ]), + ), + ).toContain('auto-instrumentations-node "^0.79.0"'); + expect( + instrumentationGaps( + context([ + manifest({ dependencies: { '@opentelemetry/instrumentation-ioredis': '^0.71.0' } }), + ]), + ), + ).toBe(false); + expect( + staleTypes(context([manifest({ devDependencies: { '@types/ioredis': '^4.28.10' } })])), + ).toBe('`package.json` declares @types/ioredis'); + }); + + it('renders only the decisions this repository has evidence for', () => { + const lines = packDecisions( + ioredisPack, + context( + [manifest({ engines: { node: '>=20' }, dependencies: { bullmq: '5.79.2' } })], + '6.1.0', + ), + [], + ); + expect(lines.some((l) => l.includes('clients built without explicit protocol'))).toBe(true); + expect(lines.some((l) => l.includes('declares bullmq "5.79.2"'))).toBe(true); + expect(lines.some((l) => l.includes('permits Node'))).toBe(false); + expect(lines.some((l) => l.includes('String.prototype'))).toBe(false); + expect(lines.some((l) => l.includes('@types/ioredis'))).toBe(false); + expect( + packDecisions(ioredisPack, context([manifest({ engines: { node: '>=20' } })]), []).some((l) => + l.includes('String.prototype'), + ), + ).toBe(true); + }); +}); diff --git a/packages/core/src/packs/ioredis/verification.json b/packages/core/src/packs/ioredis/verification.json index 250fb89..c2eabae 100644 --- a/packages/core/src/packs/ioredis/verification.json +++ b/packages/core/src/packs/ioredis/verification.json @@ -1,10 +1,10 @@ { "status": "candidate", - "truth": "sha256-6b9013a4677b5c9f41d07866bc89268fef500a257f33466efc36f363b8fd5de8", - "repos": 0, + "truth": "sha256-0c94b67872bf6ff6739f83eb55d22edc93847acf25ad0d5ff8da4cd761cef3d0", + "repos": 5, "breaking": { "precision": 1, - "recall": 1, + "recall": 0, "falsePositives": 0 } } diff --git a/packs/queue.json b/packs/queue.json index aa278bc..e6f2745 100644 --- a/packs/queue.json +++ b/packs/queue.json @@ -97,7 +97,8 @@ "from": ">=5 <6", "to": ">=6 <7", "why": "declared directly in 24 of 52 sample repositories, 22 of them on an older major; 976 breaking API changes, 191 distinct (1 removed or renamed, 190 change shapes) from 5.11.1 to 6.0.0", - "status": "todo", + "status": "blocked", + "blockedReason": "A candidate pack is in the tree (packages/core/src/packs/ioredis, RESEARCH.md), scored against five public merged upgrades: ferni-ai/voiceai (c6838d5), syzygyhack/open-foundry (a466e0d), simondietschi/dokunc (#15, c475390), safe-global/safe-client-gateway (#3409, 80135d2), kazumadev619-dev/FishingConditionsApp (#235, cdecc03). It has no breaking false positive, but cannot reach verified: ioredis 6 keeps v5 reply shapes and types for default clients and removes no command, so none of the five upgrades has a compiler diagnostic or another breaking site to predict; their source changes are connection-default decisions (protocol: 2, the v5 retryStrategy, the HELLO 3 AUTH redaction test), which are behavior notes. The one breaking compile error found (bullmq 5 bundling its own ioredis 5, elie222/inbox-zero) disappears in a real install because that workspace forces one ioredis copy, and its upgrade also moved bullmq 5 to 6, a separate major. Checked 2026-10-14. Retry when a public upgrade has a breaking ioredis 6 site (a bundled-copy type error fixed in the commit, a subscriber mode check, a connect listener with enableOfflineQueue: false), or when the gate can count behavior-note findings.", "repos": 24, "reposBehind": 22, "weeklyDownloads": 0, From 30579da013579ae93d3e061034f7e9c62d6abeb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:09:43 +0000 Subject: [PATCH 3/3] fix(packs): rescore ioredis on main with insertion anchors and note severities Rebased on main (#143, #144). The ground truth gains the four ioredis insertion anchors packs:truth now drafts (protocol: 2 and retryStrategy added inside unchanged constructor calls); no earlier site moved. Behavior notes 70% precision, 54% recall; still a candidate with no breaking false positive and no breaking prediction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017BYqQRCpXk6jz9vcCnx21H Signed-off-by: Claude --- README.md | 1 + packages/core/src/packs/ioredis/RESEARCH.md | 77 +++++++++++-------- .../core/src/packs/ioredis/ground-truth.json | 38 +++++++-- .../core/src/packs/ioredis/verification.json | 13 +++- 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0764281..cf9b4b8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,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% | 82% | vercel/chatbot, miurla/morphic | verified | +| `ioredis` | 5.x → 6.x | n/a | 0% | ferni-ai/voiceai, syzygyhack/open-foundry, simondietschi/dokunc, safe-global/safe-client-gateway, kazumadev619-dev/FishingConditionsApp | candidate | | `lucide-react` | 0.x → 1.x | 100% | 100% | tradeblocks-org/tradeblocks, spizeck/SFPCA, cubicsteak/keep | verified | | `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 | diff --git a/packages/core/src/packs/ioredis/RESEARCH.md b/packages/core/src/packs/ioredis/RESEARCH.md index 29cedb1..8074c66 100644 --- a/packages/core/src/packs/ioredis/RESEARCH.md +++ b/packages/core/src/packs/ioredis/RESEARCH.md @@ -3,8 +3,9 @@ Read date for every source below: **2026-10-14**. Scope: ioredis `>=5 <6` to `>=6 <7`; the only stable 6.x release at the read date is **6.0.0** (2026-07-31; `6.1.0-beta.1` is a prerelease). Status: **candidate**. The pack has no breaking false positive on five public upgrades, but none -of them has a breaking site to predict, so the gate (at least one predicted breaking finding) -cannot be met. The queue entry is `blocked` with the same reason. +of them has a compiler diagnostic or a breaking site in application code. The only breaking +expected sites are two lines of test data in dokunc, so the gate (at least one predicted breaking +finding) cannot be met. The queue entry is `blocked` with the same reason. ## Source ledger @@ -79,16 +80,23 @@ Anchor rule, fixed before the first `pack test` run (commit `0f767f4` holds the any score) and applied to every repository: one site per client construction the upgrade changed, at the first changed line of code of the run that contains it. A second construction in the same run starts its own site. Edits that only serve that construction (a moved import, a -widened constructor type) belong to it. Pure insertions, comments and other packages' changes are -not sites (docs/packs.md). Compiler evidence: `pnpm packs:truth` with each repository's own +widened constructor type) belong to it. Comments and other packages' changes are not sites. + +**Revision 2026-10-15, after rebasing on main.** #144 changed docs/packs.md: a pure insertion +into a call the upgrade otherwise left unchanged is now a site, at the call's start line, and +`pnpm packs:truth` drafts those `anchors`. Re-running it on all five repositories (still no +compiler diagnostic) drafted five anchors. The four for ioredis were added unchanged: voiceai +`redis-pubsub.ts:140` and `rate-limiter.ts:57`, open-foundry `server.ts:138`, dokunc +`apps/web/src/lib/redis.ts:54`. The fifth, voiceai `error-scrub.test.ts:44`, belongs to the +@sentry/node bump and was dropped. No earlier site was moved or removed. Compiler evidence: `pnpm packs:truth` with each repository's own TypeScript and ioredis 6.0.0 linked: **no new diagnostic in any of the five**. Installs ran with scripts off; nothing from a repository was executed. | Repository | Parent | Upgrade | Versions | Expected | | --- | --- | --- | --- | --- | -| [ferni-ai/voiceai](https://github.com/ferni-ai/voiceai/pull/703) | `c59ed0982f96e23d2b2f5728711da5b55e07483a` | `c6838d5ecdc0bd319c706621b2bfc9d058a2dda0` | 5.8.2 → 6.0.0 | 5 `client-defaults` (protocol: 2 pins) | -| [syzygyhack/open-foundry](https://github.com/syzygyhack/open-foundry/commit/a466e0d364c2e2dd0fae8bbcf30ef9de25978b18) | `73910018b5e8119cf4db99a81ff145726451aa3b` | `a466e0d364c2e2dd0fae8bbcf30ef9de25978b18` | 5.10.1 → 6.0.0 | 1 `client-defaults` | -| [simondietschi/dokunc](https://github.com/simondietschi/dokunc/pull/15) | `83681ed0ccc527fa1dd5c802f554f26a5eac575c` | `c47539089296efdb02a3093bb650185b4861f297` (in merge `03ae5da`) | 5.11.1 → 6.0.0 | 1 `client-defaults` (v5 retryStrategy), 2 `handshake-auth` (redaction test) | +| [ferni-ai/voiceai](https://github.com/ferni-ai/voiceai/pull/703) | `c59ed0982f96e23d2b2f5728711da5b55e07483a` | `c6838d5ecdc0bd319c706621b2bfc9d058a2dda0` | 5.8.2 → 6.0.0 | 7 `client-defaults` (protocol: 2 pins) | +| [syzygyhack/open-foundry](https://github.com/syzygyhack/open-foundry/commit/a466e0d364c2e2dd0fae8bbcf30ef9de25978b18) | `73910018b5e8119cf4db99a81ff145726451aa3b` | `a466e0d364c2e2dd0fae8bbcf30ef9de25978b18` | 5.10.1 → 6.0.0 | 2 `client-defaults` | +| [simondietschi/dokunc](https://github.com/simondietschi/dokunc/pull/15) | `83681ed0ccc527fa1dd5c802f554f26a5eac575c` | `c47539089296efdb02a3093bb650185b4861f297` (in merge `03ae5da`) | 5.11.1 → 6.0.0 | 2 `client-defaults` (v5 retryStrategy), 2 `handshake-auth` (redaction test) | | [safe-global/safe-client-gateway](https://github.com/safe-global/safe-client-gateway/pull/3409) | `14bd05b98bf41df509de0bfee8bc662cb9d95d1e` | `80135d22e8006fec71e7da270efdff65ec092c89` | 5.11.1 → 6.0.0 | none (manifest only; Yarn Berry) | | [kazumadev619-dev/FishingConditionsApp](https://github.com/kazumadev619-dev/FishingConditionsApp/pull/235) | `8ba3f0d24bc709169b79df11daa87307df32e77c` | `cdecc03c3c04d0e4f08351ceef6b10df6f1bfd81` (in merge `ebf69b5`) | 5.11.1 → 6.0.0 | none (manifest only; npm; drops `@types/ioredis`) | @@ -96,7 +104,7 @@ Per repository: - **voiceai.** Its commit message: "Pin protocol: 2 at every client construction site so the upgrade does not change what goes over the wire." Seven constructions were pinned. Two were - pure insertions (`redis-pubsub.ts:140`, `rate-limiter.ts:57`), so they are not sites. Two runs + pure insertions, anchored at their calls (`redis-pubsub.ts:140`, `rate-limiter.ts:57`). Two runs rebuilt two constructions each: `redis-cache.ts` 98–118 and `redis-job-queue.ts` 89–106, at 98/108 and 89/98. `embedding-cache.ts:134` builds its client through a module name hidden from bundlers; `:126` widens its constructor type for the option. The same commit bumps @sentry/node 10 → 11, @@ -104,14 +112,14 @@ Per repository: lines are not scored. The commit message claims Redis < 6 fails the connection; the guide says the client retries with RESP2, and the pack follows the guide. - **open-foundry.** A rate limiter with `enableOfflineQueue: false` pins `protocol: 2` "for - Redis-compatible proxies". The changed run is the constructor acquisition (`:136–137`). The - pin itself is inserted into the unchanged call at `:138`. + Redis-compatible proxies". The changed run is the constructor acquisition (`:136–137`), and the + pin itself is inserted into the unchanged call, anchored at `:138`. - **dokunc.** The branch of merged PR #15 bumps ioredis (`bb72cd7`), dedupes the lockfile (`b4d6782`) and restores the v5 `retryStrategy` (`c475390`) after measuring 10–15 s command stalls during an outage with the new default. The range also carries vitest 5 and mermaid 12 commits, whose lines are not scored. `apps/collab/src/server.ts:108` is the construction moved - to `createRedisClient` (the import at `:9` serves it). `apps/web/src/lib/redis.ts` gets - `retryStrategy` by pure insertion, so it is not a site. `log.test.ts:20` and `:28` extend the + to `createRedisClient` (the import at `:9` serves it). `apps/web/src/lib/redis.ts:54` gets + `retryStrategy` by pure insertion, anchored at the call. `log.test.ts:20` and `:28` extend the redaction test to `hello [3, AUTH, user, password]`. Later commits (`c8491ea`, `2a75967`) fix bugs their messages say already existed with ioredis 5, so they are not scored. `@hocuspocus/extension-redis` keeps its own ioredis 5.6.1; the repository already casts the @@ -146,25 +154,30 @@ Per repository: ## Measured: `pnpm uptide pack test ioredis --write` +On main `0881185` (with #143 and #144), from the revised truth above: + | Rule / scoring group | Precision | Recall | TP | FP | FN | | --- | ---: | ---: | ---: | ---: | ---: | -| client-defaults (deprecated: needs a look) | 30% | 43% | 3 | 7 | 4 | -| handshake-auth | n/a | 0% | 0 | 0 | 2 | -| Overall sites | 30% | 33% | 3 | 7 | 6 | -| Breaking sites | n/a (0 predicted) | 0% | 0 | 0 | 6 | +| client-defaults (deprecated: needs a look) | 70% | 64% | 7 | 3 | 4 | +| handshake-auth (breaking) | n/a | 0% | 0 | 0 | 2 | +| Overall sites | 70% | 54% | 7 | 3 | 6 | +| Behavior-note sites | 70% | 54% | 7 | 3 | 6 | +| Breaking sites | n/a (0 predicted) | 0% | 0 | 0 | 2 | + +Status: **candidate**, 5 public repositories, 0 breaking false positives, 0 breaking +predictions. Before the rebase (old scorer, truth without anchors) the same pack measured +client-defaults 30% / 43% (3/7/4) and breaking 0/0/6. -`n/a` means no prediction; the CLI and README print 100% for that zero denominator. Status: -**candidate**, 5 public repositories, 0 breaking false positives, 0 breaking predictions. +`check` on dokunc now also reports `@hocuspocus/extension-redis 4.7.0 depends on ioredis +~5.6.1; no release accepts ioredis 6.0.0` as a blocker (#142). That is the bundled copy the +`bundled-copies` decision names. The repository upgraded anyway, keeping both copies and casting +the client it hands to the extension. False positives, all non-breaking `client-defaults`: - voiceai `redis-cache.ts:101` and `redis-job-queue.ts:92`: the first construction of each rebuilt run, which the anchor rule puts at the run's first line (98, 89). Those two lines are the matching false negatives. -- voiceai `redis-pubsub.ts:140`, `rate-limiter.ts:57`; dokunc `apps/web/src/lib/redis.ts:54`: real - constructions the upgrade changed, but by pure insertion, which is not a site. -- open-foundry `server.ts:138`: the construction; the truth anchors the change at the rewritten - acquisition, `:136`. - FishingConditionsApp `src/lib/cache/client.ts:13`: a client that sets its own `retryStrategy` and was left on RESP3 by choice. That is the decision this finding asks for, and the repository answered it without a code change. @@ -173,14 +186,14 @@ False negatives: - voiceai `embedding-cache.ts:134`: built through `new Function('m', 'return import(m)')`, which no static scan can resolve, in a file `check` never lists as an ioredis user. -- voiceai `:98`, `:89`, open-foundry `:136`: anchor mismatches, above. +- voiceai `:98`, `:89`: anchor mismatches, above. open-foundry `:136`: the rewritten + constructor acquisition, two lines above the construction the pack reports (`:138`, now a true + positive). - dokunc `log.test.ts:20`, `:28`: test data and assertions modeling the v5 error shape (`command: { name: "auth" }`, `toBe("auth")`). `handshake-auth` finds code that *recognizes* AUTH by name, not fixtures that *construct* it. Widening it to every `"auth"` literal in tests - would trade these two for false positives elsewhere. - -All six expected sites belong to behavior notes, which are not breaking. The scorer still counts -their misses as breaking false negatives, because it reads severity from `rules` only. + would trade these two for false positives elsewhere. They are the two breaking misses: the + note is declared breaking because a redaction that stops redacting breaks its contract. ## What the engine cannot express (follow-up work, not part of this pack) @@ -188,12 +201,10 @@ their misses as breaking false negatives, because it reads severity from `rules` ioredis 6's breaking changes (RESP3, defaults, Node 20) are behavior. With perfect detection the pack would still be a candidate, and so would any upgrade that breaks nothing the compiler sees. -2. **Behavior-note severity is ignored when scoring misses.** `pack-test.ts` classifies a false - negative as breaking unless a *rule* with that id is deprecated. `BehaviorNote.severity` is not - read, so `client-defaults` (deprecated) misses count as breaking. -3. **Pure insertions are not sites.** The most common form of the v6 fix, adding `protocol` or - `retryStrategy` to an existing call, has no old line, so a correct finding at that call scores - as a false positive. An anchor at the enclosing call would make such changes scorable. +2. ~~Behavior-note severity is ignored when scoring misses.~~ Resolved by #143: expected sites + are scored at their note's severity, and behavior-note sites have their own row. +3. ~~Pure insertions are not sites.~~ Resolved by #144: an insertion into an unchanged call is + a site at the call, and the ground truth above now includes those anchors. 4. **Run anchoring vs. construction anchoring.** The truth convention anchors a rewritten run at its first line. A finding anchors at the construction it is about, so rewrites that restructure around a call always miss by a few lines. diff --git a/packages/core/src/packs/ioredis/ground-truth.json b/packages/core/src/packs/ioredis/ground-truth.json index c80dec6..353d7ca 100644 --- a/packages/core/src/packs/ioredis/ground-truth.json +++ b/packages/core/src/packs/ioredis/ground-truth.json @@ -1,5 +1,5 @@ { - "$comment": "Ground truth for the ioredis pack (docs/packs.md, \"Ground truth\"). Each public repository is pinned at the commit before its own merged ioredis 5 to 6 upgrade. Expected findings come from two sources that are not Uptide, drafted by pnpm packs:truth on 2026-10-14 and read by hand before any pack test run: the repository's own TypeScript with ioredis 6.0.0 linked (no new diagnostic in any of the five), and the lines the upgrade changed for ioredis. Anchor rule, fixed before scoring and applied to every repository: one site per client construction the upgrade changed, at the first changed line of code of the run that contains it; a second construction in the same run starts its own site; edits that only serve that construction (a moved import, a widened constructor type) belong to it; pure insertions (an option added to an unchanged call), comments, and other packages' changes (@sentry/node, mermaid, vitest) are not sites. Unrelated packages bumped in the same commit or branch are not listed under `with`: they share no dependency edge with ioredis. RESEARCH.md has the evidence for each entry and the repositories screened out.", + "$comment": "Ground truth for the ioredis pack (docs/packs.md, \"Ground truth\"). Each public repository is pinned at the commit before its own merged ioredis 5 to 6 upgrade. Expected findings come from two sources that are not Uptide, drafted by pnpm packs:truth on 2026-10-14 and read by hand before any pack test run: the repository's own TypeScript with ioredis 6.0.0 linked (no new diagnostic in any of the five), and the lines the upgrade changed for ioredis. Anchor rule, fixed before scoring and applied to every repository: one site per client construction the upgrade changed, at the first changed line of code of the run that contains it; a second construction in the same run starts its own site; edits that only serve that construction (a moved import, a widened constructor type) belong to it; comments and other packages' changes (@sentry/node, mermaid, vitest) are not sites. Revised 2026-10-15 for docs/packs.md as changed by #144 (a pure insertion into a call the upgrade otherwise left unchanged is a site, at the call's start line): the anchors `pnpm packs:truth` now drafts were added unchanged (voiceai redis-pubsub.ts:140 and rate-limiter.ts:57, open-foundry server.ts:138, dokunc apps/web/src/lib/redis.ts:54), the @sentry/node anchor (voiceai error-scrub.test.ts:44) was dropped, and no earlier site was moved or removed. Unrelated packages bumped in the same commit or branch are not listed under `with`: they share no dependency edge with ioredis. RESEARCH.md has the evidence for each entry and the repositories screened out.", "package": "ioredis", "repos": [ { @@ -22,6 +22,24 @@ "rule": "client-defaults", "note": "host/port construction, same run, its own client" }, + { + "file": "src/memory/vectors/embedding-cache.ts", + "line": 134, + "rule": "client-defaults", + "note": "construction through a module name hidden from bundlers; the constructor type widened at :126 serves it" + }, + { + "file": "src/services/data/redis-pubsub.ts", + "line": 140, + "rule": "client-defaults", + "note": "anchor (#144): protocol: 2 inserted into the unchanged publisher construction; the subscriber inherits it through duplicate()" + }, + { + "file": "src/services/platform/rate-limiter.ts", + "line": 57, + "rule": "client-defaults", + "note": "anchor (#144): protocol: 2 inserted into the unchanged construction" + }, { "file": "src/services/workflows/jobs/redis-job-queue.ts", "line": 89, @@ -33,12 +51,6 @@ "line": 98, "rule": "client-defaults", "note": "host/port construction, same run, its own client" - }, - { - "file": "src/memory/vectors/embedding-cache.ts", - "line": 134, - "rule": "client-defaults", - "note": "construction through a module name hidden from bundlers; the constructor type widened at :126 serves it" } ] }, @@ -55,6 +67,12 @@ "line": 136, "rule": "client-defaults", "note": "constructor acquisition rewritten; protocol: 2 inserted into the call at :138" + }, + { + "file": "packages/api/src/server.ts", + "line": 138, + "rule": "client-defaults", + "note": "anchor (#144): protocol: 2 inserted into the unchanged construction" } ] }, @@ -83,6 +101,12 @@ "line": 28, "rule": "handshake-auth", "note": "the redaction assertion runs for hello and auth" + }, + { + "file": "apps/web/src/lib/redis.ts", + "line": 54, + "rule": "client-defaults", + "note": "anchor (#144): the v5 retryStrategy inserted into the unchanged construction in createRedis" } ] }, diff --git a/packages/core/src/packs/ioredis/verification.json b/packages/core/src/packs/ioredis/verification.json index c2eabae..7e1a7bb 100644 --- a/packages/core/src/packs/ioredis/verification.json +++ b/packages/core/src/packs/ioredis/verification.json @@ -1,10 +1,19 @@ { "status": "candidate", - "truth": "sha256-0c94b67872bf6ff6739f83eb55d22edc93847acf25ad0d5ff8da4cd761cef3d0", + "truth": "sha256-ce9e34cf184316cce69972cf34de3a269d38fc37d077eb8936586e55bb7328de", "repos": 5, "breaking": { "precision": 1, "recall": 0, - "falsePositives": 0 + "falsePositives": 0, + "truePositives": 0, + "falseNegatives": 2 + }, + "behavior": { + "precision": 0.7, + "recall": 0.5385, + "falsePositives": 3, + "truePositives": 7, + "falseNegatives": 6 } }