fix(navigation): reject malformed containing-procedure results

Move the unchanged resolution contract into its domain owner and use it when
building navigation context. Preserve provider failures instead of guessing
absence from error text or malformed result shapes. Return null only for an
explicit valid negative resolution.

Remove the obsolete error-message matcher and partial name parser. Cover four
malformed result shapes, original error identity, valid positive/negative facts
and the typed MCP unreadable-output response. Original source failed six cases.

Validation: navigation source/MCP and advertised schema checks passed. Compact
and full generated catalogs are byte-identical to the parent. Static, docs,
discovery and duplication checks passed.
This commit is contained in:
morluto
2026-10-24 04:08:30 +08:00
parent d69752fd52
commit b7ebccf8d3
6 changed files with 148 additions and 50 deletions
+99 -1
View File
@@ -106,7 +106,12 @@ describe("analysis context queries: navigation", () => {
ok(
createAnalysisExecution(
operation === "resolve_containing_procedure"
? { found: false, procedure: null, reason: "not_in_procedure" }
? {
query_address: "",
found: false,
procedure: null,
reason: "not_in_procedure",
}
: "",
provider,
),
@@ -139,6 +144,99 @@ describe("analysis context queries: navigation", () => {
});
});
describe("navigation procedure admission", () => {
it.each([
{ condition: "missing result", value: null },
{ condition: "missing discriminator", value: {} },
{
condition: "missing query identity",
value: { found: false, procedure: null, reason: "not_in_procedure" },
},
{
condition: "missing procedure facts",
value: {
query_address: "0x401000",
found: true,
procedure: { address: "0x401000", name: "main" },
},
},
])(
"rejects $condition instead of reporting no procedure",
async ({ value }) => {
const result = await getNavigationContext(
{
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "current_address" ? "0x401000" : value,
provider,
),
),
},
{ document: "fixture" },
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
operation: "resolve_containing_procedure",
},
});
},
);
it.each(["no procedure exists", "not in a procedure"])(
"preserves a provider failure containing %s",
async (message) => {
const failure = new AnalysisProtocolError(`Provider failed: ${message}`);
const result = await getNavigationContext(
{
execute: async (operation) =>
operation === "current_address"
? ok(createAnalysisExecution("0x401000", provider))
: err(failure),
},
{ document: "fixture" },
);
expect(result).toEqual(err(failure));
if (result.ok) throw new Error("expected original provider failure");
expect(result.error).toBe(failure);
},
);
it("returns a validated containing procedure name", async () => {
const result = await getNavigationContext(
{
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "current_address"
? "0x401000"
: {
query_address: "0x401000",
found: true,
procedure: {
address: "0x401000",
name: "main",
classification: null,
body: {
available: false,
reason: "Fixture has no body ranges",
},
},
},
provider,
),
),
},
{ document: "fixture" },
);
expect(result).toEqual(
ok({ document: "fixture", address: "0x401000", procedure: "main" }),
);
});
});
describe("analysis context queries: address facets", () => {
it("keeps unsupported address facets local and filters bookmarks", async () => {
const bookmarkInputs: Readonly<Record<string, unknown>>[] = [];
+17 -27
View File
@@ -6,7 +6,10 @@ import type {
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { analysisBookmarkSchema } from "../domain/hopperValues.js";
import {
analysisBookmarkSchema,
containingProcedureResolutionSchema,
} from "../domain/hopperValues.js";
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -66,18 +69,22 @@ export const getNavigationContext = async (
parameters(documentName, address.value.result),
executionOptions(signal),
);
if (!procedure.ok) {
if (!isMissingCurrentProcedure(procedure.error)) return procedure;
return ok({
document: documentName,
address: address.value.result,
procedure: null,
});
}
if (!procedure.ok) return procedure;
const resolution = containingProcedureResolutionSchema.safeParse(
procedure.value.result,
);
if (!resolution.success)
return err(
new AnalysisOutputError(
"resolve_containing_procedure",
"expected a containing-procedure resolution",
{ cause: resolution.error },
),
);
return ok({
document: documentName,
address: address.value.result,
procedure: containingProcedureName(procedure.value.result),
procedure: resolution.data.found ? resolution.data.procedure.name : null,
});
};
@@ -147,28 +154,11 @@ const facet = (
remediation: `Call ${operation} directly to inspect provider availability and diagnostics.`,
};
const isMissingCurrentProcedure = (error: AnalysisError): boolean =>
/(?:no procedure exists|not in a procedure)/iu.test(error.message);
const isRequestLevelFailure = (error: AnalysisError): boolean =>
error._tag === "AnalysisCancelledError" ||
error._tag === "AnalysisTimeoutError" ||
error._tag === "NoBinaryOpenError";
const containingProcedureName = (value: JsonValue): string | null => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
if (value.found !== true) return null;
const procedure = value.procedure;
if (
typeof procedure !== "object" ||
procedure === null ||
Array.isArray(procedure)
)
return null;
return typeof procedure.name === "string" ? procedure.name : null;
};
const resolvedAddress = (
result: Result<AnalysisExecution, AnalysisError> | undefined,
fallback: string,
+5 -3
View File
@@ -46,6 +46,7 @@ import {
referenceEdgeSchema,
unresolvedCallSchema,
procedureIdentitySchema,
containingProcedureResolutionSchema,
} from "../domain/hopperValues.js";
import { nativeApiInspectionResultSchema } from "../domain/native/nativeApiBoundary.js";
import {
@@ -84,7 +85,6 @@ import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestiga
import {
addressList,
addressedEntry,
containingProcedureResolution,
functionDossierOutput,
graphNode,
lifecycleResultOf,
@@ -204,7 +204,9 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
}),
),
procedure_pseudo_code: evidenceResultOf(nullableText),
resolve_containing_procedure: evidenceResultOf(containingProcedureResolution),
resolve_containing_procedure: evidenceResultOf(
containingProcedureResolutionSchema,
),
search_procedures: analysisDetailOutputSchemaOf(
z.array(z.object({ address: z.string(), value: z.string() })),
nativeInventorySummaryViewResultSchema.extend({
@@ -242,7 +244,7 @@ const literalTraceOutput = evidenceResultOf(
z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: containingProcedureResolution,
containing_procedure: containingProcedureResolutionSchema,
}),
),
truncated: z.boolean(),
+1 -18
View File
@@ -1,10 +1,7 @@
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import {
procedureIdentitySchema,
functionDossierSchema,
} from "../domain/hopperValues.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { analysisProfileSchema } from "../domain/analysisProfile.js";
import {
evidenceSchema,
@@ -554,20 +551,6 @@ export const addressedEntry = z.object({
address: z.string(),
name: z.string(),
});
export const containingProcedureResolution = z.discriminatedUnion("found", [
z.object({
query_address: z.string(),
found: z.literal(true),
procedure: procedureIdentitySchema,
}),
z.object({
query_address: z.string(),
found: z.literal(false),
procedure: z.null(),
reason: z.enum(["outside_segments", "not_in_procedure"]),
}),
]);
const unavailable = z.object({
available: z.literal(false),
reason: z.string(),
+17
View File
@@ -216,6 +216,23 @@ export const procedureIdentitySchema = z
"Function body entry membership disagrees with procedure identity",
});
});
/** An explicit provider result distinguishes a containing function from known absence. */
export const containingProcedureResolutionSchema = z.discriminatedUnion(
"found",
[
z.object({
query_address: z.string(),
found: z.literal(true),
procedure: procedureIdentitySchema,
}),
z.object({
query_address: z.string(),
found: z.literal(false),
procedure: z.null(),
reason: z.enum(["outside_segments", "not_in_procedure"]),
}),
],
);
export const localVariableSchema = z
.object({
description: z.string(),
@@ -45,8 +45,16 @@ it.each([
selection: { current_document: "fixture", current_address: null },
operation: "current_address",
},
{
selection: {
current_document: "fixture",
current_address: "0x401000",
resolve_containing_procedure: null,
},
operation: "resolve_containing_procedure",
},
])(
"delivers a typed MCP failure when $operation is not a string",
"delivers a typed MCP failure when $operation returns malformed output",
async ({ selection, operation }) => {
const result = await navigate(selection);
expect(JSON.stringify(result.content)).not.toContain(