fix(telemetry): resolve enum values through nested schema wrappers (#2315)

## What

`generateToolMetrics` reads an enum parameter's values by unwrapping
**at most
one** `optional` wrapper:

```ts
if (schema._def.values?.length > 0) {
  values = schema._def.values;
} else {
  values = schema._def.innerType._def.values; // only one level
}
```

An enum parameter wrapped in `.default().optional()` (or any nested
`optional`/`default`/`nullable` combination) therefore falls through to
`schema._def.innerType._def.values === undefined`, and `npm run
update-metrics`
(run as part of `npm run gen`) crashes:

```
TypeError: Cannot read properties of undefined (reading '0')
    at validateEnumHomogeneity (build/src/telemetry/metricsRegistry.js:12)
    at generateToolMetrics (build/src/telemetry/metricsRegistry.js:59)
```

## Why it matters

`npm run gen` is required whenever a tool is added or changed (per
`CONTRIBUTING.md`). Declaring an enum parameter with a default — a
natural,
documented zod pattern — silently breaks docs/metrics generation.

## Fix

Add `getEnumValues()` in `transformation.ts` that recursively unwraps
`optional` / `default` / `nullable` / `effects` wrappers, mirroring the
existing
`getZodType()`, and use it in `generateToolMetrics`. Behavior is
unchanged for
the existing bare-enum and single-`optional` cases.

## Tests

- `getEnumValues` unit tests: bare enum, `optional`, `default`, both
orders of
  `default`+`optional`, and throws for a non-enum type.
- `generateToolMetrics` regression test with
`zod.enum(...).default(...).optional()`.
- `npm run typecheck`, the telemetry tests, and `npm run check-format`
pass.

---
First-time contributor here — happy to sign the CLA.
This commit is contained in:
Liohtml
2026-07-09 10:10:05 +02:00
committed by GitHub
parent c645eee875
commit c065fd90ce
4 changed files with 83 additions and 7 deletions
+2 -7
View File
@@ -10,6 +10,7 @@ import {
transformArgName,
transformArgType,
getZodType,
getEnumValues,
PARAM_BLOCKLIST,
stripUnderscoreBeforeNumber,
} from './transformation.js';
@@ -103,13 +104,7 @@ export function generateToolMetrics(tools: ToolDefinition[]): ToolMetric[] {
let argType = transformArgType(zodType);
if (argType === 'enum') {
let values;
if (schema._def.values?.length > 0) {
values = schema._def.values;
} else {
values = schema._def.innerType._def.values;
}
argType = validateEnumHomogeneity(values);
argType = validateEnumHomogeneity(getEnumValues(schema));
}
args.push({
+24
View File
@@ -53,6 +53,30 @@ export function getZodType(zodType: zod.ZodTypeAny): ZodType {
throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
}
/**
* Resolves the values of an enum parameter, unwrapping any optional/default/
* nullable/effects wrappers (in any order), mirroring {@link getZodType}.
*/
export function getEnumValues(zodType: zod.ZodTypeAny): unknown[] {
const def = zodType._def;
const typeName = def.typeName;
if (
typeName === 'ZodOptional' ||
typeName === 'ZodDefault' ||
typeName === 'ZodNullable'
) {
return getEnumValues(def.innerType);
}
if (typeName === 'ZodEffects') {
return getEnumValues(def.schema);
}
if (typeName === 'ZodEnum') {
return def.values;
}
throw new Error(`Cannot resolve enum values for zod type: ${typeName}`);
}
export function stripUnderscoreBeforeNumber(name: string): string {
return name.replace(/_([0-9])/g, '$1');
}
+24
View File
@@ -85,6 +85,30 @@ describe('metricsRegistry', () => {
assert.strictEqual(metrics[0].args[0].argType, 'string');
});
it('should handle enums wrapped in optional and default', () => {
const mockTool: ToolDefinition = {
name: 'wrapped_enum_tool',
description: 'test description',
annotations: {
category: ToolCategory.INPUT,
readOnlyHint: true,
},
schema: {
argEnum: zod.enum(['foo', 'bar']).default('foo').optional(),
},
blockedByDialog: false,
verifyFilesSchema: [],
handler: async () => {
// no-op
},
};
const metrics = generateToolMetrics([mockTool]);
assert.strictEqual(metrics.length, 1);
assert.strictEqual(metrics[0].args[0].name, 'arg_enum');
assert.strictEqual(metrics[0].args[0].argType, 'string');
});
it('should sanitize tool names containing underscores before numbers', () => {
const mockTool: ToolDefinition = {
name: 'list_3p_developer_tools',
+33
View File
@@ -9,12 +9,45 @@ import {describe, it} from 'node:test';
import {
bucketizeLatency,
getEnumValues,
sanitizeParams,
stripUnderscoreBeforeNumber,
transformArgName,
} from '../../src/telemetry/transformation.js';
import {zod} from '../../src/third_party/index.js';
describe('getEnumValues', () => {
it('resolves values for a bare enum', () => {
assert.deepStrictEqual(getEnumValues(zod.enum(['a', 'b'])), ['a', 'b']);
});
it('resolves values through optional/default wrappers in any order', () => {
assert.deepStrictEqual(getEnumValues(zod.enum(['a', 'b']).optional()), [
'a',
'b',
]);
assert.deepStrictEqual(getEnumValues(zod.enum(['a', 'b']).default('a')), [
'a',
'b',
]);
assert.deepStrictEqual(
getEnumValues(zod.enum(['a', 'b']).default('a').optional()),
['a', 'b'],
);
assert.deepStrictEqual(
getEnumValues(zod.enum(['a', 'b']).optional().default('a')),
['a', 'b'],
);
});
it('throws for a non-enum type', () => {
assert.throws(
() => getEnumValues(zod.string()),
/Cannot resolve enum values/,
);
});
});
describe('bucketizeLatency', () => {
it('should bucketize values correctly', () => {
assert.strictEqual(bucketizeLatency(0), 50);