mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 03:02:56 +00:00
* fix(mcp): narrow union branch diagnostics to matching discriminators * fix(mcp): merge union expected options at identical diagnostic paths * fix(mcp): guide cross-kind sub-selector mistakes on inspect_analysis_view * fix(mcp): support heterogeneous strict object variants in union discriminator narrowing * fix(mcp): resolve union discriminators before narrowing and preserve nested alternatives * fix(mcp): resolve setting discriminator before narrowing and preserve invalid_type failures * fix(mcp): narrow branches by matched discriminators before synthesizing diagnostics * test(javascript): distinguish package scope in route fixtures * test(apple): separate identity and refusal fixture groups * fix(input): preserve selector failures within projection bounds --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
762 lines
23 KiB
TypeScript
762 lines
23 KiB
TypeScript
import type { z } from "zod";
|
|
import {
|
|
AnalysisInputError,
|
|
type AnalysisInputIssue,
|
|
} from "./analysisErrorCore.js";
|
|
import {
|
|
knownArgumentMistakeGuidance,
|
|
knownViewMistakeGuidance,
|
|
} from "./inputArgumentGuidance.js";
|
|
|
|
const isRootArgument = (path: readonly unknown[]): boolean =>
|
|
path.length === 0 || (path.length === 1 && path[0] === "input");
|
|
|
|
/** Build the standard application input error while retaining schema causes and extra semantic issues. */
|
|
export const analysisInputErrorFromIssues = (
|
|
operation: string,
|
|
issues: readonly z.core.$ZodIssue[],
|
|
input: unknown,
|
|
options: {
|
|
readonly cause?: unknown;
|
|
readonly userMessage?: string;
|
|
readonly additionalIssues?: readonly AnalysisInputIssue[];
|
|
} = {},
|
|
): AnalysisInputError =>
|
|
new AnalysisInputError(
|
|
operation,
|
|
{
|
|
...(options.cause === undefined ? {} : { cause: options.cause }),
|
|
...(options.userMessage === undefined
|
|
? {}
|
|
: { userMessage: options.userMessage }),
|
|
},
|
|
[
|
|
...projectInputIssues(issues, input, operation),
|
|
...(options.additionalIssues ?? []),
|
|
],
|
|
);
|
|
|
|
/** Remove only the internal operation/input envelope from caller correction paths. */
|
|
export const analysisOperationInputErrorFromIssues = (
|
|
operation: string,
|
|
issues: readonly z.core.$ZodIssue[],
|
|
input: unknown,
|
|
cause: unknown,
|
|
): AnalysisInputError =>
|
|
new AnalysisInputError(
|
|
operation,
|
|
{ cause },
|
|
projectInputIssues(issues, { operation, input }, operation).map((issue) =>
|
|
issue.path[0] === "input"
|
|
? { ...issue, path: issue.path.slice(1) }
|
|
: issue,
|
|
),
|
|
);
|
|
|
|
/** Project Zod failures to secret-safe caller correction metadata. */
|
|
export const projectInputIssues = (
|
|
issues: readonly z.core.$ZodIssue[],
|
|
input: unknown,
|
|
operation?: string,
|
|
): readonly AnalysisInputIssue[] =>
|
|
issues.flatMap((issue) => projectIssue(issue, input, undefined, operation));
|
|
|
|
const projectionBudgetExceeded = Symbol(
|
|
"input issue projection budget exceeded",
|
|
);
|
|
|
|
/** Field diagnostics that fit a byte allowance, plus what was dropped. */
|
|
export interface BoundedInputIssues {
|
|
readonly issues: readonly AnalysisInputIssue[];
|
|
/** Validator issues whose field diagnostics did not fit the allowance. */
|
|
readonly omittedIssueCount: number;
|
|
}
|
|
|
|
/**
|
|
* Project as many validator issues as fit, in one forward pass, and count the
|
|
* rest. A single pass keeps the returned group maximal: it stops exactly at the
|
|
* allowance instead of at a coarse fraction of the list, and a compound issue
|
|
* such as `unrecognized_keys` still charges every field diagnostic it expands
|
|
* into because the recursion goes through `projectIssue` per nested issue.
|
|
*/
|
|
export const projectInputIssuesBounded = (
|
|
issues: readonly z.core.$ZodIssue[],
|
|
input: unknown,
|
|
budgetBytes: number,
|
|
): BoundedInputIssues => {
|
|
const budget = { remaining: budgetBytes };
|
|
const output: AnalysisInputIssue[] = [];
|
|
let index = 0;
|
|
try {
|
|
for (; index < issues.length; index += 1) {
|
|
const issue = issues[index];
|
|
if (issue === undefined) continue;
|
|
output.push(...projectIssue(issue, input, budget));
|
|
}
|
|
} catch (error) {
|
|
if (error !== projectionBudgetExceeded) throw error;
|
|
// The issue that crossed the allowance and every later one contribute at
|
|
// least one omitted field diagnostic each.
|
|
const omittedIssueCount = issues.length - index;
|
|
if (output.length > 0) return { issues: output, omittedIssueCount };
|
|
// One compound issue, such as `unrecognized_keys` naming hundreds of keys,
|
|
// can exceed the allowance on its own. Retain a diagnostic at its path so the
|
|
// caller keeps the location instead of only an omission notice.
|
|
const first = issues[0];
|
|
if (first === undefined) return { issues: output, omittedIssueCount };
|
|
const path = first.path.flatMap((part) =>
|
|
typeof part === "string" || typeof part === "number" ? [part] : [],
|
|
);
|
|
return {
|
|
issues: [
|
|
{
|
|
path,
|
|
reason: "invalid_value",
|
|
message: `${String(omittedIssueCount)} field diagnostics at this location were omitted because they do not fit the response budget.`,
|
|
},
|
|
],
|
|
omittedIssueCount,
|
|
};
|
|
}
|
|
return { issues: output, omittedIssueCount: 0 };
|
|
};
|
|
|
|
/** Retain each projected issue's source through bounded SDK normalization. */
|
|
export const projectInputIssueGroupsWithinBudget = (
|
|
issues: readonly z.core.$ZodIssue[],
|
|
input: unknown,
|
|
budgetBytes: number,
|
|
operation?: string,
|
|
): readonly (readonly AnalysisInputIssue[])[] | undefined => {
|
|
const budget = { remaining: budgetBytes };
|
|
try {
|
|
const groups: (readonly AnalysisInputIssue[])[] = [];
|
|
for (const issue of issues)
|
|
groups.push(projectIssue(issue, input, budget, operation));
|
|
return groups;
|
|
} catch (error) {
|
|
if (error === projectionBudgetExceeded) return undefined;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const chargeUnionProjection = (
|
|
issue: Extract<z.core.$ZodIssue, { code: "invalid_union" }>,
|
|
charge: (amount: number) => void,
|
|
): void => {
|
|
for (const branch of issue.errors) charge(32 + 32 * branch.length);
|
|
if ("options" in issue && Array.isArray(issue.options))
|
|
for (const value of issue.options)
|
|
charge(typeof value === "string" ? 6 * value.length + 32 : 32);
|
|
if ("discriminator" in issue && typeof issue.discriminator === "string")
|
|
charge(6 * issue.discriminator.length + 16);
|
|
};
|
|
|
|
const chargeProjection = (
|
|
issue: z.core.$ZodIssue,
|
|
budget: { remaining: number },
|
|
operation?: string,
|
|
input?: unknown,
|
|
): void => {
|
|
// Six bytes per UTF-16 code unit covers JSON escaping. The fixed allowance
|
|
// covers the projected object, property names, scalar values and delimiters.
|
|
const charge = (amount: number) => {
|
|
budget.remaining -= amount;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
};
|
|
charge(256);
|
|
let pathCost = 0;
|
|
for (const part of issue.path) {
|
|
const cost = typeof part === "string" ? 6 * part.length + 16 : 32;
|
|
pathCost += cost;
|
|
charge(cost);
|
|
}
|
|
charge(6 * issue.message.length);
|
|
const viewGuidance = knownViewMistakeGuidance(
|
|
operation,
|
|
input,
|
|
issue.path.flatMap((part) =>
|
|
typeof part === "string" || typeof part === "number" ? [part] : [],
|
|
),
|
|
);
|
|
if (viewGuidance !== undefined) charge(6 * viewGuidance.message.length);
|
|
if (issue.code === "invalid_union") chargeUnionProjection(issue, charge);
|
|
if (issue.code === "invalid_key") charge(32 * issue.issues.length);
|
|
if (issue.code === "unrecognized_keys")
|
|
for (const key of issue.keys) {
|
|
charge(256 + pathCost + 6 * key.length);
|
|
if (isRootArgument(issue.path)) {
|
|
const guidance = knownArgumentMistakeGuidance(operation, key);
|
|
const expectedAlreadyPresent =
|
|
guidance !== undefined &&
|
|
valueAtPath(input, [...issue.path, guidance.expected]) !== undefined;
|
|
if (guidance !== undefined && !expectedAlreadyPresent)
|
|
charge(
|
|
6 * guidance.expected.length + 6 * guidance.message.length + 32,
|
|
);
|
|
}
|
|
}
|
|
if (issue.code === "invalid_value")
|
|
for (const value of issue.values)
|
|
charge(typeof value === "string" ? 6 * value.length + 32 : 32);
|
|
};
|
|
|
|
const nestedIssue = (
|
|
parent: z.core.$ZodIssue,
|
|
child: z.core.$ZodIssue,
|
|
budget: { remaining: number } | undefined,
|
|
): z.core.$ZodIssue => {
|
|
if (budget !== undefined) {
|
|
// The combined path is allocated before the child is projected. Charge
|
|
// its references first so a long nested path cannot bypass the allowance.
|
|
budget.remaining -= 32 + 8 * (parent.path.length + child.path.length);
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
return { ...child, path: [...parent.path, ...child.path] };
|
|
};
|
|
|
|
const projectUnrecognizedKeyIssues = (
|
|
keys: readonly string[],
|
|
path: readonly (string | number)[],
|
|
input: unknown,
|
|
operation?: string,
|
|
): readonly AnalysisInputIssue[] =>
|
|
keys.map((key) => {
|
|
const guidance = isRootArgument(path)
|
|
? knownArgumentMistakeGuidance(operation, key)
|
|
: undefined;
|
|
const expectedAlreadyPresent =
|
|
guidance !== undefined &&
|
|
valueAtPath(input, [...path, guidance.expected]) !== undefined;
|
|
const effectiveGuidance = expectedAlreadyPresent ? undefined : guidance;
|
|
return {
|
|
path: [...path, key],
|
|
reason: "unknown_argument" as const,
|
|
...(effectiveGuidance === undefined
|
|
? {}
|
|
: {
|
|
expected: effectiveGuidance.expected,
|
|
message: effectiveGuidance.message,
|
|
}),
|
|
};
|
|
});
|
|
|
|
const CANDIDATE_DISCRIMINATOR_KEYS: readonly string[] = [
|
|
"kind",
|
|
"type",
|
|
"setting",
|
|
"collection",
|
|
"projection",
|
|
"facet",
|
|
"format",
|
|
"source",
|
|
"action",
|
|
"mode",
|
|
];
|
|
|
|
const candidateDiscriminatorKeys = (
|
|
targetValue: object,
|
|
budget?: { remaining: number },
|
|
): readonly string[] => {
|
|
const targetRecord = targetValue as Record<string, unknown>;
|
|
const keys: string[] = [];
|
|
for (const k of CANDIDATE_DISCRIMINATOR_KEYS) {
|
|
if (Object.hasOwn(targetRecord, k)) {
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 32;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
keys.push(k);
|
|
}
|
|
}
|
|
let otherKeys = 0;
|
|
for (const key in targetRecord) {
|
|
if (!Object.hasOwn(targetRecord, key)) continue;
|
|
if ((CANDIDATE_DISCRIMINATOR_KEYS as readonly string[]).includes(key))
|
|
continue;
|
|
if (otherKeys >= 32) break;
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 32;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
keys.push(key);
|
|
otherKeys++;
|
|
}
|
|
|
|
return keys;
|
|
};
|
|
|
|
const branchAcceptsKey = (
|
|
branchErrors: readonly z.core.$ZodIssue[],
|
|
key: string,
|
|
budget?: { remaining: number },
|
|
): boolean => {
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 16;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
for (const err of branchErrors) {
|
|
if (err.path.length === 1 && err.path[0] === key) return false;
|
|
if (err.code === "unrecognized_keys" && err.path.length === 0) {
|
|
for (const rejectedKey of err.keys) {
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 4;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
if (rejectedKey === key) return false;
|
|
}
|
|
}
|
|
if (err.code === "invalid_union" && err.path.length === 0) {
|
|
const anyNestedAccepts = err.errors.some((nested) =>
|
|
branchAcceptsKey(nested, key, budget),
|
|
);
|
|
if (!anyNestedAccepts) return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const branchHasNonValueKeyIssue = (
|
|
branchErrors: readonly z.core.$ZodIssue[],
|
|
key: string,
|
|
budget?: { remaining: number },
|
|
): boolean => {
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 16;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
for (const err of branchErrors) {
|
|
if (
|
|
err.path.length === 1 &&
|
|
err.path[0] === key &&
|
|
err.code !== "invalid_value"
|
|
) {
|
|
return true;
|
|
}
|
|
if (err.code === "invalid_union" && err.path.length === 0) {
|
|
const anyNested = err.errors.some((nested) =>
|
|
branchHasNonValueKeyIssue(nested, key, budget),
|
|
);
|
|
if (anyNested) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const branchHasDirectKeyIssue = (
|
|
branchErrors: readonly z.core.$ZodIssue[],
|
|
key: string,
|
|
code: z.core.$ZodIssue["code"],
|
|
budget?: { remaining: number },
|
|
): boolean => {
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 16;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
for (const err of branchErrors) {
|
|
if (err.path.length === 1 && err.path[0] === key && err.code === code) {
|
|
return true;
|
|
}
|
|
if (err.code === "invalid_union" && err.path.length === 0) {
|
|
const anyNested = err.errors.some((nested) =>
|
|
branchHasDirectKeyIssue(nested, key, code, budget),
|
|
);
|
|
if (anyNested) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const extractIssueExpectedValues = (
|
|
issue: z.core.$ZodIssue,
|
|
): readonly unknown[] => {
|
|
if (issue.code === "invalid_value") {
|
|
if ("values" in issue && Array.isArray(issue.values)) return issue.values;
|
|
if ("options" in issue && Array.isArray(issue.options))
|
|
return issue.options;
|
|
if ("expected" in issue && issue.expected !== undefined)
|
|
return [issue.expected];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const collectKeyExpectedValues = (
|
|
branchErrors: readonly z.core.$ZodIssue[],
|
|
key: string,
|
|
budget?: { remaining: number },
|
|
): readonly unknown[] => {
|
|
if (budget !== undefined) {
|
|
budget.remaining -= 16;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
const result: unknown[] = [];
|
|
for (const err of branchErrors) {
|
|
if (err.path.length === 1 && err.path[0] === key) {
|
|
const expected = extractIssueExpectedValues(err);
|
|
if (budget !== undefined && expected.length > 0) {
|
|
budget.remaining -= 16 * expected.length;
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
}
|
|
result.push(...expected);
|
|
} else if (err.code === "invalid_union" && err.path.length === 0) {
|
|
for (const nested of err.errors) {
|
|
result.push(...collectKeyExpectedValues(nested, key, budget));
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const chargeExpectedValuesBudget = (
|
|
path: readonly (string | number)[],
|
|
uniqueExpected: readonly unknown[],
|
|
budget?: { remaining: number },
|
|
): void => {
|
|
if (budget === undefined) return;
|
|
budget.remaining -= 32 + 8 * (path.length + 1);
|
|
for (const item of uniqueExpected) {
|
|
budget.remaining -= typeof item === "string" ? 6 * item.length + 32 : 32;
|
|
}
|
|
if (budget.remaining < 0) throw projectionBudgetExceeded;
|
|
};
|
|
|
|
const resolveUnionDiscriminatorDiagnostic = (
|
|
currentErrors: readonly (readonly z.core.$ZodIssue[])[],
|
|
path: readonly (string | number)[],
|
|
candidateKeys: readonly string[],
|
|
budget?: { remaining: number },
|
|
): readonly AnalysisInputIssue[] | "preserve" | undefined => {
|
|
for (const key of candidateKeys) {
|
|
if (!(CANDIDATE_DISCRIMINATOR_KEYS as readonly string[]).includes(key)) {
|
|
continue;
|
|
}
|
|
const allBranchesReject = currentErrors.every(
|
|
(branch) => !branchAcceptsKey(branch, key, budget),
|
|
);
|
|
if (!allBranchesReject) continue;
|
|
|
|
const anyBranchHasNonValue = currentErrors.some((branch) =>
|
|
branchHasNonValueKeyIssue(branch, key, budget),
|
|
);
|
|
if (anyBranchHasNonValue) return "preserve";
|
|
|
|
const anyBranchHasInvalidValue = currentErrors.some((branch) =>
|
|
branchHasDirectKeyIssue(branch, key, "invalid_value", budget),
|
|
);
|
|
if (!anyBranchHasInvalidValue) continue;
|
|
|
|
const allExpected = currentErrors.flatMap((branch) =>
|
|
collectKeyExpectedValues(branch, key, budget),
|
|
);
|
|
const uniqueExpected = [...new Set(allExpected.filter(isSafeExpected))];
|
|
if (uniqueExpected.length === 0) continue;
|
|
chargeExpectedValuesBudget(path, uniqueExpected, budget);
|
|
return [
|
|
{
|
|
path: [...path, key],
|
|
reason: "invalid_value",
|
|
expected: uniqueExpected,
|
|
},
|
|
];
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const narrowUnionBranches = (
|
|
errors: readonly (readonly z.core.$ZodIssue[])[],
|
|
targetValue: object,
|
|
budget?: { remaining: number },
|
|
keysToCheck?: readonly string[],
|
|
): readonly (readonly z.core.$ZodIssue[])[] => {
|
|
let currentErrors = errors;
|
|
const candidateKeys =
|
|
keysToCheck ?? candidateDiscriminatorKeys(targetValue, budget);
|
|
for (const key of candidateKeys) {
|
|
const matchingBranches = currentErrors.filter((branch) =>
|
|
branchAcceptsKey(branch, key, budget),
|
|
);
|
|
if (
|
|
matchingBranches.length === 0 &&
|
|
currentErrors.some((branch) =>
|
|
branchHasNonValueKeyIssue(branch, key, budget),
|
|
)
|
|
)
|
|
break;
|
|
if (
|
|
matchingBranches.length > 0 &&
|
|
matchingBranches.length < currentErrors.length
|
|
) {
|
|
currentErrors = matchingBranches;
|
|
if (currentErrors.length === 1) break;
|
|
}
|
|
}
|
|
return currentErrors;
|
|
};
|
|
|
|
interface UnionProjectionContext {
|
|
readonly input: unknown;
|
|
readonly projectChildIssue: (
|
|
child: z.core.$ZodIssue,
|
|
) => readonly AnalysisInputIssue[];
|
|
readonly budget?: { remaining: number } | undefined;
|
|
}
|
|
|
|
const projectUnionIssue = (
|
|
issue: Extract<z.core.$ZodIssue, { code: "invalid_union" }>,
|
|
path: readonly (string | number)[],
|
|
context: UnionProjectionContext,
|
|
): readonly AnalysisInputIssue[] => {
|
|
const { input, projectChildIssue, budget } = context;
|
|
if (issue.errors.length === 0) {
|
|
const targetPath =
|
|
"discriminator" in issue &&
|
|
typeof issue.discriminator === "string" &&
|
|
path.at(-1) !== issue.discriminator
|
|
? [...path, issue.discriminator]
|
|
: path;
|
|
const expected =
|
|
"options" in issue && Array.isArray(issue.options)
|
|
? issue.options.filter(isSafeExpected)
|
|
: undefined;
|
|
return [
|
|
{
|
|
path: targetPath,
|
|
reason:
|
|
valueAtPath(input, targetPath) === undefined
|
|
? "missing_argument"
|
|
: "invalid_value",
|
|
...(expected === undefined ? {} : { expected }),
|
|
},
|
|
];
|
|
}
|
|
|
|
const targetValue = valueAtPath(input, path);
|
|
let activeErrors: readonly (readonly z.core.$ZodIssue[])[] = issue.errors;
|
|
if (
|
|
typeof targetValue === "object" &&
|
|
targetValue !== null &&
|
|
!Array.isArray(targetValue)
|
|
) {
|
|
const candidateKeys = candidateDiscriminatorKeys(targetValue, budget);
|
|
let narrowed = narrowUnionBranches(
|
|
issue.errors,
|
|
targetValue,
|
|
budget,
|
|
candidateKeys.filter((key) => CANDIDATE_DISCRIMINATOR_KEYS.includes(key)),
|
|
);
|
|
if (narrowed.length > 1) {
|
|
const discriminator = resolveUnionDiscriminatorDiagnostic(
|
|
narrowed,
|
|
path,
|
|
candidateKeys,
|
|
budget,
|
|
);
|
|
if (discriminator !== undefined && discriminator !== "preserve")
|
|
return discriminator;
|
|
if (discriminator !== "preserve") {
|
|
const payloadNarrowed = narrowUnionBranches(
|
|
narrowed,
|
|
targetValue,
|
|
budget,
|
|
candidateKeys,
|
|
);
|
|
if (
|
|
payloadNarrowed.length > 1 &&
|
|
payloadNarrowed.length < narrowed.length
|
|
) {
|
|
const secondary = resolveUnionDiscriminatorDiagnostic(
|
|
payloadNarrowed,
|
|
path,
|
|
candidateKeys,
|
|
budget,
|
|
);
|
|
if (secondary !== undefined && secondary !== "preserve")
|
|
return secondary;
|
|
}
|
|
narrowed = payloadNarrowed;
|
|
}
|
|
}
|
|
|
|
activeErrors = narrowed;
|
|
}
|
|
|
|
return uniqueIssues(
|
|
activeErrors.flatMap((branch) =>
|
|
branch.flatMap((branchIssue) =>
|
|
projectChildIssue(nestedIssue(issue, branchIssue, budget)),
|
|
),
|
|
),
|
|
);
|
|
};
|
|
|
|
const projectIssue = (
|
|
issue: z.core.$ZodIssue,
|
|
input: unknown,
|
|
budget?: { remaining: number },
|
|
operation?: string,
|
|
): readonly AnalysisInputIssue[] => {
|
|
if (budget !== undefined) chargeProjection(issue, budget, operation, input);
|
|
const path = issue.path.flatMap((part) =>
|
|
typeof part === "string" || typeof part === "number" ? [part] : [],
|
|
);
|
|
const viewGuidance = knownViewMistakeGuidance(operation, input, path);
|
|
// Branch issue paths are relative to the union; branches often agree.
|
|
if (issue.code === "invalid_union") {
|
|
return projectUnionIssue(issue, path, {
|
|
input,
|
|
projectChildIssue: (child) =>
|
|
projectIssue(child, input, budget, operation),
|
|
budget,
|
|
});
|
|
}
|
|
if (issue.code === "invalid_key")
|
|
return issue.issues.flatMap((keyIssue) =>
|
|
projectIssue(
|
|
nestedIssue(issue, keyIssue, budget),
|
|
input,
|
|
budget,
|
|
operation,
|
|
),
|
|
);
|
|
if (issue.code === "unrecognized_keys")
|
|
return projectUnrecognizedKeyIssues(issue.keys, path, input, operation);
|
|
if (issue.code === "invalid_type")
|
|
return [
|
|
{
|
|
path,
|
|
reason:
|
|
valueAtPath(input, path) === undefined
|
|
? "missing_argument"
|
|
: "invalid_type",
|
|
expected: safeExpected(issue.expected),
|
|
...(viewGuidance !== undefined
|
|
? { message: viewGuidance.message }
|
|
: {}),
|
|
},
|
|
];
|
|
if (issue.code === "too_small")
|
|
return [boundedIssue(path, "minimum", numericBound(issue.minimum), issue)];
|
|
if (issue.code === "too_big")
|
|
return [boundedIssue(path, "maximum", numericBound(issue.maximum), issue)];
|
|
if (issue.code === "invalid_format")
|
|
return [
|
|
{
|
|
path,
|
|
reason: "invalid_format",
|
|
expected: issue.format,
|
|
...(issue.format === "regex" && issue.message.length > 0
|
|
? { message: issue.message }
|
|
: {}),
|
|
},
|
|
];
|
|
if (issue.code === "invalid_value")
|
|
return [
|
|
{
|
|
path,
|
|
reason: "invalid_value",
|
|
expected: issue.values.filter(isSafeExpected),
|
|
...(viewGuidance !== undefined
|
|
? { message: viewGuidance.message }
|
|
: {}),
|
|
},
|
|
];
|
|
return [
|
|
{
|
|
path,
|
|
reason: "invalid_value",
|
|
...(viewGuidance !== undefined
|
|
? { message: viewGuidance.message }
|
|
: issue.message.length > 0
|
|
? { message: issue.message }
|
|
: {}),
|
|
},
|
|
];
|
|
};
|
|
|
|
const mergeExpectedValues = (
|
|
first: unknown,
|
|
second: unknown,
|
|
): (string | number | boolean | null)[] => [
|
|
...new Set(
|
|
[
|
|
...(Array.isArray(first) ? first : [first]),
|
|
...(Array.isArray(second) ? second : [second]),
|
|
].filter(isSafeExpected),
|
|
),
|
|
];
|
|
|
|
const uniqueIssues = (
|
|
issues: readonly AnalysisInputIssue[],
|
|
): AnalysisInputIssue[] => {
|
|
const merged: AnalysisInputIssue[] = [];
|
|
const invalidValueIndexByPath = new Map<string, number>();
|
|
const seenSerializedKeys = new Set<string>();
|
|
|
|
for (const issue of issues) {
|
|
const pathKey = JSON.stringify(issue.path);
|
|
if (issue.reason === "invalid_value" && issue.expected !== undefined) {
|
|
const existingIndex = invalidValueIndexByPath.get(pathKey);
|
|
if (existingIndex !== undefined) {
|
|
const existing = merged[existingIndex];
|
|
if (existing !== undefined) {
|
|
merged[existingIndex] = {
|
|
...existing,
|
|
expected: mergeExpectedValues(existing.expected, issue.expected),
|
|
...((existing.message ?? issue.message)
|
|
? { message: existing.message ?? issue.message }
|
|
: {}),
|
|
};
|
|
continue;
|
|
}
|
|
}
|
|
invalidValueIndexByPath.set(pathKey, merged.length);
|
|
}
|
|
const fullKey = JSON.stringify(issue);
|
|
if (!seenSerializedKeys.has(fullKey)) {
|
|
seenSerializedKeys.add(fullKey);
|
|
merged.push(issue);
|
|
}
|
|
}
|
|
return merged;
|
|
};
|
|
|
|
const valueAtPath = (input: unknown, path: readonly PropertyKey[]): unknown => {
|
|
let current = input;
|
|
for (const part of path) {
|
|
if (typeof current !== "object" || current === null) return undefined;
|
|
const next: unknown = Reflect.get(current, part);
|
|
current = next;
|
|
}
|
|
return current;
|
|
};
|
|
|
|
const numericBound = (value: number | bigint): number | undefined =>
|
|
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
|
|
const isSafeExpected = (
|
|
value: unknown,
|
|
): value is string | number | boolean | null =>
|
|
value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
|
|
const safeExpected = (value: unknown) =>
|
|
isSafeExpected(value) ? value : "schema-defined value";
|
|
|
|
/**
|
|
* `minimum` and `maximum` are inclusive, as in JSON Schema. An exclusive bound
|
|
* keeps the schema's own message instead of naming a value it rejects.
|
|
*/
|
|
const boundedIssue = (
|
|
path: readonly (string | number)[],
|
|
key: "minimum" | "maximum",
|
|
value: number | undefined,
|
|
issue: { readonly inclusive?: boolean; readonly message: string },
|
|
): AnalysisInputIssue => ({
|
|
path,
|
|
reason: "out_of_range",
|
|
...(value === undefined
|
|
? {}
|
|
: issue.inclusive === false
|
|
? { message: issue.message }
|
|
: { [key]: value }),
|
|
});
|