mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 23:06:18 +00:00
* fix(evidence): bound malformed file field diagnostics `readEvidenceFileSource` projected every validator issue, and the generic error delivery applied the MCP response budget afterwards. A file with many invalid fields therefore produced a transport `resource_constraint` that replaced the typed file error, so the caller lost the selected path entirely. Issue #8453 reports exactly that: 20,000 invalid values still returned a direct `invalid_request` with the path and every field issue, while 125,000 returned `resource_constraint` with neither. `projectInputIssuesWithinBudget` already existed for this but had no production caller. The reader now projects within a 64 KiB allowance and, when the whole list does not fit, keeps the largest leading group that does and appends one explicit omission issue naming how many of how many were dropped. The typed file error and its `path` therefore survive the response boundary, and the diagnostics stay actionable and explicitly incomplete instead of hiding the real failure. Two regressions cover the bound and the fits-in-budget case. With the unbounded projection restored, the bound test and its supporting fixtures fail. * chore: retrigger title and readiness checks after the title was corrected * fix(evidence): bound file diagnostics in one pass and cover the MCP boundary Review findings on the first version. **The prefix was not maximal.** `boundedContentIssues` halved the list and returned the first attempt that fit, so a file just over the allowance lost roughly half its diagnostics. Prefix selection now lives in the shared projector (`projectInputIssuesBounded`), which walks the issues once and stops exactly at the allowance, keeping the largest fitting prefix. **A compound issue could still lose its location.** One `unrecognized_keys` issue naming hundreds of keys expands into hundreds of field diagnostics and can exceed the allowance on its own. The projector now retains a diagnostic at that issue's path instead of returning only the omission notice. **The regression never crossed the MCP boundary.** The issue is about the delivered MCP error, so `evidenceFileSourceMcp.test.ts` sends the 125,000-value fixture through the production `inspect_analysis_view` tool and asserts the delivered error stays `invalid_request`, keeps `details.path`, keeps bounded `content_issues`, and fits the response budget. Both regressions fail without the bound. * test(evidence): move the MCP delivery regression to the adapters project Review finding. `scripts/lib/test-projects.mjs` assigns `src/application/**` to the `services` project, so the delivery regression sat in the wrong project even though it exercises the production MCP tool registration. Moving it to `src/server/` puts it in `adapters`, which runs the other MCP boundary tests and plans the same way.
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import type { z } from "zod";
|
|
import {
|
|
AnalysisInputError,
|
|
type AnalysisInputIssue,
|
|
} from "./analysisErrorCore.js";
|
|
|
|
/** 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), ...(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 }).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,
|
|
): readonly AnalysisInputIssue[] =>
|
|
issues.flatMap((issue) => projectIssue(issue, input));
|
|
|
|
const projectionBudgetExceeded = Symbol(
|
|
"input issue projection budget exceeded",
|
|
);
|
|
|
|
/** Bound representation expansion before constructing projected diagnostics. */
|
|
export const projectInputIssuesWithinBudget = (
|
|
issues: readonly z.core.$ZodIssue[],
|
|
input: unknown,
|
|
budgetBytes: number,
|
|
): readonly AnalysisInputIssue[] | undefined => {
|
|
return projectInputIssueGroupsWithinBudget(
|
|
issues,
|
|
input,
|
|
budgetBytes,
|
|
)?.flat();
|
|
};
|
|
|
|
/** 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,
|
|
): readonly (readonly AnalysisInputIssue[])[] | undefined => {
|
|
const budget = { remaining: budgetBytes };
|
|
try {
|
|
const groups: (readonly AnalysisInputIssue[])[] = [];
|
|
for (const issue of issues) groups.push(projectIssue(issue, input, budget));
|
|
return groups;
|
|
} catch (error) {
|
|
if (error === projectionBudgetExceeded) return undefined;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const chargeProjection = (
|
|
issue: z.core.$ZodIssue,
|
|
budget: { remaining: number },
|
|
): 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);
|
|
if (issue.code === "invalid_union") {
|
|
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 (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 (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 projectIssue = (
|
|
issue: z.core.$ZodIssue,
|
|
input: unknown,
|
|
budget?: { remaining: number },
|
|
): readonly AnalysisInputIssue[] => {
|
|
if (budget !== undefined) chargeProjection(issue, budget);
|
|
const path = issue.path.flatMap((part) =>
|
|
typeof part === "string" || typeof part === "number" ? [part] : [],
|
|
);
|
|
// Branch issue paths are relative to the union; branches often agree.
|
|
if (issue.code === "invalid_union") {
|
|
// Unmatched discriminators and ambiguous unions have no branch issues.
|
|
// Keep their outer failure instead of flattening it into an empty list.
|
|
if (issue.errors.length === 0) {
|
|
const expected =
|
|
"options" in issue ? issue.options?.filter(isSafeExpected) : undefined;
|
|
return [
|
|
{
|
|
path,
|
|
reason:
|
|
valueAtPath(input, path) === undefined
|
|
? "missing_argument"
|
|
: "invalid_value",
|
|
...(expected === undefined ? {} : { expected }),
|
|
},
|
|
];
|
|
}
|
|
return uniqueIssues(
|
|
issue.errors.flatMap((branch) =>
|
|
branch.flatMap((branchIssue) =>
|
|
projectIssue(nestedIssue(issue, branchIssue, budget), input, budget),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (issue.code === "invalid_key")
|
|
return issue.issues.flatMap((keyIssue) =>
|
|
projectIssue(nestedIssue(issue, keyIssue, budget), input, budget),
|
|
);
|
|
if (issue.code === "unrecognized_keys")
|
|
return issue.keys.map((key) => ({
|
|
path: [...path, key],
|
|
reason: "unknown_argument" as const,
|
|
}));
|
|
if (issue.code === "invalid_type")
|
|
return [
|
|
{
|
|
path,
|
|
reason:
|
|
valueAtPath(input, path) === undefined
|
|
? "missing_argument"
|
|
: "invalid_type",
|
|
expected: safeExpected(issue.expected),
|
|
},
|
|
];
|
|
if (issue.code === "too_small")
|
|
return [boundedIssue(path, "minimum", numericBound(issue.minimum))];
|
|
if (issue.code === "too_big")
|
|
return [boundedIssue(path, "maximum", numericBound(issue.maximum))];
|
|
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),
|
|
},
|
|
];
|
|
return [
|
|
{
|
|
path,
|
|
reason: "invalid_value",
|
|
...(issue.message.length > 0 ? { message: issue.message } : {}),
|
|
},
|
|
];
|
|
};
|
|
|
|
const uniqueIssues = (
|
|
issues: readonly AnalysisInputIssue[],
|
|
): AnalysisInputIssue[] => [
|
|
...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(),
|
|
];
|
|
|
|
const valueAtPath = (
|
|
input: unknown,
|
|
path: readonly (string | number)[],
|
|
): 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";
|
|
|
|
const boundedIssue = (
|
|
path: readonly (string | number)[],
|
|
key: "minimum" | "maximum",
|
|
value: number | undefined,
|
|
): AnalysisInputIssue => ({
|
|
path,
|
|
reason: "out_of_range",
|
|
...(value === undefined ? {} : { [key]: value }),
|
|
});
|