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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions packages/client/lib/commands/COMMAND_DOCS.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { RESP_TYPES } from '../RESP/decoder';
import COMMAND_DOCS from './COMMAND_DOCS';
import { parseArgs } from './generic-transformers';

describe('COMMAND DOCS', () => {
testUtils.isVersionGreaterThanHook([7]);

describe('transformArguments', () => {
it('simple', () => {
assert.deepEqual(
parseArgs(COMMAND_DOCS),
['COMMAND', 'DOCS']
);
});

it('string', () => {
assert.deepEqual(
parseArgs(COMMAND_DOCS, 'GET'),
['COMMAND', 'DOCS', 'GET']
);
});

it('array', () => {
assert.deepEqual(
parseArgs(COMMAND_DOCS, ['GET', 'SET']),
['COMMAND', 'DOCS', 'GET', 'SET']
);
});
});

describe('transformReply', () => {
it('RESP2 map with nested arguments', () => {
assert.deepEqual(
COMMAND_DOCS.transformReply[2]([
'get',
[
'summary', 'Get the value of a key',
'since', '1.0.0',
'group', 'string',
'complexity', 'O(1)',
'history', [['2.2.3', 'Added MSET']],
'arguments', [
[
'name', 'key',
'type', 'key',
'display_text', 'key',
'key_spec_index', 0
]
]
],
'command',
[
'summary', 'Get or set information about commands',
'since', '2.8.13',
'group', 'server',
'complexity', 'O(N)',
'subcommands', [
'command|docs',
[
'summary', 'Returns documentary information about commands',
'since', '7.0.0',
'group', 'server',
'complexity', 'O(N)'
]
]
]
] as never),
{
get: {
summary: 'Get the value of a key',
since: '1.0.0',
group: 'string',
complexity: 'O(1)',
history: [['2.2.3', 'Added MSET']],
arguments: [{
name: 'key',
type: 'key',
display_text: 'key',
key_spec_index: 0
}]
},
command: {
summary: 'Get or set information about commands',
since: '2.8.13',
group: 'server',
complexity: 'O(N)',
subcommands: {
'command|docs': {
summary: 'Returns documentary information about commands',
since: '7.0.0',
group: 'server',
complexity: 'O(N)'
}
}
}
}
);
});
});

testUtils.testWithClient('client.commandDocs', async client => {
const docs = await client.commandDocs();
assert.equal(typeof docs, 'object');
assert.ok(docs !== null);
assert.ok(!Array.isArray(docs));

const get = docs['get'];
assert.ok(get);
assert.equal(typeof get.summary, 'string');
assert.equal(typeof get.since, 'string');
assert.equal(typeof get.group, 'string');
assert.equal(typeof get.complexity, 'string');
assert.ok(Array.isArray(get.arguments));
assert.ok(get.arguments!.length > 0);
assert.equal(typeof get.arguments![0].name, 'string');
assert.equal(typeof get.arguments![0].type, 'string');
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [7]
});

testUtils.testWithClient('client.commandDocs with RESP2 and Map type mapping', async client => {
const docs = await client
.withTypeMapping({ [RESP_TYPES.MAP]: Map })
.commandDocs('GET');

assert.ok(docs instanceof Map);
const get = docs.get('get');
assert.ok(get instanceof Map);

const argumentsReply = get.get('arguments');
assert.ok(Array.isArray(argumentsReply));
assert.ok(argumentsReply[0] instanceof Map);
assert.equal(argumentsReply[0].get('name'), 'key');
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: {
RESP: 2
},
minimumDockerVersion: [7]
});

testUtils.testWithClient('client.commandDocs with command filter', async client => {
const docs = await client.commandDocs(['GET', 'SET']);
assert.ok('get' in docs);
assert.ok('set' in docs);
assert.equal(Object.keys(docs).length, 2);
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [7]
});
});
116 changes: 116 additions & 0 deletions packages/client/lib/commands/COMMAND_DOCS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { CommandParser } from '../client/parser';
import { ArrayReply, BlobStringReply, Command, MapReply, NumberReply, ReplyUnion, Resp2Reply, TuplesReply, TypeMapping, UnwrapReply } from '../RESP/types';
import { RESP_TYPES } from '../RESP/decoder';
import { RedisVariadicArgument } from './generic-transformers';

export interface CommandDocsArgument {
name?: BlobStringReply;
type?: BlobStringReply;
display_text?: BlobStringReply;
key_spec_index?: NumberReply;
token?: BlobStringReply;
summary?: BlobStringReply;
since?: BlobStringReply;
deprecated_since?: BlobStringReply;
flags?: ArrayReply<BlobStringReply>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Declare set-valued docs fields as SetReply

When RESP3 callers opt into withTypeMapping({ [RESP_TYPES.SET]: Set }), argument metadata such as flags is decoded as a Set, but this declaration still advertises an array, so TypeScript consumers can write argument.flags?.includes('optional') and then fail at runtime because Set has no includes. Use SetReply<BlobStringReply> for this set-valued field and the analogous doc-level set fields so the public type follows the decoder output.

Useful? React with 👍 / 👎.

arguments?: ArrayReply<CommandDocsArgument>;
}

export interface CommandDoc {
summary?: BlobStringReply;
since?: BlobStringReply;
group?: BlobStringReply;
complexity?: BlobStringReply;
module?: BlobStringReply;
doc_flags?: ArrayReply<BlobStringReply>;
deprecated_since?: BlobStringReply;
replaced_by?: BlobStringReply;
history?: ArrayReply<TuplesReply<[BlobStringReply, BlobStringReply]>>;
arguments?: ArrayReply<CommandDocsArgument>;
subcommands?: CommandDocsReply;
reply_schema?: ReplyUnion;
}

export type CommandDocsReply = MapReply<BlobStringReply, CommandDoc>;

function transformMap(
reply: unknown,
typeMapping: TypeMapping | undefined,
transformValue: (key: string, value: unknown) => unknown
): unknown {
if (!Array.isArray(reply)) {
return reply;
}

const entries: Array<[string, unknown]> = [];
for (let i = 0; i < reply.length; i += 2) {
const key = (reply[i] as { toString(): string }).toString();
entries.push([key, transformValue(key, reply[i + 1])]);
}

switch (typeMapping?.[RESP_TYPES.MAP]) {
case Array:
return entries.flat();
case Map:
return new Map(entries);
default: {
const object: Record<string, unknown> = {};
for (const [key, value] of entries) {
object[key] = value;
}
return object;
}
}
}

function transformArgument(reply: unknown, typeMapping?: TypeMapping): CommandDocsArgument {
return transformMap(reply, typeMapping, (key, value) => {
if (key === 'arguments' && Array.isArray(value)) {
return value.map(argument => transformArgument(argument, typeMapping));
}
return value;
}) as CommandDocsArgument;
}

function transformDoc(reply: unknown, typeMapping?: TypeMapping): CommandDoc {
return transformMap(reply, typeMapping, (key, value) => {
switch (key) {
case 'arguments':
return Array.isArray(value)
? value.map(argument => transformArgument(argument, typeMapping))
: value;
case 'subcommands':
return transformDocsMap(value, typeMapping);
case 'reply_schema':
return value;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize reply_schema maps in RESP2

When a Redis build includes reply_schema in COMMAND DOCS (for example, schema metadata for commands such as GET), RESP2 encodes that schema as nested flattened maps. This branch returns the raw array while the rest of the transformer normalizes maps, so client.commandDocs('GET') under RESP2 yields reply_schema: ['oneOf', ...] and reply_schema.oneOf is undefined even though the RESP3/default shape exposes an object. Recursively transform the schema maps here instead of returning value unchanged.

Useful? React with 👍 / 👎.

default:
return value;
}
}) as CommandDoc;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose map-mapped document values as maps

Fresh evidence after the runtime mapping fix is that this cast still reports each transformed command document as CommandDoc even when callers use withTypeMapping({ [RESP_TYPES.MAP]: Map }) (or Array), where transformMap now returns a Map for every document and the integration test expects get instanceof Map. TypeScript therefore sees docs.get('get') as a plain object and rejects get.get('arguments') even though that is the runtime shape; model command docs/arguments as map replies or provide mapped variants so nested MAP mappings are reflected.

Useful? React with 👍 / 👎.

}

function transformDocsMap(reply: unknown, typeMapping?: TypeMapping): CommandDocsReply {
return transformMap(
reply,
typeMapping,
(_key, value) => transformDoc(value, typeMapping)
) as CommandDocsReply;
}

export default {
IS_READ_ONLY: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing NOT_KEYED_COMMAND flag on COMMAND_DOCS

Medium Severity

COMMAND_DOCS is missing NOT_KEYED_COMMAND: true, which every other COMMAND_* sibling declares (COMMAND.ts, COMMAND_COUNT.ts, COMMAND_GETKEYS.ts, COMMAND_GETKEYSANDFLAGS.ts, COMMAND_INFO.ts, COMMAND_LIST.ts). The project's own skill checklist in .agents/skills/implement-command/SKILL.md explicitly requires this flag for commands that take no Redis keys. COMMAND DOCS takes only command names, not keys, so the flag is needed for consistency and correctness in cluster routing contexts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 12dbaa1. Configure here.

/**
* @see https://redis.io/commands/command-docs/
*/
parseCommand(parser: CommandParser, commands?: RedisVariadicArgument) {
parser.push('COMMAND', 'DOCS');
if (commands !== undefined) {
parser.pushVariadic(commands);
}
},
transformReply: {
2: (reply: UnwrapReply<Resp2Reply<CommandDocsReply>>, _preserve?: unknown, typeMapping?: TypeMapping) =>
transformDocsMap(reply, typeMapping),
3: undefined as unknown as () => CommandDocsReply
}
} as const satisfies Command;
13 changes: 13 additions & 0 deletions packages/client/lib/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import CLUSTER_SET_CONFIG_EPOCH from './CLUSTER_SET-CONFIG-EPOCH';
import CLUSTER_SETSLOT, { CLUSTER_SLOT_STATES } from './CLUSTER_SETSLOT';
import CLUSTER_SLOTS from './CLUSTER_SLOTS';
import COMMAND_COUNT from './COMMAND_COUNT';
import COMMAND_DOCS from './COMMAND_DOCS';
import COMMAND_GETKEYS from './COMMAND_GETKEYS';
import COMMAND_GETKEYSANDFLAGS from './COMMAND_GETKEYSANDFLAGS';
import COMMAND_INFO from './COMMAND_INFO';
Expand Down Expand Up @@ -1449,6 +1450,18 @@ export default {
* @param args - Command arguments to analyze
*/
commandGetKeysAndFlags: COMMAND_GETKEYSANDFLAGS,
/**
* Returns documentary information about one, multiple, or all Redis commands
* @param commands - Optional command name or names to get documentation for
* @since 7.0.0
*/
COMMAND_DOCS,
/**
* Returns documentary information about one, multiple, or all Redis commands
* @param commands - Optional command name or names to get documentation for
* @since 7.0.0
*/
commandDocs: COMMAND_DOCS,
/**
* Returns details about specific Redis commands
* @param commands - Array of command names to get information about
Expand Down