Skip to content

Commit 8a8f9dc

Browse files
tanshanshantanshanshanclaude
authored
fix(config): append numeric bound hints to ceiling/floor validation errors (#84852)
* fix(config): append numeric bound hints to ceiling/floor validation errors When a config value exceeds a schema-enforced ceiling or falls below a floor, the error message now includes the constraint explicitly: - Inclusive: `(maximum: 20)` / `(minimum: 0)` - Exclusive: `(must be less than 5)` / `(must be greater than 0)` This matches the clarity that enum/union rejections already get via `(allowed: …)` hints, and avoids the misleading "minimum: 0" wording that previous attempts produced for `.positive()` / `.gt(0)` rejections. Only numeric-origin `too_big`/`too_small` issues are enriched; string, array, and file-size origins are left unchanged. Fixes #52500 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(config): update maxFileBytes test for numeric bound hint The test snapshot for `logging.maxFileBytes: 0` rejection now includes the `(must be greater than 0)` hint appended by the numeric bound enrichment added in the previous commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(config): guard nullable record in appendNumericBoundHint call ClawSweeper P1: `record` from `toIssueRecord()` can be null, but `appendNumericBoundHint` expects a non-null `UnknownIssueRecord`. Guard with a ternary so the original message is returned when record is null (which only happens for malformed/empty issues that already produce generic "Invalid input" messages). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: tanshanshan <tanshanshan@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0fb1de5 commit 8a8f9dc

3 files changed

Lines changed: 75 additions & 3 deletions

File tree

src/config/logging-max-file-bytes.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe("logging.maxFileBytes config", () => {
2222
expect(res.issues).toEqual([
2323
{
2424
path: "logging.maxFileBytes",
25-
message: "Too small: expected number to be >0",
25+
message: "Too small: expected number to be >0 (must be greater than 0)",
2626
},
2727
]);
2828
}

src/config/validation.allowed-values.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,45 @@ describe("config validation allowed-values metadata", () => {
146146
}
147147
});
148148
});
149+
150+
describe("config validation numeric bound hints", () => {
151+
it("appends maximum for inclusive too_big numeric bound", () => {
152+
const issue = mapFirstIssue(
153+
z.object({ maxPingPongTurns: z.number().int().min(0).max(20).optional() }),
154+
{ maxPingPongTurns: 50 },
155+
);
156+
expect(issue.path).toBe("maxPingPongTurns");
157+
expect(issue.message).toContain("(maximum: 20)");
158+
expect(issue.allowedValues).toBeUndefined();
159+
});
160+
161+
it("appends 'must be less than' for exclusive too_big numeric bound", () => {
162+
const issue = mapFirstIssue(z.object({ rate: z.number().lt(5) }), { rate: 5 });
163+
expect(issue.path).toBe("rate");
164+
expect(issue.message).toContain("(must be less than 5)");
165+
expect(issue.message).not.toContain("(maximum: 5)");
166+
});
167+
168+
it("appends 'must be greater than' for exclusive too_small numeric bound (positive/gt)", () => {
169+
const issue = mapFirstIssue(z.object({ count: z.number().positive() }), { count: 0 });
170+
expect(issue.path).toBe("count");
171+
expect(issue.message).toContain("(must be greater than 0)");
172+
expect(issue.message).not.toContain("(minimum: 0)");
173+
});
174+
175+
it("appends minimum for inclusive too_small numeric bound", () => {
176+
const issue = mapFirstIssue(z.object({ retries: z.number().min(0) }), { retries: -1 });
177+
expect(issue.path).toBe("retries");
178+
expect(issue.message).toContain("(minimum: 0)");
179+
});
180+
181+
it("does not append numeric bound hints for non-number origins (string)", () => {
182+
const issue = mapFirstIssue(z.object({ name: z.string().max(10) }), {
183+
name: "abcdefghijklmnop",
184+
});
185+
expect(issue.path).toBe("name");
186+
expect(issue.message).not.toContain("(maximum:");
187+
expect(issue.message).not.toContain("(must be less than");
188+
expect(issue.allowedValues).toBeUndefined();
189+
});
190+
});

src/config/validation.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,31 @@ function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): Allowe
317317
return collectAllowedValuesFromBundledChannelSchemaPath(toConfigPathSegments(record.path));
318318
}
319319

320+
function appendNumericBoundHint(message: string, record: UnknownIssueRecord): string {
321+
const origin = typeof record.origin === "string" ? record.origin : "";
322+
if (origin !== "number") {
323+
return message;
324+
}
325+
const inclusive = record.inclusive === true;
326+
if (record.code === "too_big") {
327+
const maximum = typeof record.maximum === "number" ? record.maximum : undefined;
328+
if (maximum !== undefined) {
329+
return inclusive
330+
? `${message} (maximum: ${maximum})`
331+
: `${message} (must be less than ${maximum})`;
332+
}
333+
}
334+
if (record.code === "too_small") {
335+
const minimum = typeof record.minimum === "number" ? record.minimum : undefined;
336+
if (minimum !== undefined) {
337+
return inclusive
338+
? `${message} (minimum: ${minimum})`
339+
: `${message} (must be greater than ${minimum})`;
340+
}
341+
}
342+
return message;
343+
}
344+
320345
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
321346
const record = toIssueRecord(issue);
322347
if (!record) {
@@ -581,6 +606,11 @@ function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
581606
const path = formatConfigPath(toConfigPathSegments(record?.path));
582607
const message = typeof record?.message === "string" ? record.message : "Invalid input";
583608

609+
// Numeric ceiling/floor hints (too_big / too_small with numeric origin).
610+
// Append a parenthesized bound alongside Zod's native message,
611+
// matching the clarity that enum/union rejections get via (allowed: …).
612+
const enrichedMessage = record ? appendNumericBoundHint(message, record) : message;
613+
584614
const allowedValuesSummary = summarizeAllowedValues(collectAllowedValuesFromUnknownIssue(issue));
585615

586616
// Bindings use a plain union because legacy route bindings may omit `type`.
@@ -599,12 +629,12 @@ function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
599629
}
600630

601631
if (!allowedValuesSummary) {
602-
return { path, message };
632+
return { path, message: enrichedMessage };
603633
}
604634

605635
return {
606636
path,
607-
message: appendAllowedValuesHint(message, allowedValuesSummary),
637+
message: appendAllowedValuesHint(enrichedMessage, allowedValuesSummary),
608638
allowedValues: allowedValuesSummary.values,
609639
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
610640
};

0 commit comments

Comments
 (0)