diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 9a8d35d7f9..7ac0bf1f21 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -354,7 +354,9 @@ node and coverage is `unknown`. An empty call list with partial or unknown coverage is not proof that the function has no calls. `trace_call_path` reports each limited expansion in `residual_unknowns` and sets `truncated: true`; any path already supported by observed edges remains -visible. Tool errors remain in their existing error or failure fields. +visible. When a requested goal is not reached but an expansion is unresolved, +`goal_status` is `unknown`; `not_reached` means traversal exhausted without +unresolved relationship reads. Cancellation remains a tool error. REA prepares complete MCP results within the pinned stdio client's 10 MiB receive-buffer budget, including both text and structured representations and diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index 825c69f445..cc4ce41f47 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -4,10 +4,11 @@ import type { ExecutionOptions, } from "./AnalysisProvider.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { parseRelatedAddresses } from "../domain/hopperValues.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import { ok, type Result } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; import type { EnhancedResult } from "./EnhancedToolTypes.js"; import { resolveProcedureAddress } from "./ProcedureAddressResolution.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; @@ -91,24 +92,31 @@ export const traceCallPath = async ( ...(goal === undefined ? {} : { goal }), direction: input.direction, }; + if (isAborted(signal)) + return err(new AnalysisCancelledError("trace_call_path")); const state = createTraceState(resolvedInput); while ( state.queueIndex < state.queue.length && state.traversalPath.length === 0 ) { + if (isAborted(signal)) + return err(new AnalysisCancelledError("trace_call_path")); const address = state.queue[state.queueIndex++]; if (address === undefined) break; const current = state.visited.get(address); if (current === undefined) continue; - const found = await expandAddress(callRelationship, { + const expanded = await expandAddress(callRelationship, { input: resolvedInput, state, address, current, ...(signal === undefined ? {} : { signal }), }); - if (found) break; + if (!expanded.ok) return expanded; + if (expanded.value) break; } + if (isAborted(signal)) + return err(new AnalysisCancelledError("trace_call_path")); return ok(projectTrace(resolvedInput, state)); }; @@ -127,19 +135,28 @@ const createTraceState = (input: CallPathTraceInput): TraceState => ({ const expandAddress = async ( call: RelationshipCall, expansion: AddressExpansion, -): Promise => { +): Promise> => { const { input, state, address, current, signal } = expansion; const relation = input.direction === "forward" ? "callees" : "callers"; const tool = input.direction === "forward" ? "procedure_callees" : "procedure_callers"; const result = await call(tool, { procedure: address }, signal, "provider"); + if (!result.ok) { + if ( + result.error._tag === "AnalysisCancelledError" || + signal?.aborted === true + ) + return result; + } + if (isAborted(signal)) + return err(new AnalysisCancelledError("trace_call_path")); if (!result.ok) { state.failures.push({ address, error: projectAnalysisError(result.error), }); state.residual.add(`Call relationships were unavailable for ${address}.`); - return false; + return ok(false); } const related = parseRelatedAddresses(result.value.result, relation); if (!related.ok) { @@ -148,7 +165,7 @@ const expandAddress = async ( error: projectAnalysisError(related.error), }); state.residual.add(`Call relationships were unreadable for ${address}.`); - return false; + return ok(false); } for (const limitation of result.value.limitations) state.limitations.add(limitation); @@ -184,7 +201,7 @@ const expandAddress = async ( if (state.visited.has(relatedAddress)) { if (relatedAddress === input.goal) { state.traversalPath = path; - return true; + return ok(true); } continue; } @@ -194,11 +211,11 @@ const expandAddress = async ( }); if (relatedAddress === input.goal) { state.traversalPath = path; - return true; + return ok(true); } state.queue.push(relatedAddress); } - return false; + return ok(false); }; const recordEdge = ( @@ -228,7 +245,9 @@ const projectTrace = (input: CallPathTraceInput, state: TraceState) => ({ ? "not_requested" : state.traversalPath.length > 0 ? "reached" - : "not_reached", + : state.residual.size > 0 + ? "unknown" + : "not_reached", nodes: [...state.visited.entries()] .map(([address, { depth }]) => ({ address, depth })) .sort( @@ -264,3 +283,6 @@ const compareAddress = (left: string, right: string): number => const sortedUniqueAddresses = (addresses: readonly string[]): string[] => [...new Set(addresses)].sort(compareAddress); + +const isAborted = (signal: AbortSignal | undefined): boolean => + signal?.aborted === true; diff --git a/src/application/EnhancedToolSelectorOrigin.test.ts b/src/application/EnhancedToolSelectorOrigin.test.ts index 387d4ebfb7..48c21ea034 100644 --- a/src/application/EnhancedToolSelectorOrigin.test.ts +++ b/src/application/EnhancedToolSelectorOrigin.test.ts @@ -4,7 +4,11 @@ import { type AnalysisOperation, type AnalysisOperationPort, } from "./AnalysisProvider.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisProtocolError, +} from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; import { EnhancedTools } from "./EnhancedTools.js"; @@ -140,3 +144,118 @@ describe("selector origin of composed workflows", () => { ).toBe(false); }); }); + +describe("trace path completion and cancellation", () => { + it("keeps an unreachable goal unknown when provider coverage is unresolved", async () => { + const tools = new EnhancedTools({ + execute: async (operation, parameters) => { + if (operation === "procedure_address") + return ok(createAnalysisExecution(parameters.procedure, provider)); + if (operation === "procedure_callees") + return ok( + createAnalysisExecution([], provider, { + relationshipCoverage: { + status: "unknown", + reason: "the provider cannot confirm graph coverage", + }, + }), + ); + throw new Error(`Unexpected operation: ${operation}`); + }, + }); + + const result = await tools.execute("trace_call_path", { + start: "0x1000", + goal: "0x2000", + }); + + expect(result).toMatchObject({ + ok: true, + value: { goal_status: "unknown", truncated: true }, + }); + }); + + it.each([ + { abortSignal: false, errorKind: "cancelled" }, + { abortSignal: true, errorKind: "cancelled" }, + { abortSignal: true, errorKind: "protocol" }, + ])( + "preserves provider $errorKind failure and cleanup with request abort: $abortSignal", + async ({ abortSignal, errorKind }) => { + const cleanup = { + reason: "provider cleanup is still pending", + resources: ["provider session"], + }; + const providerFailure = + errorKind === "cancelled" + ? new AnalysisCancelledError("procedure_callees", { cleanup }) + : new AnalysisProtocolError("provider relationship read failed", { + cleanup, + }); + const controller = new AbortController(); + const tools = new EnhancedTools({ + execute: async (operation, parameters) => { + if (operation === "procedure_address") + return ok(createAnalysisExecution(parameters.procedure, provider)); + if (operation === "procedure_callees") { + if (abortSignal) controller.abort(); + return err(providerFailure); + } + throw new Error(`Unexpected operation: ${operation}`); + }, + }); + + const result = await tools.execute( + "trace_call_path", + { start: "0x1000", goal: "0x2000" }, + abortSignal ? controller.signal : undefined, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: + errorKind === "cancelled" + ? "AnalysisCancelledError" + : "AnalysisProtocolError", + cleanup, + }, + }); + if (result.ok) throw new Error("expected provider failure"); + expect(result.error).toBe(providerFailure); + }, + ); + + it("stops after cancellation races with a successful relationship response", async () => { + const controller = new AbortController(); + const calls: string[] = []; + const tools = new EnhancedTools({ + execute: async (operation, parameters) => { + calls.push(operation); + if (operation === "procedure_address") + return ok(createAnalysisExecution(parameters.procedure, provider)); + if (operation === "procedure_callees") { + controller.abort(); + return ok(createAnalysisExecution(["0x2000"], provider)); + } + throw new Error(`Unexpected operation: ${operation}`); + }, + }); + + const result = await tools.execute( + "trace_call_path", + { start: "0x1000", goal: "0x3000" }, + controller.signal, + ); + + expect(calls).toEqual([ + "procedure_address", + "procedure_address", + "procedure_callees", + ]); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + }); +}); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index a5d09ecc2a..5ea7ed70d7 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -591,6 +591,7 @@ export class EnhancedTools { ...(signal === undefined ? {} : { signal }), ...(selectorOrigin === undefined ? {} : { selectorOrigin }), }); + if (!execution.ok) return execution; if (isAborted(signal)) return err(new AnalysisCancelledError(name)); return execution; } diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index d045e9e1e2..e7956a7193 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -73,7 +73,7 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "trace_call_path", - "Resolve start and optional goal symbols or addresses to canonical procedure entries, then trace direct callers or callees until the graph is exhausted or the goal is reached. Results use canonical addresses; Evidence preserves supplied selectors. Returns visited nodes, direct-call edges, a shortest traversal path, provider failures, and residual unknowns; unresolved indirect calls remain unknown.", + "Resolve start and optional goal symbols or addresses to canonical procedure entries, then trace direct callers or callees until the graph is exhausted or the goal is reached. Results use canonical addresses; Evidence preserves supplied selectors. Returns visited nodes, direct-call edges, a shortest traversal path, provider failures, and residual unknowns. A requested goal is unknown when unresolved expansions prevent proving it absent; unresolved indirect calls remain unknown.", enhancedInputSchemas.trace_call_path, ), enhanced( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 3366be8ec7..1f5db4cc3e 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -220,7 +220,7 @@ const callPathTraceOutput = evidenceResultOf( start: z.string(), goal: z.string().nullable(), direction: z.enum(["forward", "backward"]), - goal_status: z.enum(["not_requested", "reached", "not_reached"]), + goal_status: z.enum(["not_requested", "reached", "not_reached", "unknown"]), nodes: z.array( z.object({ address: z.string(), diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index bf357a7229..fc9705696c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -56,7 +56,7 @@ export const inspectBundlerRegistration = ( ...runtimeValue.asyncChunkKeys, ...recovered.asyncChunkKeys, ]); - const registration = { + const registration: JavaScriptBundlerRegistration = { bundler: runtime.toLowerCase().includes("rspack") ? ("rspack" as const) : ("webpack" as const), @@ -64,7 +64,10 @@ export const inspectBundlerRegistration = ( chunk_keys: chunkKeys.values, unknown_chunk_keys: chunkKeys.unknown, runtime_require_name: runtimeValue.requireName, - runtime_module_cache_status: bundlerModuleCacheStatus(source), + // A name appearing in the source does not establish cache behavior or + // associate it with this registration. Until the analyzed runtime body + // proves cache reads and writes, preserve the honest unknown state. + runtime_module_cache_status: "not-observed", entry_module_keys: runtimeValue.entryModuleKeys, unknown_entry_module_keys: runtimeValue.unknownEntryModuleKeys, async_chunk_keys: asyncChunkKeys.values, @@ -350,12 +353,3 @@ const uniqueValues = (values: readonly string[], unknown = 0): StaticValues => { unknown, }; }; - -const bundlerModuleCacheStatus = ( - source: string, -): "observed" | "not-observed" => - /\b(?:__webpack_module_cache__|__rspack_module_cache__|installedModules)\b/u.test( - source, - ) - ? "observed" - : "not-observed"; diff --git a/src/ida/IdaOperationFailures.test.ts b/src/ida/IdaOperationFailures.test.ts index e863266d09..c638c30bb0 100644 --- a/src/ida/IdaOperationFailures.test.ts +++ b/src/ida/IdaOperationFailures.test.ts @@ -17,6 +17,112 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +describe("headless IDA partial operation evidence", () => { + it("retains completed rows when post-operation target identity changes", async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const producer = new RecordingIdaMcp(target, "headless"); + let healthReads = 0; + producer.overrideCall = (name) => { + if (name !== "server_health") return undefined; + healthReads += 1; + return healthReads === 2 + ? { + status: "ok", + input_path: "/different/target.elf", + idb_path: "/different/target.elf.i64", + module: "target.elf", + imagebase: "0x1000", + auto_analysis_ready: true, + } + : undefined; + }; + const client = new IdaSessionClient( + { + command: "fixture", + args: [], + env: {}, + mode: "headless", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + try { + const result = await client.execute("list_procedures", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("identity changed"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + status: "partial", + observations: [expect.objectContaining({ tool: "list_funcs" })], + target_binding: { + status: "not_verified", + reason: expect.stringContaining("identity changed"), + }, + metadata_after: { + input_path: "/different/target.elf", + idb_path: "/different/target.elf.i64", + }, + }, + raw_result: { + post_operation_identity_check: "started", + metadata_after: { + input_path: "/different/target.elf", + idb_path: "/different/target.elf.i64", + }, + }, + }); + } + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("retains completed replies when cancellation arrives after a reply", async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const producer = new RecordingIdaMcp(target, "headless"); + const controller = new AbortController(); + producer.beforeCall = async (name) => { + if (name === "list_funcs") controller.abort(); + }; + const client = new IdaSessionClient( + { + command: "fixture", + args: [], + env: {}, + mode: "headless", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + try { + const result = await client.execute( + "list_procedures", + {}, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + status: "partial", + observations: [expect.objectContaining({ tool: "list_funcs" })], + }, + }); + } + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); + describe("headless IDA operation failures", () => { it.each<{ tool: string; @@ -92,7 +198,28 @@ describe("headless IDA operation failures", () => { try { const result = await client.execute(operation, parameters); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.message).toContain(failure); + if (!result.ok) { + expect(result.error.message).toContain(failure); + expect(result.error.partialObservation).toMatchObject({ + provider: { id: "ida" }, + operation, + parameters, + normalized_result: { + status: "partial", + failed_operation: operation, + observations: expect.arrayContaining([ + expect.objectContaining({ tool }), + ]), + metadata_before: expect.any(Object), + }, + locations: expect.arrayContaining([ + { kind: "artifact-path", path: target.path }, + ]), + }); + expect( + projectAnalysisError(result.error).details?.partial_observation, + ).toMatchObject({ normalized_result: { status: "partial" } }); + } } finally { expect((await client.close()).ok).toBe(true); } @@ -168,7 +295,7 @@ describe("headless IDA procedure lookup failures", () => { expect( projected.details, "the operation and exactly one issue that names the procedure in its message", - ).toEqual({ + ).toMatchObject({ operation, issues: [ { diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 1d6aa599dc..0633d9f689 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -43,6 +43,7 @@ import { sameIdaHostPath, } from "./IdaWorkspace.js"; import { parseIdaInput } from "./IdaInput.js"; +import { createEvidence } from "../domain/evidence.js"; const legacyTools = [ "get_metadata", @@ -75,6 +76,14 @@ const isIdaOperation = (operation: string): operation is IdaOperation => const wasCancelled = (options: ExecutionOptions | undefined): boolean => options?.signal?.aborted === true; +interface IdaOperationFailureContext { + runner: IdaOperationRunner | undefined; + metadataBefore: JsonValue | undefined; + metadataAfter: JsonValue | undefined; + postOperationIdentityCheck: "not_attempted" | "started" | "verified"; + parameters: Readonly>; +} + /** A failed cleanup retains its private workspace and reports the remaining resource. */ class IdaCleanupError extends ProviderAdapterError { override readonly cleanupIncomplete = true; @@ -185,23 +194,34 @@ export class IdaSessionClient implements AnalysisClient { }, ]), ); + const failureContext: IdaOperationFailureContext = { + runner: undefined, + metadataBefore: undefined, + metadataAfter: undefined, + postOperationIdentityCheck: "not_attempted", + parameters, + }; try { await this.#start(); const before = await this.#observe(options); - if (wasCancelled(options)) - return err(new AnalysisCancelledError(operation)); + failureContext.metadataBefore = before; + if (wasCancelled(options)) throw new AnalysisCancelledError(operation); if (operation === "health") return this.#health(before); if (!isIdaOperation(operation)) throw new AnalysisProtocolError( "IDA operation changed after validation.", ); const runner = this.#runner(options); + failureContext.runner = runner; const result = await runner.run(operation, parameters); - if (wasCancelled(options)) - return err(new AnalysisCancelledError(operation)); - const after = await this.#observe(options); - if (wasCancelled(options)) - return err(new AnalysisCancelledError(operation)); + if (wasCancelled(options)) throw new AnalysisCancelledError(operation); + failureContext.postOperationIdentityCheck = "started"; + const after = await this.#observe( + options, + (observed) => (failureContext.metadataAfter = observed), + ); + failureContext.postOperationIdentityCheck = "verified"; + if (wasCancelled(options)) throw new AnalysisCancelledError(operation); return ok( createAnalysisExecution(result, IDA_PROVIDER_IDENTITY, { rawResult: { @@ -224,7 +244,7 @@ export class IdaSessionClient implements AnalysisClient { }), ); } catch (cause: unknown) { - return this.#failure(operation, cause, options); + return this.#failure(operation, cause, options, failureContext); } } @@ -268,20 +288,27 @@ export class IdaSessionClient implements AnalysisClient { operation: string, cause: unknown, options: ExecutionOptions | undefined, + context: IdaOperationFailureContext, ) { - if (wasCancelled(options)) - return err(new AnalysisCancelledError(operation)); - if (cause instanceof AnalysisError) return err(cause); - if (cause instanceof z.ZodError) - return err( - new AnalysisOutputError( - operation, - `IDA MCP returned malformed or unsupported producer data: ${cause.issues.map((issue) => `${issue.path.join(".") || "$"}: ${issue.message}`).join("; ")}`, - { cause }, - ), + let failure: AnalysisError; + if ( + cause instanceof AnalysisError && + (cause.partialObservation !== undefined || + cause.cleanupIncomplete || + cause.capturedOutput !== undefined) + ) + failure = cause; + else if (wasCancelled(options)) + failure = new AnalysisCancelledError(operation); + else if (cause instanceof AnalysisError) failure = cause; + else if (cause instanceof z.ZodError) + failure = new AnalysisOutputError( + operation, + `IDA MCP returned malformed or unsupported producer data: ${cause.issues.map((issue) => `${issue.path.join(".") || "$"}: ${issue.message}`).join("; ")}`, + { cause }, ); - return err( - new ProviderAdapterError("ida", operation, { + else + failure = new ProviderAdapterError("ida", operation, { cause, diagnostics: { reason: @@ -289,8 +316,68 @@ export class IdaSessionClient implements AnalysisClient { recovery: "Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.", }, - }), - ); + }); + // Only operation calls made after target identity/readiness validation can + // become partial Evidence. The failing call itself is absent from `raw` + // when its transport or MCP envelope could not be decoded. + const { runner, metadataBefore, metadataAfter } = context; + if ( + runner !== undefined && + runner.raw.length > 0 && + metadataBefore !== undefined + ) { + const observations = jsonValueSchema.parse(runner.raw); + const upstream = this.connection.serverInfo(); + const binding = + context.postOperationIdentityCheck !== "verified" + ? { + status: "not_verified", + reason: + cause instanceof Error + ? cause.message + : "Post-operation target identity was not observed.", + } + : { status: "verified" }; + failure.retainPartialObservation( + createEvidence(this.target, IDA_PROVIDER_IDENTITY, { + predicateType: "analysis-observation", + operation, + parameters: context.parameters, + result: { + status: "partial", + failed_operation: operation, + observations, + metadata_before: metadataBefore, + target_binding: binding, + ...(metadataAfter === undefined + ? {} + : { metadata_after: metadataAfter }), + }, + rawResult: { + upstream, + observations, + metadata_before: metadataBefore, + ...(metadataAfter === undefined + ? {} + : { metadata_after: metadataAfter }), + post_operation_identity_check: context.postOperationIdentityCheck, + }, + confidence: "observed", + authority: "external-service", + limitations: [ + ...IDA_LIMITATIONS, + ...runner.limitations, + "The requested IDA operation failed after these completed upstream replies; the partial Evidence does not establish a complete result.", + "Replies are bound to the selected target only by the pre-operation identity check unless post_operation_identity_check is verified.", + ], + locations: [ + { kind: "artifact-path", path: this.target.path }, + ...runner.locations, + ], + }), + ); + } + return err(failure); } #start(): Promise { @@ -356,11 +443,16 @@ export class IdaSessionClient implements AnalysisClient { } } - async #observe(options?: ExecutionOptions): Promise { + async #observe( + options?: ExecutionOptions, + onObserved?: (metadata: JsonValue) => void, + ): Promise { let identity: string; if (this.config.mode === "attached") { const raw = await this.connection.call("get_metadata", {}); const metadata = legacyMetadataSchema.parse(raw); + this.#metadata = raw; + onObserved?.(raw); if (metadata.sha256.toLowerCase() !== this.target.sha256.toLowerCase()) throw new AnalysisProtocolError( `The attached IDA input SHA-256 does not match the selected target ${this.target.path}; open that target in IDA or use headless mode.`, @@ -370,7 +462,6 @@ export class IdaSessionClient implements AnalysisClient { metadata.sha256.toLowerCase(), metadata.base, ]); - this.#metadata = raw; } else { if (this.#database === undefined || this.#workspace === undefined) throw new AnalysisProtocolError( @@ -382,6 +473,8 @@ export class IdaSessionClient implements AnalysisClient { signal: options?.signal, }); const metadata = modernHealthSchema.parse(raw); + this.#metadata = raw; + onObserved?.(raw); if ( !sameIdaHostPath(metadata.input_path, this.#workspace.inputPath) || (await idaFileDigest(this.#workspace.inputPath)) !== this.target.sha256 @@ -399,7 +492,6 @@ export class IdaSessionClient implements AnalysisClient { metadata.idb_path, metadata.imagebase, ]); - this.#metadata = raw; } if ( this.#initialIdentity !== undefined && diff --git a/tests/boundary/filesystem/javascript-applications/javascriptBundlerAdapters.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptBundlerAdapters.test.ts index f5b4250723..f20eaac1fb 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptBundlerAdapters.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptBundlerAdapters.test.ts @@ -42,7 +42,7 @@ describe("Webpack and Rspack runtime adapters", () => { expect(webpackEntry?.observations[0]?.properties).toMatchObject({ bundler: "webpack", runtime_require_name: "__webpack_require__", - runtime_module_cache_status: "observed", + runtime_module_cache_status: "not-observed", entry_module_keys: ["10"], async_chunk_keys: ["lazy", "missing"], unknown_async_chunk_keys: 1, @@ -55,6 +55,7 @@ describe("Webpack and Rspack runtime adapters", () => { expect(rspackEntry?.observations[0]?.properties).toMatchObject({ bundler: "rspack", runtime_require_name: "r", + runtime_module_cache_status: "not-observed", entry_module_keys: ["src/entry.ts"], async_chunk_keys: ["model"], }); @@ -156,7 +157,8 @@ const bundlerFixture = async (): Promise => { const root = await createTestTempDirectory("rea-bundler-adapters-"); await writeFixtureFiles(root, { "renderer/chunks/runtime.js": ` - var __webpack_module_cache__ = {}; + // A cache-like name here is not evidence about either registration. + const documentation = "__webpack_module_cache__"; (globalThis["webpackChunkcustomPortal"] = globalThis["webpackChunkcustomPortal"] || []).push([ ["main"], { @@ -179,7 +181,11 @@ const bundlerFixture = async (): Promise => { (globalThis["webpackChunkcustomPortal"] = globalThis["webpackChunkcustomPortal"] || []).push([ ["lazy"], { - 20: (module) => { module.exports = "lazy"; } + 20: (module) => { + // A marker in a different registration must not be inherited. + const documentation = "installedModules"; + module.exports = "lazy"; + } } ]); (self.rspackChunkcustomPortal = self.rspackChunkcustomPortal || []).push([ diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index c13ea3c56b..f4915afd13 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -5,6 +5,7 @@ import { createAnalysisExecution } from "../../../src/application/AnalysisProvid import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; import { AnalysisCapabilityUnavailableError, + AnalysisCancelledError, AnalysisOutputError, } from "../../../src/domain/analysisErrorCore.js"; import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; @@ -178,6 +179,125 @@ describe("enhanced MCP tools", () => { }); }); +describe("trace_call_path MCP outcome semantics", () => { + it("reports an unreachable goal as unknown when relationship coverage is partial", async () => { + const client = await connect({ + execute: async (name, arguments_) => { + if (name === "procedure_address") + return relationshipExecution(String(arguments_.procedure)); + if (name !== "procedure_callees") + throw new Error(`Unexpected operation: ${name}`); + return relationshipExecution([], [], { + status: "partial", + limitations: ["one or more callees may be missing"], + }); + }, + }); + + const trace = await client.callTool({ + name: "trace_call_path", + arguments: { start: "0x1", goal: "0x2" }, + }); + expect(trace.isError).not.toBe(true); + expect(trace.structuredContent).toMatchObject({ + normalized_result: { + goal_status: "unknown", + truncated: true, + relationship_coverage: { "0x1": { status: "partial" } }, + }, + }); + }); + + it("reports an unreachable goal as unknown when an expansion fails", async () => { + const client = await connect({ + execute: async (name, arguments_) => { + if (name === "procedure_address") + return relationshipExecution(String(arguments_.procedure)); + if (name !== "procedure_callees") + throw new Error(`Unexpected operation: ${name}`); + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + name, + "relationship read unavailable", + ), + ); + }, + }); + + const trace = await client.callTool({ + name: "trace_call_path", + arguments: { start: "0x1", goal: "0x2" }, + }); + expect(trace.isError).not.toBe(true); + expect(trace.structuredContent).toMatchObject({ + normalized_result: { + goal_status: "unknown", + failures: [{ address: "0x1" }], + truncated: true, + }, + }); + }); + + it("reports an unreachable goal as absent after complete traversal", async () => { + const client = await connect({ + execute: async (name, arguments_) => { + if (name === "procedure_address") + return relationshipExecution(String(arguments_.procedure)); + if (name !== "procedure_callees") + throw new Error(`Unexpected operation: ${name}`); + return relationshipExecution([], [], { status: "complete" }); + }, + }); + + const trace = await client.callTool({ + name: "trace_call_path", + arguments: { start: "0x1", goal: "0x2" }, + }); + expect(trace.isError).not.toBe(true); + expect(trace.structuredContent).toMatchObject({ + normalized_result: { + goal_status: "not_reached", + truncated: false, + residual_unknowns: [], + }, + }); + }); + + it("propagates cancellation during expansion without visiting another frontier", async () => { + const controller = new AbortController(); + const calls: string[] = []; + const client = await connect({ + execute: async (name, arguments_, options) => { + calls.push(name); + if (name === "procedure_address") + return relationshipExecution(String(arguments_.procedure)); + if (name !== "procedure_callees") + throw new Error(`Unexpected operation: ${name}`); + controller.abort(); + if (options?.signal?.aborted !== true) + throw new Error("provider request was not cancelled"); + return err(new AnalysisCancelledError(name)); + }, + }); + + await expect( + client.callTool( + { + name: "trace_call_path", + arguments: { start: "0x1", goal: "0x3" }, + }, + { signal: controller.signal }, + ), + ).rejects.toThrow(); + expect(calls).toEqual([ + "procedure_address", + "procedure_address", + "procedure_callees", + ]); + }); +}); + describe("enhanced MCP batches", () => { it("returns every batch item in caller order with its own result", async () => { const addresses = Array.from( diff --git a/tests/boundary/providers/ida/calleeRelationships.test.ts b/tests/boundary/providers/ida/calleeRelationships.test.ts index 9d8ed638d5..244eb21e5a 100644 --- a/tests/boundary/providers/ida/calleeRelationships.test.ts +++ b/tests/boundary/providers/ida/calleeRelationships.test.ts @@ -209,7 +209,7 @@ it("preserves missing callgraph coverage through direct, graph, and trace workfl }); if (!trace.ok) throw trace.error; expect(trace.value).toMatchObject({ - goal_status: "not_reached", + goal_status: "unknown", edges: [], limitations: expect.arrayContaining([ expect.stringContaining("callgraph was unavailable"),