mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +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>
976 lines
31 KiB
TypeScript
976 lines
31 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
analysisInputErrorFromIssues,
|
|
analysisOperationInputErrorFromIssues,
|
|
projectInputIssues,
|
|
projectInputIssuesBounded,
|
|
projectInputIssueGroupsWithinBudget,
|
|
} from "./inputIssueProjection.js";
|
|
import { projectAnalysisError } from "./analysisErrorProjection.js";
|
|
import { processScenarioSchema } from "./process/processScenario.js";
|
|
import { applicationFeatureSeedSchema } from "./javascript/javascriptFeatureTraceSchemas.js";
|
|
import { updateAnalysisSettingsInputSchema } from "../contracts/analysisSettingsContracts.js";
|
|
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
|
|
import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js";
|
|
import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js";
|
|
|
|
const projectFlattenedIssueGroups = (
|
|
...args: Parameters<typeof projectInputIssueGroupsWithinBudget>
|
|
) => projectInputIssueGroupsWithinBudget(...args)?.flat();
|
|
|
|
const officialContract = (
|
|
name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"],
|
|
) => {
|
|
const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name);
|
|
if (contract === undefined)
|
|
throw new Error(`Missing official contract: ${name}`);
|
|
return contract;
|
|
};
|
|
|
|
const applicationContract = (
|
|
name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"],
|
|
) => {
|
|
const contract = APPLICATION_TOOL_CONTRACTS.find((c) => c.name === name);
|
|
if (contract === undefined)
|
|
throw new Error(`Missing application contract: ${name}`);
|
|
return contract;
|
|
};
|
|
|
|
const enhancedContract = (
|
|
name: (typeof ENHANCED_TOOL_CONTRACTS)[number]["name"],
|
|
) => {
|
|
const contract = ENHANCED_TOOL_CONTRACTS.find((c) => c.name === name);
|
|
if (contract === undefined)
|
|
throw new Error(`Missing enhanced contract: ${name}`);
|
|
return contract;
|
|
};
|
|
|
|
describe("input issue projection", () => {
|
|
it("builds application input errors with projected and semantic issues", () => {
|
|
const schema = z.object({ count: z.number().int() });
|
|
const input = { count: "invalid" };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const cause = parsed.error;
|
|
const error = analysisInputErrorFromIssues(
|
|
"inspect_fixture",
|
|
parsed.error.issues,
|
|
input,
|
|
{
|
|
cause,
|
|
userMessage: "Supply an integer count.",
|
|
additionalIssues: [
|
|
{ path: ["count"], reason: "out_of_range", minimum: 0 },
|
|
],
|
|
},
|
|
);
|
|
|
|
expect(error.cause).toBe(cause);
|
|
expect(error.userMessage).toBe("Supply an integer count.");
|
|
expect(error.issues).toEqual([
|
|
{ path: ["count"], reason: "invalid_type", expected: "number" },
|
|
{ path: ["count"], reason: "out_of_range", minimum: 0 },
|
|
]);
|
|
});
|
|
|
|
it("reports inclusive bounds and never names an excluded bound as allowed", () => {
|
|
const schema = z.object({
|
|
positive: z.number().int().positive(),
|
|
below: z.number().lt(10),
|
|
atLeast: z.number().int().min(1),
|
|
atMost: z.number().max(5),
|
|
});
|
|
const input = { positive: 0, below: 10, atLeast: 0, atMost: 6 };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
|
|
expect(issues).toEqual([
|
|
{
|
|
path: ["positive"],
|
|
reason: "out_of_range",
|
|
message: expect.stringContaining(">0"),
|
|
},
|
|
{
|
|
path: ["below"],
|
|
reason: "out_of_range",
|
|
message: expect.stringContaining("<10"),
|
|
},
|
|
{ path: ["atLeast"], reason: "out_of_range", minimum: 1 },
|
|
{ path: ["atMost"], reason: "out_of_range", maximum: 5 },
|
|
]);
|
|
expect(JSON.stringify(issues)).not.toContain('"minimum":0');
|
|
});
|
|
|
|
it("preserves static regex guidance without echoing the rejected value", () => {
|
|
const schema = z.object({
|
|
value: z.string().regex(/^[^\0]*$/u, "Values cannot contain NUL"),
|
|
});
|
|
const input = { value: "private\0value" };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
|
|
expect(projectInputIssues(parsed.error.issues, input)).toEqual([
|
|
{
|
|
path: ["value"],
|
|
reason: "invalid_format",
|
|
expected: "regex",
|
|
message: "Values cannot contain NUL",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("projects reserved process environment key constraints at the key path", () => {
|
|
const input = {
|
|
executable: "/usr/bin/true",
|
|
environment: { REA_PROCESS_RUN_ID: "caller-value" },
|
|
};
|
|
const parsed = processScenarioSchema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
|
|
expect(projectInputIssues(parsed.error.issues, input)).toEqual([
|
|
{
|
|
path: ["environment", "REA_PROCESS_RUN_ID"],
|
|
reason: "invalid_format",
|
|
expected: "regex",
|
|
message: "REA_PROCESS_RUN_ID is reserved by the process adapter",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves schema-authored custom correction guidance", () => {
|
|
const schema = z
|
|
.object({ left: z.string().optional(), right: z.string().optional() })
|
|
.superRefine((value, context) => {
|
|
if (value.left === undefined && value.right === undefined)
|
|
context.addIssue({
|
|
code: "custom",
|
|
path: [],
|
|
message: "Supply either left or right",
|
|
});
|
|
});
|
|
const parsed = schema.safeParse({});
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
|
|
expect(projectInputIssues(parsed.error.issues, {})).toEqual([
|
|
{
|
|
path: [],
|
|
reason: "invalid_value",
|
|
message: "Supply either left or right",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports union branch issues at the union's path once", () => {
|
|
const schema = z.object({
|
|
seed: z.union([
|
|
z.object({ value: z.string().min(1) }),
|
|
z.object({ value: z.literal(""), match: z.literal("exact") }),
|
|
]),
|
|
});
|
|
const missing = schema.safeParse({});
|
|
if (missing.success) throw new Error("expected invalid input");
|
|
expect(projectInputIssues(missing.error.issues, {})).toEqual([
|
|
{ path: ["seed"], reason: "missing_argument", expected: "object" },
|
|
]);
|
|
|
|
const input = { seed: { value: 1 } };
|
|
const nested = schema.safeParse(input);
|
|
if (nested.success) throw new Error("expected invalid input");
|
|
expect(projectInputIssues(nested.error.issues, input)).toEqual(
|
|
expect.arrayContaining([
|
|
{ path: ["seed", "value"], reason: "invalid_type", expected: "string" },
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("input issue argument guidance", () => {
|
|
it("guides callers from neighboring-tool argument names while keeping unknown keys strict", () => {
|
|
const input = { address: "0x401000", count: 10 };
|
|
for (const op of [
|
|
"read_function_instructions",
|
|
"procedure_pseudo_code",
|
|
"procedure_assembly",
|
|
] as const) {
|
|
const parsed = officialContract(op).inputSchema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const error = analysisInputErrorFromIssues(
|
|
op,
|
|
parsed.error.issues,
|
|
input,
|
|
);
|
|
expect(error.issues).toEqual([
|
|
{ path: ["procedure"], reason: "missing_argument", expected: "string" },
|
|
{
|
|
path: ["address"],
|
|
reason: "unknown_argument",
|
|
expected: "procedure",
|
|
message: expect.stringMatching(/address.*procedure/i),
|
|
},
|
|
{ path: ["count"], reason: "unknown_argument" },
|
|
]);
|
|
const action = projectAnalysisError(error).remediation.action;
|
|
expect(action).toMatch(/procedure.*address/i);
|
|
expect(action).toContain(op);
|
|
}
|
|
|
|
const searchInput = { query: "flag" };
|
|
for (const op of ["search_strings", "search_procedures"] as const) {
|
|
const parsed = officialContract(op).inputSchema.safeParse(searchInput);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const searchError = analysisInputErrorFromIssues(
|
|
op,
|
|
parsed.error.issues,
|
|
searchInput,
|
|
);
|
|
expect(searchError.issues).toEqual([
|
|
{ path: ["pattern"], reason: "missing_argument", expected: "string" },
|
|
{
|
|
path: ["query"],
|
|
reason: "unknown_argument",
|
|
expected: "pattern",
|
|
message: expect.stringMatching(/query.*pattern/i),
|
|
},
|
|
]);
|
|
const action = projectAnalysisError(searchError).remediation.action;
|
|
expect(action).toMatch(/pattern.*query/i);
|
|
expect(action).toContain(op);
|
|
}
|
|
});
|
|
|
|
it("guides callers for symmetric neighboring-tool argument names", () => {
|
|
const addressInput = { procedure: "main" };
|
|
for (const op of ["resolve_containing_procedure", "read_bytes"] as const) {
|
|
const parsed = officialContract(op).inputSchema.safeParse(addressInput);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const error = analysisInputErrorFromIssues(
|
|
op,
|
|
parsed.error.issues,
|
|
addressInput,
|
|
);
|
|
expect(error.issues).toEqual([
|
|
{ path: ["address"], reason: "missing_argument", expected: "string" },
|
|
{
|
|
path: ["procedure"],
|
|
reason: "unknown_argument",
|
|
expected: "address",
|
|
message: expect.stringMatching(/procedure.*address/i),
|
|
},
|
|
]);
|
|
const action = projectAnalysisError(error).remediation.action;
|
|
expect(action).toMatch(/address.*procedure/i);
|
|
expect(action).toContain(op);
|
|
}
|
|
|
|
const xrefsParsed =
|
|
officialContract("xrefs").inputSchema.safeParse(addressInput);
|
|
if (xrefsParsed.success) throw new Error("expected invalid input");
|
|
const xrefsError = analysisInputErrorFromIssues(
|
|
"xrefs",
|
|
xrefsParsed.error.issues,
|
|
addressInput,
|
|
);
|
|
expect(xrefsError.issues).toEqual([
|
|
{
|
|
path: ["procedure"],
|
|
reason: "unknown_argument",
|
|
expected: "address",
|
|
message: expect.stringMatching(/procedure.*address/i),
|
|
},
|
|
]);
|
|
const xrefsAction = projectAnalysisError(xrefsError).remediation.action;
|
|
expect(xrefsAction).toMatch(/address.*procedure/i);
|
|
expect(xrefsAction).toContain("xrefs");
|
|
|
|
const queryInput = { pattern: "flag" };
|
|
const queryCases = [
|
|
{
|
|
op: "search_guidance" as const,
|
|
contract: applicationContract("search_guidance"),
|
|
},
|
|
{
|
|
op: "trace_feature" as const,
|
|
contract: enhancedContract("trace_feature"),
|
|
},
|
|
];
|
|
for (const { op, contract } of queryCases) {
|
|
const parsed = contract.inputSchema.safeParse(queryInput);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const error = analysisInputErrorFromIssues(
|
|
op,
|
|
parsed.error.issues,
|
|
queryInput,
|
|
);
|
|
expect(error.issues).toEqual([
|
|
{ path: ["query"], reason: "missing_argument", expected: "string" },
|
|
{
|
|
path: ["pattern"],
|
|
reason: "unknown_argument",
|
|
expected: "query",
|
|
message: expect.stringMatching(/pattern.*query/i),
|
|
},
|
|
]);
|
|
const action = projectAnalysisError(error).remediation.action;
|
|
expect(action).toMatch(/query.*pattern/i);
|
|
expect(action).toContain(op);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("input issue argument guidance suppression", () => {
|
|
it("suppresses replacement guidance when expected argument is already present", () => {
|
|
const inputWithBoth = {
|
|
procedure: "main",
|
|
address: "0x401000",
|
|
comment: "entry comment",
|
|
};
|
|
const parsed = officialContract(
|
|
"annotate_native_function",
|
|
).inputSchema.safeParse(inputWithBoth);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const error = analysisInputErrorFromIssues(
|
|
"annotate_native_function",
|
|
parsed.error.issues,
|
|
inputWithBoth,
|
|
);
|
|
expect(error.issues).toEqual([
|
|
{ path: ["address"], reason: "unknown_argument" },
|
|
]);
|
|
const action = projectAnalysisError(error).remediation.action;
|
|
expect(action).not.toMatch(/procedure.*instead of.*address/i);
|
|
expect(action).toContain("annotate_native_function");
|
|
|
|
const budgeted = projectFlattenedIssueGroups(
|
|
parsed.error.issues,
|
|
inputWithBoth,
|
|
1024,
|
|
"annotate_native_function",
|
|
);
|
|
expect(budgeted).toEqual(error.issues);
|
|
|
|
const inputMissingExpected = {
|
|
address: "0x401000",
|
|
comment: "entry comment",
|
|
};
|
|
const parsedMissing = officialContract(
|
|
"annotate_native_function",
|
|
).inputSchema.safeParse(inputMissingExpected);
|
|
if (parsedMissing.success) throw new Error("expected invalid input");
|
|
const errorMissing = analysisInputErrorFromIssues(
|
|
"annotate_native_function",
|
|
parsedMissing.error.issues,
|
|
inputMissingExpected,
|
|
);
|
|
expect(errorMissing.issues).toContainEqual({
|
|
path: ["address"],
|
|
reason: "unknown_argument",
|
|
expected: "procedure",
|
|
message: expect.stringMatching(/address.*procedure/i),
|
|
});
|
|
const missingAction = projectAnalysisError(errorMissing).remediation.action;
|
|
expect(missingAction).toMatch(/procedure.*address/i);
|
|
});
|
|
});
|
|
|
|
describe("bounded input issue projection", () => {
|
|
it("bounds union expansion before constructing field diagnostics", () => {
|
|
const schema = z.object({
|
|
value: z.union([
|
|
z.object({ a: z.string(), b: z.number() }),
|
|
z.object({ a: z.number(), c: z.string() }),
|
|
]),
|
|
});
|
|
const input = { value: { a: false } };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
expect(
|
|
projectFlattenedIssueGroups(parsed.error.issues, input, 32_768),
|
|
).toEqual(projectInputIssues(parsed.error.issues, input));
|
|
expect(
|
|
projectFlattenedIssueGroups(parsed.error.issues, input, 300),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("checks a nested path before copying its components", () => {
|
|
const input = { value: {} };
|
|
const parsed = z
|
|
.object({
|
|
value: z.union([
|
|
z.object({ a: z.string() }),
|
|
z.object({ b: z.number() }),
|
|
]),
|
|
})
|
|
.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issue = parsed.error.issues[0];
|
|
if (issue?.code !== "invalid_union")
|
|
throw new Error("expected union issue");
|
|
const child = issue.errors[0]?.[0];
|
|
if (child === undefined) throw new Error("expected branch issue");
|
|
let copied = false;
|
|
const path = new Proxy(
|
|
Array.from({ length: 1000 }, () => "field"),
|
|
{
|
|
get(target, key, receiver) {
|
|
if (key === Symbol.iterator) copied = true;
|
|
return Reflect.get(target, key, receiver);
|
|
},
|
|
},
|
|
);
|
|
const largePathIssue: z.core.$ZodIssue = {
|
|
code: "invalid_union",
|
|
path: issue.path,
|
|
message: issue.message,
|
|
errors: [[{ ...child, path }]],
|
|
};
|
|
expect(
|
|
projectFlattenedIssueGroups([largePathIssue], input, 1024),
|
|
).toBeUndefined();
|
|
expect(copied).toBe(false);
|
|
});
|
|
|
|
it("accounts for the repeated path on every unknown-key issue", () => {
|
|
const issue: z.core.$ZodIssue = {
|
|
code: "unrecognized_keys",
|
|
path: Array.from({ length: 20 }, () => "nested"),
|
|
keys: Array.from({ length: 10 }, (_, index) => `key_${String(index)}`),
|
|
message: "Unrecognized keys",
|
|
};
|
|
expect(projectInputIssues([issue], {})).toHaveLength(10);
|
|
expect(projectFlattenedIssueGroups([issue], {}, 5000)).toBeUndefined();
|
|
const groups = projectInputIssueGroupsWithinBudget([issue], {}, 32_000);
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups?.[0]).toHaveLength(10);
|
|
});
|
|
|
|
it("bounds pre-allocation budget accounting for guided unrecognized keys", () => {
|
|
const issue: z.core.$ZodIssue = {
|
|
code: "unrecognized_keys",
|
|
path: [],
|
|
keys: ["address"],
|
|
message: "Unrecognized keys: address",
|
|
};
|
|
expect(projectFlattenedIssueGroups([issue], {}, 800)).toEqual([
|
|
{ path: ["address"], reason: "unknown_argument" },
|
|
]);
|
|
|
|
expect(
|
|
projectFlattenedIssueGroups(
|
|
[issue],
|
|
{},
|
|
800,
|
|
"read_function_instructions",
|
|
),
|
|
).toBeUndefined();
|
|
|
|
expect(
|
|
projectFlattenedIssueGroups(
|
|
[issue],
|
|
{},
|
|
2000,
|
|
"read_function_instructions",
|
|
),
|
|
).toEqual([
|
|
{
|
|
path: ["address"],
|
|
reason: "unknown_argument",
|
|
expected: "procedure",
|
|
message: expect.stringMatching(/address.*procedure/i),
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("discriminated union input issue projection", () => {
|
|
it("projects exact discriminator path and options when discriminated union has no errors", () => {
|
|
const issue: z.core.$ZodIssue = {
|
|
code: "invalid_union",
|
|
errors: [],
|
|
discriminator: "kind",
|
|
options: ["node-id", "route", "string"],
|
|
path: ["seed"],
|
|
message: "Invalid discriminator value",
|
|
};
|
|
const input = { seed: { kind: "node" } };
|
|
const projected = projectInputIssues([issue], input);
|
|
expect(projected).toEqual([
|
|
{
|
|
path: ["seed", "kind"],
|
|
reason: "invalid_value",
|
|
expected: ["node-id", "route", "string"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("narrows plain union branches when an object matches a candidate discriminator key", () => {
|
|
const schema = z.union([
|
|
z.strictObject({
|
|
kind: z.literal("node-id"),
|
|
node_id: z.string().optional(),
|
|
match: z.enum(["exact", "contains"]).default("exact"),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal("route"),
|
|
route: z.string(),
|
|
}),
|
|
]);
|
|
const input = { kind: "node-id", match: "invalid_match" };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const projected = projectInputIssues(parsed.error.issues, input);
|
|
// Should narrow to node-id branch only, ignoring missing 'route' from branch 2
|
|
expect(projected).toEqual([
|
|
{
|
|
path: ["match"],
|
|
reason: "invalid_value",
|
|
expected: ["exact", "contains"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports a single discriminator diagnostic with all union options when tag matches no branch", () => {
|
|
const schema = z.union([
|
|
z.strictObject({
|
|
kind: z.enum(["node-id", "route", "string"]),
|
|
value: z.string(),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal("api"),
|
|
endpoint: z.string(),
|
|
}),
|
|
]);
|
|
const input = { kind: "node", node_id: "jag_node_1" };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const projected = projectInputIssues(parsed.error.issues, input);
|
|
// Should NOT emit missing value, unknown node_id, or missing endpoint from non-matching branches
|
|
expect(projected).toEqual([
|
|
{
|
|
path: ["kind"],
|
|
reason: "invalid_value",
|
|
expected: ["node-id", "route", "string", "api"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("narrows branches when union includes a strict object variant without the discriminator key", () => {
|
|
const schema = z.union([
|
|
z.strictObject({
|
|
kind: z.literal("target"),
|
|
value: z.number(),
|
|
}),
|
|
z.strictObject({
|
|
unrelated: z.string(),
|
|
}),
|
|
]);
|
|
const input = { kind: "target", value: "not-a-number" };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const projected = projectInputIssues(parsed.error.issues, input);
|
|
expect(projected).toEqual([
|
|
{
|
|
path: ["value"],
|
|
reason: "invalid_type",
|
|
expected: "number",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("union branch diagnostic merging", () => {
|
|
it("merges disjoint expected sets at the identical path for homogeneous union failures", () => {
|
|
const peFieldSchema = z.enum(["name", "ordinal", "address", "forwarder"]);
|
|
const elfFieldSchema = z.enum([
|
|
"name",
|
|
"name_bytes_base64",
|
|
"value",
|
|
"size",
|
|
"type",
|
|
"binding",
|
|
]);
|
|
const schema = z.object({
|
|
fields: z.array(z.union([peFieldSchema, elfFieldSchema])),
|
|
});
|
|
const input = { fields: ["name", "symbol_type"] };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const projected = projectInputIssues(parsed.error.issues, input);
|
|
expect(projected).toEqual([
|
|
{
|
|
path: ["fields", 1],
|
|
reason: "invalid_value",
|
|
expected: [
|
|
"name",
|
|
"ordinal",
|
|
"address",
|
|
"forwarder",
|
|
"name_bytes_base64",
|
|
"value",
|
|
"size",
|
|
"type",
|
|
"binding",
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("merges scalar literal expected values at identical path", () => {
|
|
const schema = z.union([z.literal("alpha"), z.literal("beta")]);
|
|
const parsed = schema.safeParse("gamma");
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
expect(projectInputIssues(parsed.error.issues, "gamma")).toEqual([
|
|
{
|
|
path: [],
|
|
reason: "invalid_value",
|
|
expected: ["alpha", "beta"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves distinct diagnostic reasons at identical path", () => {
|
|
const schema = z.union([z.number(), z.literal("beta")]);
|
|
const parsed = schema.safeParse("gamma");
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
expect(projectInputIssues(parsed.error.issues, "gamma")).toEqual([
|
|
{
|
|
path: [],
|
|
reason: "invalid_type",
|
|
expected: "number",
|
|
},
|
|
{
|
|
path: [],
|
|
reason: "invalid_value",
|
|
expected: ["beta"],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
const testViewUnionSchema = z.union([
|
|
z.strictObject({
|
|
kind: z.literal("page"),
|
|
collection: z.enum(["sections", "symbols", "modules"]),
|
|
offset: z.number().int().default(0),
|
|
limit: z.number().int().default(64),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal("javascript"),
|
|
collection: z.literal("modules"),
|
|
projection: z.literal("references"),
|
|
offset: z.number().int().default(0),
|
|
limit: z.number().int().default(64),
|
|
}),
|
|
]);
|
|
|
|
describe("inspect_analysis_view cross-kind sub-selector guidance", () => {
|
|
it("guides caller toward kind 'page' when javascript kind uses collection 'modules' without projection", () => {
|
|
const input = {
|
|
source: {
|
|
kind: "retained-evidence",
|
|
evidence_id: `ev_${"a".repeat(64)}`,
|
|
},
|
|
view: {
|
|
kind: "javascript",
|
|
collection: "modules",
|
|
limit: 40,
|
|
offset: 0,
|
|
},
|
|
};
|
|
const schema = z.strictObject({
|
|
source: z.unknown(),
|
|
view: testViewUnionSchema,
|
|
});
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const projected = projectInputIssues(
|
|
parsed.error.issues,
|
|
input,
|
|
"inspect_analysis_view",
|
|
);
|
|
expect(projected).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ["view", "projection"],
|
|
reason: "invalid_value",
|
|
expected: ["references"],
|
|
message: expect.stringMatching(/page.*modules/i),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("omits guidance message when operation is not inspect_analysis_view", () => {
|
|
const input = {
|
|
view: {
|
|
kind: "javascript",
|
|
collection: "modules",
|
|
limit: 40,
|
|
offset: 0,
|
|
},
|
|
};
|
|
const schema = z.strictObject({ view: testViewUnionSchema });
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const projected = projectInputIssues(
|
|
parsed.error.issues,
|
|
input,
|
|
"other_operation",
|
|
);
|
|
expect(projected).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ["view", "projection"],
|
|
reason: "invalid_value",
|
|
expected: ["references"],
|
|
}),
|
|
);
|
|
expect(
|
|
projected.find((p) => p.path.join(".") === "view.projection")?.message,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("attaches guidance when wrapped in analysisOperationInputErrorFromIssues", () => {
|
|
const input = {
|
|
source: {
|
|
kind: "retained-evidence",
|
|
evidence_id: `ev_${"a".repeat(64)}`,
|
|
},
|
|
view: {
|
|
kind: "javascript",
|
|
collection: "modules",
|
|
limit: 40,
|
|
offset: 0,
|
|
},
|
|
};
|
|
const schema = z.strictObject({
|
|
operation: z.literal("inspect_analysis_view"),
|
|
input: z.strictObject({
|
|
source: z.unknown(),
|
|
view: testViewUnionSchema,
|
|
}),
|
|
});
|
|
const parsed = schema.safeParse({
|
|
operation: "inspect_analysis_view",
|
|
input,
|
|
});
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const error = analysisOperationInputErrorFromIssues(
|
|
"inspect_analysis_view",
|
|
parsed.error.issues,
|
|
input,
|
|
parsed.error,
|
|
);
|
|
expect(error.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ["view", "projection"],
|
|
reason: "invalid_value",
|
|
expected: ["references"],
|
|
message: expect.stringMatching(/page.*modules/i),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("discriminated union nested alternatives and budget", () => {
|
|
it("preserves valid discriminator options across nested alternatives before payload narrowing", () => {
|
|
const input = { kind: "node", value: "main", match: "exact" };
|
|
const parsed = applicationFeatureSeedSchema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toEqual([
|
|
{
|
|
path: ["kind"],
|
|
reason: "invalid_value",
|
|
expected: [
|
|
"node-id",
|
|
"route",
|
|
"string",
|
|
"channel",
|
|
"module",
|
|
"native-export",
|
|
"api",
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("retains missing field diagnostics alongside invalid enums when narrowed to one branch", () => {
|
|
const input = { kind: "node-id", match: "bogus" };
|
|
const parsed = applicationFeatureSeedSchema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: ["match"],
|
|
reason: "invalid_value",
|
|
}),
|
|
expect.objectContaining({
|
|
path: ["value"],
|
|
reason: "missing_argument",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("does not emit spurious sibling expected values when a nested alternative accepts a field", () => {
|
|
const input = { kind: "api", value: "foo", case_sensitive: "bad" };
|
|
const parsed = applicationFeatureSeedSchema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ["case_sensitive"],
|
|
reason: "invalid_type",
|
|
expected: "boolean",
|
|
}),
|
|
);
|
|
expect(issues.some((issue) => issue.path.join(".") === "value")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("enforces projection budget and bounds discriminator candidate key checks", () => {
|
|
const hugeInput: Record<string, unknown> = {
|
|
kind: "unknown_kind",
|
|
};
|
|
for (let i = 0; i < 500; i += 1) {
|
|
hugeInput[`extra_${String(i)}`] = `val_${String(i)}`;
|
|
}
|
|
const parsed = applicationFeatureSeedSchema.safeParse(hugeInput);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const bounded = projectInputIssuesBounded(
|
|
parsed.error.issues,
|
|
hugeInput,
|
|
40,
|
|
);
|
|
expect(bounded.omittedIssueCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("resolves setting discriminator before payload narrowing on analysis settings input", () => {
|
|
const input = {
|
|
changes: [{ setting: "bogus", action: "set", value: null }],
|
|
};
|
|
const parsed = updateAnalysisSettingsInputSchema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ["changes", 0, "setting"],
|
|
reason: "invalid_value",
|
|
expected: expect.arrayContaining([
|
|
"asar.max_header_bytes",
|
|
"ghidra.startup_timeout_ms",
|
|
"ghidra.decompile_timeout_seconds",
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves invalid_type reason when all union variants reject candidate key on type", () => {
|
|
const schema = z.union([
|
|
z.object({ kind: z.string(), foo: z.string() }),
|
|
z.object({ kind: z.number(), bar: z.string() }),
|
|
]);
|
|
const input = { kind: true };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ["kind"],
|
|
reason: "invalid_type",
|
|
}),
|
|
);
|
|
expect(issues.some((issue) => issue.reason === "invalid_value")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("union discriminator variant selection and constraint preservation", () => {
|
|
it("respects matched outer discriminator before secondary selector and retains selected branch errors", () => {
|
|
const schema = z.union([
|
|
z.strictObject({
|
|
kind: z.literal("a"),
|
|
mode: z.literal("fast"),
|
|
value: z.string(),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.literal("b"),
|
|
mode: z.literal("slow"),
|
|
count: z.number(),
|
|
}),
|
|
]);
|
|
const input = { kind: "a", mode: "bogus" };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: ["mode"],
|
|
reason: "invalid_value",
|
|
expected: ["fast"],
|
|
}),
|
|
expect.objectContaining({
|
|
path: ["value"],
|
|
reason: "missing_argument",
|
|
}),
|
|
]),
|
|
);
|
|
expect(issues.some((issue) => issue.path.join(".") === "count")).toBe(
|
|
false,
|
|
);
|
|
const modeIssue = issues.find((issue) => issue.path.join(".") === "mode");
|
|
expect(modeIssue?.expected).not.toContain("slow");
|
|
|
|
const bounded = projectInputIssuesBounded(parsed.error.issues, input, 8192);
|
|
expect(bounded.issues).toEqual(issues);
|
|
expect(bounded.omittedIssueCount).toBe(0);
|
|
});
|
|
|
|
it.each(["bogus", "fast"])(
|
|
"preserves non-value discriminator failures before secondary selector %s",
|
|
(mode) => {
|
|
const schema = z.union([
|
|
z.strictObject({
|
|
kind: z.enum(["a", "b"]),
|
|
foo: z.string(),
|
|
mode: z.literal("fast"),
|
|
}),
|
|
z.strictObject({
|
|
kind: z.string().min(5),
|
|
bar: z.string(),
|
|
mode: z.literal("slow"),
|
|
}),
|
|
]);
|
|
const input = { kind: "x", mode };
|
|
const parsed = schema.safeParse(input);
|
|
if (parsed.success) throw new Error("expected invalid input");
|
|
const issues = projectInputIssues(parsed.error.issues, input);
|
|
expect(issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: ["kind"],
|
|
reason: "invalid_value",
|
|
expected: ["a", "b"],
|
|
}),
|
|
expect.objectContaining({
|
|
path: ["kind"],
|
|
reason: "out_of_range",
|
|
minimum: 5,
|
|
}),
|
|
]),
|
|
);
|
|
const bounded = projectInputIssuesBounded(
|
|
parsed.error.issues,
|
|
input,
|
|
8192,
|
|
);
|
|
expect(bounded.issues).toEqual(issues);
|
|
expect(bounded.omittedIssueCount).toBe(0);
|
|
},
|
|
);
|
|
});
|