diff --git a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md index 9caf17076e..34594c8bd3 100644 --- a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md @@ -169,7 +169,10 @@ leaving it, and `both` when the question needs both. Trace traversal includes containment relationships, so even a narrow seed can expand many nodes; a bidirectional trace can reach unrelated sibling modules. A module item or page is preferable when its observations already answer the question. Do not treat -all reached nodes as participants in the feature. +all reached nodes as participants in the feature. For a broad trace, pass +`view: {"kind": "feature-trace", "collection": "paths"}` (or `summary`) to +retain the complete trace and read terminal paths first; page `nodes` or `edges` +with exact `where.node_ids` through `inspect_analysis_view` as needed. Reuse retained parent Evidence when the server advertises it, or supply complete Evidence inline. Include Hopper or Ghidra Evidence only when its artifact digest diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index c3a975f842..83802e1c2b 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -173,7 +173,14 @@ Results follow retained node order and default to offset 0 and limit 64. Supply to examine relationships from that node. Other typed seeds do not search labels. The result contains the matching basis, complete reachable subgraph, terminal -paths, authority summaries, and native handoffs. A handoff binds +paths, authority summaries, and native handoffs. A bidirectional trace of a +large application can exceed one MCP response. Over MCP, pass +`view: {"kind":"summary"}` or a `feature-trace` page of `paths`, `nodes`, +`edges`, `seed_matches` or `native_handoffs` to retain the complete trace in +the session and return only selected rows with the trace's identity and +coverage. Page the retained trace with `inspect_analysis_view`; an oversized +complete reply offers its first `paths` page as a ready-to-call continuation. +See [MCP contracts](mcp-contracts.md) for filters. A handoff binds the exact native artifact digest and requested exports from the application graph. Trace direction selects the reachable addons; requested exports for each selected addon come from the complete supplied graph, so an diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index c643b88cd3..835795c2c9 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -318,6 +318,8 @@ offered for completed `analyze_function` results only after session recording is acknowledged; the complete result and its retention reference remain intact. Oversized native inventories likewise include a ready-to-call first `native-inventory` page. Reduce its limit or select fields when a row is large. +An oversized `trace_application_feature` result includes a first `feature-trace` +page of its retained terminal `paths`; page `nodes` or `edges` from there. A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting preserves the complete session without sending it through a single MCP frame. @@ -572,6 +574,32 @@ the working directory). Save its complete `--json` Evidence, then use `inspect-analysis-view` with an `evidence-file` source and the same view. Neither adapter executes the target or repeats analysis for retained queries. +`trace_application_feature` follows the same delivery contract. Omitting `view` +returns the complete trace. `view: {"kind":"summary"}` or a `feature-trace` page +retains the complete trace in the session first and returns only the selected +facts; query further pages with `inspect_analysis_view` and the returned +`parent_evidence_id`. The summary carries the trace identity (`trace_id`, +`source_evidence_id`, `source_graph_id`, seed and direction), the producer's +`summary` counts, `trace_coverage`, the traced subgraph's `graph_coverage`, +`evidence_links` and any no-match `semantic_discovery`. A `feature-trace` page +selects one `collection` in retained order and repeats that context: + +- `paths`: exact terminal paths; `where.node_ids` selects paths visiting any of + those nodes and `where.end_kinds` selects their terminal node kinds. +- `nodes`: compact rows with identity, `seed`, `observation_count` and distinct + authority/state/confidence `basis`; `where.node_ids` and `where.kinds` filter + exactly, and `include_observations: true` adds each node's complete retained + observations. +- `edges`: exact relationships; `where.node_ids` matches either endpoint and + `where.relations` the relation. +- `seed_matches` and `native_handoffs`: the producer's exact rows. + +Omitted filters select all; empty arrays select none. `offset` and `limit` +default to 0 and 64. Page `coverage` counts the selected collection only; it +never upgrades `trace_coverage`, and graph connectivity does not prove runtime +reachability. The CLI keeps complete trace JSON; save it and use +`inspect-analysis-view` with an `evidence-file` source. + Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also support summary and inventory views. A summary reports the manifest's expected counts separately from available record counts, observed entry kinds, inventory diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 0170604a00..e8bb3cfb27 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,6 +1,8 @@ import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; import { z } from "zod"; import { + featureTraceSummaryViewResultSchema, + featureTraceViewResultSchema, sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, } from "../domain/analysisView/analysisView.js"; @@ -13,7 +15,7 @@ import { compareApplicationVersionsRequestSchema, compareJavaScriptExportShapesRequestSchema, compareSourceToBundleRequestSchema, - traceApplicationFeatureRequestSchema, + traceApplicationFeatureToolRequestSchema, traceJavaScriptSemanticsRequestSchema, } from "./javascript/applicationWorkflowInputContracts.js"; import { reconstructionCoverageEvaluationInputSchema } from "../domain/reconstructionCoverageInput.js"; @@ -51,7 +53,13 @@ import { MACOS_APPLICATION_GRAPH_EXAMPLE, } from "./mobileApplicationGraphExamples.js"; -const traceOutputSchema = evidenceResultOf(applicationFeatureTraceResultSchema); +const traceOutputSchema = evidenceResultOf( + z.union([ + applicationFeatureTraceResultSchema, + featureTraceSummaryViewResultSchema, + featureTraceViewResultSchema, + ]), +); const semanticTraceOutputSchema = evidenceResultOf( javaScriptSemanticTraceResultSchema, ); @@ -131,9 +139,9 @@ export const APPLICATION_TOOL_CONTRACTS = [ name: "trace_application_feature", ...toolContractMetadata("trace_application_feature"), description: - "Trace a typed literal seed through every reachable part of an authenticated JavaScript Application Graph supplied as inline Evidence or exact same-session retained references. Original static, native, passive-runtime, inferred, and unknown authorities remain distinct; native addon handoffs never open a provider or execute the application.", + "Trace a typed literal seed through every reachable part of an authenticated JavaScript Application Graph supplied as inline Evidence or exact same-session retained references. Original static, native, passive-runtime, inferred, and unknown authorities remain distinct; native addon handoffs never open a provider or execute the application. An optional view returns selected facts and retains the complete trace.", kind: "application", - inputSchema: traceApplicationFeatureRequestSchema, + inputSchema: traceApplicationFeatureToolRequestSchema, outputSchema: traceOutputSchema, examples: [ { diff --git a/src/contracts/javascript/applicationWorkflowInputContracts.ts b/src/contracts/javascript/applicationWorkflowInputContracts.ts index 2bf4e8737b..08a4294584 100644 --- a/src/contracts/javascript/applicationWorkflowInputContracts.ts +++ b/src/contracts/javascript/applicationWorkflowInputContracts.ts @@ -3,6 +3,7 @@ import { localPathStringSchema, } from "../../domain/localPath.js"; import { sourceToBundleViewRequestSchema } from "../../domain/analysisView/sourceToBundleView.js"; +import { featureTraceViewRequestSchema } from "../../domain/analysisView/featureTraceView.js"; import { z } from "zod"; import { evidenceInputSchema } from "../../domain/evidenceReference.js"; @@ -25,6 +26,20 @@ export const traceApplicationFeatureRequestSchema = z.strictObject({ application: evidenceInputSchema, }); +/** MCP trace request; a selected view retains the complete trace and returns only those facts. */ +export const traceApplicationFeatureToolRequestSchema = + traceApplicationFeatureRequestSchema.extend({ + view: z + .union([ + z.strictObject({ kind: z.literal("summary") }), + featureTraceViewRequestSchema, + ]) + .optional() + .describe( + "Omitted returns the complete trace. summary or a feature-trace page retains the complete trace in this session first; query it again with inspect_analysis_view and the returned parent_evidence_id.", + ), + }); + /** Semantic trace request accepting inline or same-session application Evidence. */ export const traceJavaScriptSemanticsRequestSchema = traceJavaScriptSemanticsInputSchema.extend({ diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index d8219a65ea..cc69a3505f 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -17,6 +17,7 @@ import { import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js"; import { projectWebBundleView } from "./webBundleView.js"; import { projectSourceToBundleView } from "./sourceToBundleView.js"; +import { projectFeatureTraceView } from "./featureTraceView.js"; import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js"; import { projectModelArtifactView } from "./modelArtifactView.js"; import { projectCommandObservationView } from "./commandObservationView.js"; @@ -147,6 +148,8 @@ import { nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, + featureTraceSummaryViewResultSchema, + featureTraceViewResultSchema, analysisViewResultSchema, type AnalysisViewResult, type UnsignedAnalysisView, @@ -211,6 +214,8 @@ export { nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, + featureTraceSummaryViewResultSchema, + featureTraceViewResultSchema, analysisViewResultSchema, type AnalysisViewResult, type UnsignedAnalysisView, @@ -327,6 +332,7 @@ const retainedViewProjectors = new Map([ retain("inspect_gpu_code_objects", projectGpuCodeObjectsView), retain("match_native_functions", projectNativeFunctionMatchingView), retain("compare_source_to_bundle", projectSourceToBundleView), + retain("trace_application_feature", projectFeatureTraceView), retain("inspect_artifact", projectArtifactInventoryView), retain("inventory_artifact", projectArtifactInventoryView), retain("analyze_web_bundle", projectWebBundleView), diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 697b0ac9c8..8375b84dd9 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -10,6 +10,7 @@ import { nativeExportsViewRequestSchema } from "./nativeExportsView.js"; import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js"; import { webBundleViewRequestSchema } from "./webBundleView.js"; import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js"; +import { featureTraceViewRequestSchema } from "./featureTraceView.js"; import { gpuCodeObjectsViewRequestSchema } from "./gpuCodeObjectsView.js"; import { modelArtifactViewRequestSchema } from "../model/modelArtifactQuery.js"; import { rustBinaryViewRequestSchema } from "../rust/rustBinaryQuery.js"; @@ -66,6 +67,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ webBundleViewRequestSchema, nativeFunctionMatchingViewRequestSchema, sourceToBundleViewRequestSchema, + featureTraceViewRequestSchema, summaryViewSchema, goBinaryViewRequestSchema, rustBinaryViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index f7ee6a1aa1..7bd99d111b 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -63,6 +63,11 @@ import { sourceToBundleViewItemsSchema, sourceToBundleViewContextSchema, } from "./sourceToBundleView.js"; +import { + featureTraceSummarySchema, + featureTraceViewCollections, + featureTraceViewContextSchema, +} from "./featureTraceView.js"; import { gpuCodeObjectsViewRequestSchema, gpuCodeObjectsSummarySchema, @@ -946,6 +951,42 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({ items: sourceToBundleViewItemsSchema, }); +/** Compact retained feature-trace summary; pages carry the same trace context. */ +export const featureTraceSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + artifact: z.null(), + parent_operation: z.literal("trace_application_feature"), + summary: featureTraceSummarySchema, +}); +const featureTracePage = < + Collection extends keyof typeof featureTraceViewCollections, +>( + collection: Collection, +) => + z.strictObject({ + ...viewResultBase, + ...featureTraceViewContextSchema.shape, + kind: z.literal("feature-trace"), + artifact: z.null(), + parent_operation: z.literal("trace_application_feature"), + view: featureTraceViewCollections[collection].request, + items: featureTraceViewCollections[collection].rows, + }); +// As with Mach-O pages, the kind tag keeps dispatch while each collection is +// advertised with exactly its own rows. +export const featureTraceViewResultSchema = z + .object({ kind: z.literal("feature-trace") }) + .passthrough() + .pipe( + z.union([ + featureTracePage("paths"), + featureTracePage("nodes"), + featureTracePage("edges"), + featureTracePage("seed_matches"), + featureTracePage("native_handoffs"), + ]), + ); + /** Bind the existing nested facet selection to its producer-owned rows. */ const machoPage = (facet: Facet) => z.strictObject({ @@ -984,6 +1025,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ javaScriptRoutesViewResultSchema, webBundlePageViewResultSchema, sourceToBundlePageViewResultSchema, + featureTraceViewResultSchema, z.strictObject({ ...viewResultBase, ...nativeFunctionMatchingContextSchema.shape, @@ -1073,6 +1115,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, + featureTraceSummaryViewResultSchema, webBundleSummaryViewResultSchema, ]), z.strictObject({ diff --git a/src/domain/analysisView/featureTraceView.ts b/src/domain/analysisView/featureTraceView.ts new file mode 100644 index 0000000000..b9a848401d --- /dev/null +++ b/src/domain/analysisView/featureTraceView.ts @@ -0,0 +1,349 @@ +import { z } from "zod"; +import { prefixedDigestSchema } from "../digests.js"; +import { applicationCoverageSchema } from "../javascript/javascriptApplicationEvidenceSchemas.js"; +import { + applicationEdgeSchema, + applicationNodeSchema, + type ApplicationEdge, + type ApplicationNode, +} from "../javascript/javascriptApplicationGraphSchemas.js"; +import { + applicationFeatureTraceResultSchema, + type ApplicationFeatureTraceResult, +} from "../javascript/javascriptFeatureTraceSchemas.js"; +import { err, ok } from "../result.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + javascriptFactBasis, + javascriptFactBasisSchema, +} from "./javascriptModuleReferences.js"; +import { selectViewRows } from "./selectViewRows.js"; + +const OPERATION = "trace_application_feature" as const; +const trace = applicationFeatureTraceResultSchema.shape; + +const nodeIdsFilter = z + .array(prefixedDigestSchema("jag_node")) + .optional() + .describe( + "Exact trace node identities. Omitted selects all; [] selects none.", + ); +const page = { + kind: z.literal("feature-trace"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; + +const pathsRequestSchema = z.strictObject({ + ...page, + collection: z.literal("paths"), + where: z + .strictObject({ + node_ids: nodeIdsFilter.describe( + "Select terminal paths that visit any of these exact node identities. Omitted selects all; [] selects none.", + ), + end_kinds: z.array(applicationNodeSchema.shape.kind).optional(), + }) + .default({}), +}); +const nodesRequestSchema = z.strictObject({ + ...page, + collection: z.literal("nodes"), + where: z + .strictObject({ + node_ids: nodeIdsFilter, + kinds: z.array(applicationNodeSchema.shape.kind).optional(), + }) + .default({}), + include_observations: z + .boolean() + .default(false) + .describe( + "Opt into each selected node's complete retained observations, including properties and provenance.", + ), +}); +const edgesRequestSchema = z.strictObject({ + ...page, + collection: z.literal("edges"), + where: z + .strictObject({ + node_ids: nodeIdsFilter.describe( + "Select edges whose source or target is one of these exact node identities. Omitted selects all; [] selects none.", + ), + relations: z.array(applicationEdgeSchema.shape.relation).optional(), + }) + .default({}), +}); +const seedMatchesRequestSchema = z.strictObject({ + ...page, + collection: z.literal("seed_matches"), +}); +const handoffsRequestSchema = z.strictObject({ + ...page, + collection: z.literal("native_handoffs"), +}); + +/** Page one collection of a completed feature trace in its retained order. */ +export const featureTraceViewRequestSchema = z.discriminatedUnion( + "collection", + [ + pathsRequestSchema, + nodesRequestSchema, + edgesRequestSchema, + seedMatchesRequestSchema, + handoffsRequestSchema, + ], +); + +type FeatureTraceViewRequest = z.output; + +/** Compact node identity and support; full observations are opt-in. */ +const featureTraceNodeRowSchema = applicationNodeSchema + .pick({ node_id: true, kind: true, identity: true }) + .extend({ + seed: z + .boolean() + .describe("Whether this node matched the trace seed (a trace root)."), + observation_count: z.int().positive(), + basis: z.array(javascriptFactBasisSchema).min(1), + observations: applicationNodeSchema.shape.observations.exactOptional(), + }); + +/** + * Each collection's request bound to its own rows, so a reply cannot pair one + * collection with another's rows. Rows are the producer's exact records or the + * compact node projection. + */ +export const featureTraceViewCollections = { + paths: { request: pathsRequestSchema, rows: z.array(trace.paths.element) }, + nodes: { + request: nodesRequestSchema, + rows: z.array(featureTraceNodeRowSchema), + }, + edges: { request: edgesRequestSchema, rows: z.array(applicationEdgeSchema) }, + seed_matches: { + request: seedMatchesRequestSchema, + rows: z.array(trace.seed_matches.element), + }, + native_handoffs: { + request: handoffsRequestSchema, + rows: z.array(trace.native_handoffs.element), + }, +} as const; + +/** Trace identity and coverage repeated with every view so selected rows stay traceable. */ +export const featureTraceViewContextSchema = z.strictObject({ + trace_id: trace.trace_id, + source_evidence_id: trace.source_evidence_id, + source_graph_id: trace.source_graph_id, + seed: trace.seed, + direction: trace.direction, + summary: trace.summary, + trace_coverage: trace.coverage, + graph_coverage: applicationCoverageSchema + .nullable() + .describe("The traced subgraph's coverage, or null when no seed matched."), + evidence_links: trace.evidence_links, +}); + +/** Summary context adds the producer's already paged no-match discovery. */ +export const featureTraceSummarySchema = featureTraceViewContextSchema.extend({ + semantic_discovery: trace.semantic_discovery, + limitation_count: z.int().nonnegative(), +}); + +const checkedTrace = createAnalysisViewResultReader( + applicationFeatureTraceResultSchema, +); + +const selected = (values: readonly Value[] | undefined) => + values === undefined ? undefined : new Set(values); + +const traceContext = (result: ApplicationFeatureTraceResult) => ({ + trace_id: result.trace_id, + source_evidence_id: result.source_evidence_id, + source_graph_id: result.source_graph_id, + seed: result.seed, + direction: result.direction, + summary: result.summary, + trace_coverage: result.coverage, + graph_coverage: result.graph?.coverage ?? null, + evidence_links: result.evidence_links, +}); + +const traceUnknowns = (result: ApplicationFeatureTraceResult): string[] => [ + ...(result.coverage.status === "partial" + ? [ + `trace_coverage.status is partial because the source application graph is ${result.coverage.source_graph_status}; unmatched seeds and frontiers remain unknown.`, + ] + : []), + "Trace connectivity does not prove runtime execution or reachability. Exhausting a page establishes nothing outside this trace.", +]; + +const nodeRow = ( + node: ApplicationNode, + seeds: ReadonlySet, + includeObservations: boolean, +): z.output => ({ + node_id: node.node_id, + kind: node.kind, + identity: node.identity, + seed: seeds.has(node.node_id), + observation_count: node.observations.length, + basis: javascriptFactBasis(node.observations.map(({ evidence }) => evidence)), + ...(includeObservations ? { observations: node.observations } : {}), +}); + +/** One page per collection, built in its own branch so view and rows stay correlated. */ +const tracePage = ( + result: ApplicationFeatureTraceResult, + view: FeatureTraceViewRequest, + shared: Shared, +) => { + const pageOf = ( + selection: View, + { rows, total }: { readonly rows: Row[]; readonly total: number }, + ) => ({ + kind: "feature-trace" as const, + view: selection, + ...shared, + items: rows, + coverage: pageViewCoverage(selection.offset, rows.length, total), + }); + const all = () => true; + if (view.collection === "seed_matches") + return pageOf( + view, + selectViewRows(result.seed_matches, view, all, (row) => row), + ); + if (view.collection === "native_handoffs") + return pageOf( + view, + selectViewRows(result.native_handoffs, view, all, (row) => row), + ); + if (view.collection === "paths") { + const nodes = selected(view.where.node_ids); + const ends = selected(view.where.end_kinds); + return pageOf( + view, + selectViewRows( + result.paths, + view, + (path) => + (ends === undefined || ends.has(path.end_kind)) && + (nodes === undefined || path.node_ids.some((id) => nodes.has(id))), + (path) => path, + ), + ); + } + const graph = result.graph; + if (view.collection === "edges") { + const nodes = selected(view.where.node_ids); + const relations = selected(view.where.relations); + return pageOf( + view, + selectViewRows( + graph?.edges ?? [], + view, + (edge) => + (relations === undefined || relations.has(edge.relation)) && + (nodes === undefined || + nodes.has(edge.source_node_id) || + nodes.has(edge.target_node_id)), + (edge) => edge, + ), + ); + } + const ids = selected(view.where.node_ids); + const kinds = selected(view.where.kinds); + const seeds = new Set(result.seed_matches.map(({ node_id: id }) => id)); + return pageOf( + view, + selectViewRows( + graph?.nodes ?? [], + view, + (node) => + (ids === undefined || ids.has(node.node_id)) && + (kinds === undefined || kinds.has(node.kind)), + (node) => nodeRow(node, seeds, view.include_observations), + ), + ); +}; + +/** Select retained trace facts without re-running traversal or changing its coverage. */ +export const projectFeatureTraceView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, +) => { + const view = ownedView( + admitted, + "summary", + "feature-trace", + ); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Application feature traces support summary and feature-trace pages of paths, nodes, edges, seed_matches or native_handoffs.", + }, + ]), + ); + const parsed = checkedTrace(parent); + if (!parsed.ok) return parsed; + const result = parsed.value; + const limitations = [ + ...parent.limitations, + ...result.limitations, + ...(result.graph?.limitations ?? []), + ]; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: OPERATION, + parent_digest: parent.evidenceId.slice(3), + artifact: null, + limitations: boundedViewMessages( + limitations, + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + [], + parent.evidenceId, + "unknowns", + traceUnknowns(result), + ), + }; + const context = traceContext(result); + if (view.kind === "summary") + return ok({ + kind: "summary" as const, + view, + ...shared, + summary: { + ...context, + ...(result.semantic_discovery === undefined + ? {} + : { semantic_discovery: result.semantic_discovery }), + limitation_count: new Set(limitations).size, + }, + coverage: completeWithinViewCoverage(1, 1), + }); + return ok(tracePage(result, view, { ...shared, ...context })); +}; diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 46872962ec..6405275e88 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -10,7 +10,10 @@ import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSc import { err, ok, type Result } from "../result.js"; import { z } from "zod"; import type { JavaScriptModuleReference } from "./javascriptModuleReferences.js"; -import { javascriptModuleReferencesRequestSchema } from "./javascriptModuleReferences.js"; +import { + javascriptFactBasis, + javascriptModuleReferencesRequestSchema, +} from "./javascriptModuleReferences.js"; import { javascriptApplicationSourceCoverage, javascriptStaticSourceCoverageSummary, @@ -200,27 +203,21 @@ const moduleReference = ({ path, }: ModuleEntry): JavaScriptModuleReference => { let ranges = 0; - const support = new Map(); - for (const { evidence } of node.observations) { + for (const { evidence } of node.observations) if ( evidence.location.available && evidence.location.value.kind === "source-range" ) ranges += 1; - const { authority, state, confidence } = evidence; - support.set(`${authority}:${state}:${confidence}`, { - authority, - state, - confidence, - }); - } return { node_id: node.node_id, kind: node.kind, path, identity: node.identity, retained_source_range_count: ranges, - basis: [...support.values()], + basis: javascriptFactBasis( + node.observations.map(({ evidence }) => evidence), + ), }; }; diff --git a/src/domain/analysisView/javascriptModuleReferences.ts b/src/domain/analysisView/javascriptModuleReferences.ts index 475ca0d81e..e97f4b912c 100644 --- a/src/domain/analysisView/javascriptModuleReferences.ts +++ b/src/domain/analysisView/javascriptModuleReferences.ts @@ -27,12 +27,28 @@ export const javascriptModuleReferencesRequestSchema = z.strictObject({ }); /** Unique support states, without promoting mixed observations to one fact. */ -const basisSchema = z.strictObject({ +export const javascriptFactBasisSchema = z.strictObject({ authority: applicationGraphEvidenceSchema.shape.authority, state: applicationGraphEvidenceSchema.shape.state, confidence: applicationGraphEvidenceSchema.shape.confidence, }); +type JavaScriptFactBasis = z.infer; + +/** Distinct support states in first-observed order; mixed support stays mixed. */ +export const javascriptFactBasis = ( + evidence: Iterable, +): JavaScriptFactBasis[] => { + const support = new Map(); + for (const { authority, state, confidence } of evidence) + support.set(`${authority}:${state}:${confidence}`, { + authority, + state, + confidence, + }); + return [...support.values()]; +}; + /** Reusable facts rather than task-specific dependency interpretations. */ export const javascriptModuleReferenceSchema = z.strictObject({ node_id: prefixedDigestSchema("jag_node"), @@ -40,7 +56,7 @@ export const javascriptModuleReferenceSchema = z.strictObject({ path: z.string().nullable(), identity: applicationNodeIdentitySchema, retained_source_range_count: z.int().nonnegative(), - basis: z.array(basisSchema).min(1), + basis: z.array(javascriptFactBasisSchema).min(1), }); export type JavaScriptModuleReference = z.infer< diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index b31aacdc20..d92c620d87 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -88,6 +88,7 @@ export class ToolResultDelivery { maximumResponseBytes: number, private readonly nativeContinuation: ( evidence: Evidence, + selected?: JsonValue, ) => Readonly> = () => ({}), ) { this.maximumResponseBytes = maximumResponseBytes; @@ -132,6 +133,29 @@ export class ToolResultDelivery { }); } + /** + * Deliver a caller-selected view of an already retained parent. Overflow + * recovery names that parent and a smaller page of the same selection, + * since the view record itself is not an analysis-view parent. + */ + toSelectedViewToolResult( + view: Evidence, + selection: { readonly parent: Evidence; readonly request: JsonValue }, + contract: ToolContract, + recorded: Result | undefined, + ): CallToolResult { + return recorded !== undefined && !recorded.ok + ? this.toErrorToolResult(recorded.error, contract.returnsTargetContent) + : this.successResult(view, contract, { + evidence_id: view.evidence_id, + evidence_reference: { + kind: "retained-evidence", + evidence_id: selection.parent.evidence_id, + }, + ...this.nativeContinuation(selection.parent, selection.request), + }); + } + /** * Project an error without allocating oversized repeated MCP text. * `targetContent` is the failing tool's `returnsTargetContent`: a failure diff --git a/src/server/registerApplicationTools/compareSourceToBundle.ts b/src/server/registerApplicationTools/compareSourceToBundle.ts index 4dcbaf3a9c..9abc80aa06 100644 --- a/src/server/registerApplicationTools/compareSourceToBundle.ts +++ b/src/server/registerApplicationTools/compareSourceToBundle.ts @@ -1,19 +1,12 @@ -import { setImmediate } from "node:timers/promises"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; -import { ok } from "../../domain/result.js"; -import { - requireAnalysisViewRetention, - inspectAnalysisViewValidated, -} from "../../application/analysisView/AnalysisViewService.js"; +import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js"; import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { executeSourceToBundleComparison } from "../../application/javascript/JavaScriptApplicationWorkflowService.js"; import { toolContract } from "../../contracts/toolContracts.js"; import { logToolExecution } from "../toolLogging.js"; import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; -import { recordApplicationResult } from "./helpers.js"; +import { deliverApplicationEvidence } from "./helpers.js"; import type { ApplicationToolRegistration } from "./types.js"; const contract = toolContract("compare_source_to_bundle"); @@ -55,57 +48,17 @@ export const registerCompareSourceToBundleTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, contract); - const parent = result.value.evidence; - const comparison = result.value.result; - // Stage the projection against the owned parent before committing any session facts. - const selected = - input.view === undefined - ? ok(parent) - : inspectAnalysisViewValidated( - { - source: { - kind: "retained-evidence", - evidence_id: parent.evidence_id, - }, - view: input.view, - }, - (id) => - id === parent.evidence_id - ? parent - : options.evidenceById?.(id), - ); - await setImmediate(); - if (context.mcpReq.signal.aborted) - return server.delivery.toErrorToolResult( - new AnalysisCancelledError(contract.name, { - cause: context.mcpReq.signal.reason, - }), - contract.returnsTargetContent, - ); - if (!selected.ok) - return server.delivery.toCallToolResult(selected, contract); - const recorded = recordEvidenceSources( - options.recordEvidence, - result.value.sources, - ); - if (!recorded.ok) - return server.delivery.toCallToolResult(recorded, contract); - const saved = recordApplicationResult( + return deliverApplicationEvidence( + server, options, - parent, - comparison.summary.unknown > 0 + contract, + result.value, + input.view, + context.mcpReq.signal, + result.value.result.summary.unknown > 0 ? "source-to-bundle-comparison" : undefined, ); - if (saved !== undefined && !saved.ok) - return server.delivery.toCallToolResult(saved, contract); - return server.delivery.toEvidenceToolResult( - selected.value, - contract, - input.view === undefined - ? saved - : options.recordEvidence?.(selected.value), - ); }, ), ); diff --git a/src/server/registerApplicationTools/helpers.ts b/src/server/registerApplicationTools/helpers.ts index 9bf4f7fa8b..a0facab6a1 100644 --- a/src/server/registerApplicationTools/helpers.ts +++ b/src/server/registerApplicationTools/helpers.ts @@ -1,31 +1,97 @@ +import { setImmediate } from "node:timers/promises"; +import type { CallToolResult } from "@modelcontextprotocol/server"; import type { ToolResultDelivery } from "../ToolResultDelivery.js"; import { APPLICATION_TOOL_CONTRACTS } from "../../contracts/applicationToolContracts.js"; +import { inspectAnalysisViewValidated } from "../../application/analysisView/AnalysisViewService.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js"; import type { Evidence } from "../../domain/evidence.js"; +import { jsonValueSchema } from "../../domain/jsonValue.js"; +import { ok } from "../../domain/result.js"; import type { ApplicationToolRegistration } from "./types.js"; +type UnknownKind = + | "application-version-comparison" + | "javascript-export-shape" + | "source-to-bundle-comparison"; + export const recordResult = ( options: ApplicationToolRegistration & { readonly delivery: ToolResultDelivery; }, contract: (typeof APPLICATION_TOOL_CONTRACTS)[number], evidence: Evidence, - unknownKind?: - | "application-version-comparison" - | "javascript-export-shape" - | "source-to-bundle-comparison", + unknownKind?: UnknownKind, ) => { const recorded = recordApplicationResult(options, evidence, unknownKind); return options.delivery.toEvidenceToolResult(evidence, contract, recorded); }; +/** + * Stage an optional caller-selected view against the completed parent, then + * commit its sources and the parent and deliver. A request cancelled before + * commit leaves the session unchanged; a delivered view that does not fit + * names the retained parent and a smaller page instead of its own record. + */ +export const deliverApplicationEvidence = async ( + server: { readonly delivery: ToolResultDelivery }, + options: ApplicationToolRegistration, + contract: (typeof APPLICATION_TOOL_CONTRACTS)[number], + execution: { + readonly evidence: Evidence; + readonly sources: readonly Evidence[]; + }, + view: AnalysisViewRequest | undefined, + signal: AbortSignal, + unknownKind?: UnknownKind, +): Promise => { + const { delivery } = server; + const parent = execution.evidence; + const selected = + view === undefined + ? ok(parent) + : inspectAnalysisViewValidated( + { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view, + }, + (id) => + id === parent.evidence_id ? parent : options.evidenceById?.(id), + ); + await setImmediate(); + if (signal.aborted) + return delivery.toErrorToolResult( + new AnalysisCancelledError(contract.name, { cause: signal.reason }), + contract.returnsTargetContent, + ); + if (!selected.ok) return delivery.toCallToolResult(selected, contract); + const sources = recordEvidenceSources( + options.recordEvidence, + execution.sources, + ); + if (!sources.ok) return delivery.toCallToolResult(sources, contract); + const saved = recordApplicationResult(options, parent, unknownKind); + if (view === undefined) + return delivery.toEvidenceToolResult(parent, contract, saved); + if (saved !== undefined && !saved.ok) + return delivery.toCallToolResult(saved, contract); + return delivery.toSelectedViewToolResult( + selected.value, + { parent, request: jsonValueSchema.parse(view) }, + contract, + options.recordEvidence?.(selected.value), + ); +}; + /** Record the complete parent before choosing a delivery projection. */ export const recordApplicationResult = ( options: ApplicationToolRegistration, evidence: Evidence, - unknownKind?: - | "application-version-comparison" - | "javascript-export-shape" - | "source-to-bundle-comparison", + unknownKind?: UnknownKind, ) => unknownKind === undefined ? options.recordEvidence?.(evidence) @@ -34,13 +100,7 @@ export const recordApplicationResult = ( unknownRegistration(evidence, unknownKind), ); -const unknownRegistration = ( - evidence: Evidence, - kind: - | "application-version-comparison" - | "javascript-export-shape" - | "source-to-bundle-comparison", -) => ({ +const unknownRegistration = (evidence: Evidence, kind: UnknownKind) => ({ // Scope unknown identity to the comparison while keeping exact replays stable. question: `${ kind === "application-version-comparison" diff --git a/src/server/registerApplicationTools/traceFeature.ts b/src/server/registerApplicationTools/traceFeature.ts index bab24de1c0..b66c937f3f 100644 --- a/src/server/registerApplicationTools/traceFeature.ts +++ b/src/server/registerApplicationTools/traceFeature.ts @@ -1,12 +1,12 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; +import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js"; import { traceApplicationFeatureEvidence } from "../../application/javascript/JavaScriptApplicationWorkflowService.js"; import { toolContract } from "../../contracts/toolContracts.js"; import { logToolExecution } from "../toolLogging.js"; import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; -import { recordResult } from "./helpers.js"; +import { deliverApplicationEvidence } from "./helpers.js"; import type { ApplicationToolRegistration } from "./types.js"; const traceContract = toolContract("trace_application_feature"); @@ -19,13 +19,22 @@ export const registerTraceFeatureTool = ( server.registerTool( traceContract.name, toolRegistrationOptions(traceContract), - async (input, context) => + async ({ view, ...input }, context) => runAdmittedToolOperation( server, options.withAdmittedAnalysis, traceContract.name, context.mcpReq.signal, async () => { + // A selected view is meaningful only when the complete trace can be retained. + if (view !== undefined) { + const retention = requireAnalysisViewRetention( + traceContract.name, + options, + ); + if (!retention.ok) + return server.delivery.toCallToolResult(retention, traceContract); + } const result = await logToolExecution( options.logger, traceContract.name, @@ -38,16 +47,13 @@ export const registerTraceFeatureTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, traceContract); - const recorded = recordEvidenceSources( - options.recordEvidence, - result.value.sources, - ); - if (!recorded.ok) - return server.delivery.toCallToolResult(recorded, traceContract); - return recordResult( - { ...options, delivery: server.delivery }, + return deliverApplicationEvidence( + server, + options, traceContract, - result.value.evidence, + result.value, + view, + context.mcpReq.signal, ); }, ), diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index a8d904e24c..fee372ad37 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -16,18 +16,34 @@ export const createToolResultDelivery = ( retainedEvidenceContinuation, ); -/** Offer a named, schema-validated source page only for retained native analysis. */ +/** + * Halve a caller-selected page that did not fit. Null means no smaller page + * exists; undefined means the selection was not a page, so the owner's first + * page applies. + */ +const smallerPage = ( + selected: JsonValue | undefined, +): JsonValue | null | undefined => { + if ( + typeof selected !== "object" || + selected === null || + Array.isArray(selected) || + typeof selected.limit !== "number" + ) + return undefined; + return selected.limit > 1 + ? { ...selected, limit: Math.floor(selected.limit / 2) } + : null; +}; + +/** Offer a named, schema-validated page for retained analysis whose owner supports paging. */ const retainedEvidenceContinuation = ( evidence: Evidence, + selected?: JsonValue, ): Readonly> => { - const view = - evidence.operation === "analyze_function" - ? { kind: "native", facet: "pseudocode", offset: 0 } - : isNativeInventoryOperation(evidence.operation) - ? { kind: "native-inventory", offset: 0, limit: 64 } - : isNativeObservationOperation(evidence.operation) - ? { kind: "native-observation", offset: 0, limit: 64 } - : undefined; + const smaller = smallerPage(selected); + if (smaller === null) return {}; + const view = smaller ?? ownerFirstPage(evidence); if (view === undefined) return {}; const input = inspectAnalysisViewInputSchema.safeParse({ source: { kind: "retained-evidence", evidence_id: evidence.evidence_id }, @@ -41,3 +57,15 @@ const retainedEvidenceContinuation = ( }, }; }; + +/** The first page an owner offers when its complete retained result does not fit. */ +const ownerFirstPage = (evidence: Evidence): JsonValue | undefined => + evidence.operation === "analyze_function" + ? { kind: "native", facet: "pseudocode", offset: 0 } + : evidence.operation === "trace_application_feature" + ? { kind: "feature-trace", collection: "paths", offset: 0, limit: 64 } + : isNativeInventoryOperation(evidence.operation) + ? { kind: "native-inventory", offset: 0, limit: 64 } + : isNativeObservationOperation(evidence.operation) + ? { kind: "native-observation", offset: 0, limit: 64 } + : undefined; diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 9ae9548b14..c4357c379a 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -793,6 +793,7 @@ describe("application workflow CLI parity", () => { }); const root = await createTestTempDirectory("rea-application-cli-"); + await assertTraceFileView(root, traced); const comparisonPath = join(root, "comparison.json"); await writeFile( comparisonPath, @@ -1915,6 +1916,59 @@ const runCliWithRequestFile = async ( return runCli([command, requestPath, "--json"]); }; +/** The CLI keeps complete trace JSON; the shared view owner selects from the saved file. */ +const assertTraceFileView = async (root: string, traced: unknown) => { + const parent = parseEvidence(traced); + const tracePath = join(root, "trace.json"); + await writeFile(tracePath, JSON.stringify(parent)); + const source = { kind: "evidence-file", path: tracePath }; + expect( + await runCli([ + "inspect-analysis-view", + JSON.stringify({ source, view: { kind: "summary" } }), + "--json", + ]), + ).toMatchObject({ + normalized_result: { + kind: "summary", + parent_evidence_id: parent.evidence_id, + parent_operation: "trace_application_feature", + summary: { summary: { matched_seeds: 1, traced_nodes: 1 } }, + }, + }); + expect( + await runCli([ + "inspect-analysis-view", + JSON.stringify({ + source, + view: { kind: "feature-trace", collection: "nodes", limit: 1 }, + }), + "--json", + ]), + ).toMatchObject({ + normalized_result: { + kind: "feature-trace", + items: [{ seed: true, observation_count: 1 }], + coverage: { total: 1, exhausted: true }, + }, + }); + // Without a session ledger the CLI cannot retain a parent for a selected view. + expect( + await runCli( + [ + "trace-application-feature", + JSON.stringify({ + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + view: { kind: "summary" }, + }), + "--json", + ], + {}, + 1, + ), + ).toMatchObject({ code: "invalid_request" }); +}; + const assertSourceFileView = async (sourceCompared: unknown) => { const wrongApplication = await runCli( [ diff --git a/tests/boundary/mcp/featureTraceViewsMcp.test.ts b/tests/boundary/mcp/featureTraceViewsMcp.test.ts new file mode 100644 index 0000000000..7d3656115c --- /dev/null +++ b/tests/boundary/mcp/featureTraceViewsMcp.test.ts @@ -0,0 +1,540 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterAll, expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; +import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE } from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { deliverApplicationEvidence } from "../../../src/server/registerApplicationTools/helpers.js"; +import { traceApplicationFeatureEvidence } from "../../../src/application/javascript/JavaScriptApplicationWorkflowService.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +type Trace = z.output; +type Row = Record; + +const CHANNELS = ["save-0", "save-1", "save-2"]; + +/** A small Electron application whose IPC handlers reach storage and network endpoints. */ +const writeApplication = async (): Promise => { + const root = await createTestTempDirectory("rea-feature-trace-view-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ name: "trace-view", version: "1.0.0", main: "main.js" }), + ), + writeFile( + join(root, "main.js"), + [ + 'const {app,BrowserWindow,ipcMain}=require("electron");', + 'const fs=require("fs");', + 'const audio=require("./audio.node");', + 'const crypto=require("./crypto.node");', + ...CHANNELS.map( + (channel) => + `ipcMain.handle("${channel}",async(event,value)=>{fs.writeFileSync("/tmp/${channel}",value);return fetch("https://api.example.test/${channel}");});`, + ), + 'app.whenReady().then(()=>new BrowserWindow({webPreferences:{preload:"./preload.js"}}));', + ].join("\n"), + ), + writeFile( + join(root, "preload.js"), + `const {contextBridge,ipcRenderer}=require("electron"); +contextBridge.exposeInMainWorld("api",{${CHANNELS.map( + (channel, index) => + `save${String(index)}:(value)=>ipcRenderer.invoke("${channel}",value)`, + ).join(",")}});`, + ), + writeFile(join(root, "index.html"), "\n"), + // Distinct opaque addon bytes give two content-identified native handoffs. + writeFile(join(root, "audio.node"), Buffer.from("rea-audio-addon")), + writeFile(join(root, "crypto.node"), Buffer.from("rea-crypto-addon")), + ]); + return root; +}; + +const connect = async ( + profile: "full" | "compact", + maximumResponseBytes?: number, +) => { + const application = await analyzeJavaScriptApplication({ + input_path: await writeApplication(), + }); + if (!application.ok) throw application.error; + const session = createTestBinarySession(() => { + throw new Error("Feature trace views must not open a native provider"); + }); + const { client } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "feature-trace-view", version: "1" }, + schemaValidator: schemaValidation, + serverOptions: { + environment: { ...process.env, REA_MCP_INPUT_SCHEMA_PROFILE: profile }, + ...(maximumResponseBytes === undefined + ? {} + : { delivery: createToolResultDelivery(maximumResponseBytes) }), + }, + }); + expect(session.recordEvidence(application.value).ok).toBe(true); + const inputs = { + application: { + kind: "retained-evidence", + evidence_id: application.value.evidence_id, + }, + seed: { kind: "channel", value: "save-0", match: "exact" }, + direction: "both", + }; + const reply = async (name: string, args: Record) => + client.callTool({ name, arguments: args }); + const call = async (name: string, args: Record) => { + const result = await reply(name, args); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + return { evidence: parseEvidence(result.structuredContent), result }; + }; + return { client, session, inputs, reply, call }; +}; + +const traced = (evidence: Evidence): Trace => + applicationFeatureTraceResultSchema.parse(evidence.normalized_result); + +const bytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value)); + +type Harness = Awaited>; + +it.each(["full", "compact"] as const)( + "returns selected feature-trace facts and retains the complete trace (%s)", + async (profile) => { + const harness = await connect(profile); + const { client, session, inputs, call } = harness; + // Select first: the view call alone must retain the complete parent. + const summary = await call("trace_application_feature", { + ...inputs, + view: { kind: "summary" }, + }); + const parentId = z + .object({ parent_evidence_id: z.string() }) + .parse(summary.evidence.normalized_result).parent_evidence_id; + const retained = session.evidenceById(parentId); + const complete = await call("trace_application_feature", inputs); + const parent = complete.evidence; + expect(parent.evidence_id).toBe(parentId); + expect(retained).toEqual(parent); + const full = traced(parent); + const graph = full.graph!; + expect(full.paths.length).toBeGreaterThan(1); + expect(graph.edges.length).toBeGreaterThan(graph.nodes.length); + expect(summary.evidence.confidence).toBe(parent.confidence); + expect(summary.evidence.normalized_result).toMatchObject({ + kind: "summary", + parent_operation: "trace_application_feature", + artifact: null, + summary: { + trace_id: full.trace_id, + source_evidence_id: full.source_evidence_id, + seed: full.seed, + summary: full.summary, + trace_coverage: full.coverage, + graph_coverage: graph.coverage, + evidence_links: full.evidence_links, + }, + coverage: { status: "complete-within-view", exhausted: true }, + }); + expect(bytes(summary.result)).toBeLessThan(bytes(complete.result) / 10); + + const { tools } = await client.listTools(); + const ajv = schemaValidation.assertions; + const traceTool = tools.find( + ({ name }) => name === "trace_application_feature", + )!; + expect(ajv.validateSchema(traceTool.inputSchema)).toBe(true); + const outputSchema: Record = traceTool.outputSchema!; + expect(ajv.validateSchema(outputSchema)).toBe(true); + const request = { + kind: "feature-trace", + collection: "paths", + where: { end_kinds: ["endpoint"] }, + limit: 2, + }; + expect( + ajv.validate(traceTool.inputSchema, { ...inputs, view: request }), + ).toBe(true); + const selected = await call("trace_application_feature", { + ...inputs, + view: request, + }); + for (const evidence of [summary.evidence, selected.evidence]) + expect(ajv.validate(outputSchema, evidence)).toBe(true); + const endpointPaths = full.paths.filter( + ({ end_kind }) => end_kind === "endpoint", + ); + expect(endpointPaths.length).toBeGreaterThan(2); + expect(selected.evidence.normalized_result).toMatchObject({ + kind: "feature-trace", + parent_evidence_id: parent.evidence_id, + trace_id: full.trace_id, + trace_coverage: full.coverage, + items: endpointPaths.slice(0, 2), + coverage: { total: endpointPaths.length, next_offset: 2 }, + }); + // The advertised page contract binds each collection to its own rows. + const mixed = { + ...jsonObjectSchema.parse(selected.evidence.normalized_result), + items: graph.edges.slice(0, 1), + }; + expect(analysisViewResultSchema.safeParse(mixed).success).toBe(false); + expect( + ajv.validate(outputSchema, { + ...jsonObjectSchema.parse(selected.result.structuredContent), + normalized_result: mixed, + }), + ).toBe(false); + await assertRetainedTracePages(harness, parent.evidence_id, full); + }, +); + +/** Page the retained parent through inspect_analysis_view without re-running the trace. */ +const assertRetainedTracePages = async ( + { call, reply }: Harness, + parentId: string, + full: Trace, +) => { + const graph = full.graph!; + const source = { kind: "retained-evidence", evidence_id: parentId }; + const view = async (selection: Record) => { + const page = analysisViewResultSchema.parse( + (await call("inspect_analysis_view", { source, view: selection })) + .evidence.normalized_result, + ); + if (page.kind !== "feature-trace") throw new Error("Expected trace page"); + return page; + }; + // Successive small pages reproduce each complete collection in retained order. + const collect = async (selection: Record) => { + const rows: Row[] = []; + let offset: number | null = 0; + while (offset !== null) { + const page = await view({ + kind: "feature-trace", + ...selection, + offset, + limit: 7, + }); + rows.push(...(page.items as Row[])); + offset = page.coverage.next_offset; + } + return rows; + }; + const ids = (rows: readonly Row[] | readonly { node_id: string }[]) => + rows.map(({ node_id: id }) => id); + const seeds = new Set(ids(full.seed_matches)); + expect(await collect({ collection: "paths" })).toEqual(full.paths); + expect(await collect({ collection: "edges" })).toEqual(graph.edges); + expect(await collect({ collection: "seed_matches" })).toEqual( + full.seed_matches, + ); + expect(full.native_handoffs).toHaveLength(2); + expect(await collect({ collection: "native_handoffs" })).toEqual( + full.native_handoffs, + ); + for (const [offset, handoff] of full.native_handoffs.entries()) + expect( + await view({ + kind: "feature-trace", + collection: "native_handoffs", + offset, + limit: 1, + }), + ).toMatchObject({ + items: [handoff], + coverage: { + total: 2, + next_offset: offset === 0 ? 1 : null, + }, + }); + const detailed = await collect({ + collection: "nodes", + include_observations: true, + }); + expect(ids(detailed)).toEqual(ids(graph.nodes)); + expect(detailed.map(({ observations }) => observations)).toEqual( + graph.nodes.map(({ observations }) => observations), + ); + expect(ids(detailed.filter(({ seed }) => seed === true))).toEqual( + ids(graph.nodes.filter(({ node_id: id }) => seeds.has(id))), + ); + const compact = await view({ kind: "feature-trace", collection: "nodes" }); + expect(compact.items[0]).not.toHaveProperty("observations"); + expect(compact.items[0]).toMatchObject({ + node_id: graph.nodes[0]!.node_id, + observation_count: graph.nodes[0]!.observations.length, + }); + + const seed = full.seed_matches[0]!.node_id; + expect( + await collect({ collection: "edges", where: { node_ids: [seed] } }), + ).toEqual( + graph.edges.filter( + (edge) => edge.source_node_id === seed || edge.target_node_id === seed, + ), + ); + expect( + await collect({ collection: "paths", where: { node_ids: [seed] } }), + ).toEqual(full.paths.filter(({ node_ids: path }) => path.includes(seed))); + expect( + ids( + await collect({ collection: "nodes", where: { kinds: ["ipc-channel"] } }), + ), + ).toEqual(ids(graph.nodes.filter(({ kind }) => kind === "ipc-channel"))); + expect( + await collect({ collection: "edges", where: { relations: ["handles"] } }), + ).toEqual(graph.edges.filter(({ relation }) => relation === "handles")); + for (const selection of [ + { collection: "paths", where: { node_ids: [] } }, + { collection: "paths", where: { end_kinds: [] } }, + { collection: "nodes", where: { kinds: [] } }, + { collection: "edges", where: { relations: [] } }, + { collection: "paths", offset: full.paths.length }, + ]) + expect(await view({ kind: "feature-trace", ...selection })).toMatchObject({ + items: [], + coverage: { status: "empty", exhausted: true }, + trace_coverage: full.coverage, + }); + + // Views owned by other parents, and unknown filter values, are rejected rather than reinterpreted. + for (const selection of [ + { kind: "page", collection: "modules", offset: 0, limit: 1 }, + { + kind: "feature-trace", + collection: "nodes", + where: { kinds: ["not-a-kind"] }, + }, + { kind: "feature-trace", collection: "graph" }, + ]) + expect( + parseMcpToolError( + await reply("inspect_analysis_view", { source, view: selection }), + ), + ).toMatchObject({ error: { code: "invalid_request" } }); +}; + +it("keeps a no-match trace's discovery and empty collections explicit", async () => { + const { inputs, call } = await connect("full"); + const missing = { + ...inputs, + seed: { kind: "string", value: "absent-literal", match: "exact" }, + }; + const summary = await call("trace_application_feature", { + ...missing, + view: { kind: "summary" }, + }); + expect(summary.evidence.normalized_result).toMatchObject({ + summary: { + summary: { matched_seeds: 0, traced_nodes: 0 }, + trace_coverage: { status: "no-match" }, + graph_coverage: null, + semantic_discovery: { total: 0, next_offset: null }, + }, + }); + const nodes = await call("trace_application_feature", { + ...missing, + view: { kind: "feature-trace", collection: "nodes" }, + }); + expect(nodes.evidence.normalized_result).toMatchObject({ + items: [], + graph_coverage: null, + coverage: { status: "empty", total: 0, exhausted: true }, + }); +}); + +it("refuses a selected view before tracing when the server retains no Evidence", async () => { + const session = createTestBinarySession(() => { + throw new Error("No deep provider"); + }); + const server = createServer({ kind: "fixed", analysis: session }); + const client = new Client({ name: "feature-trace-fixed", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await client.close(); + await server.close(); + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + const refused = await client.callTool({ + name: "trace_application_feature", + arguments: { + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + view: { kind: "summary" }, + }, + }); + expect(parseMcpToolError(refused)).toMatchObject({ + error: { + code: "capability_unavailable", + message: expect.stringContaining("does not retain Evidence"), + }, + }); + const complete = await client.callTool({ + name: "trace_application_feature", + arguments: JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + }); + expect(complete.isError, JSON.stringify(complete)).not.toBe(true); + expect( + traced(parseEvidence(complete.structuredContent)).summary, + ).toMatchObject({ matched_seeds: 1 }); +}); + +it("recovers an oversized completed trace through its retained paths page", async () => { + const budget = 64 * 1024; + const { session, inputs, call, reply } = await connect("full", budget); + const failed = await reply("trace_application_feature", inputs); + const error = parseMcpToolError(failed).error; + expect(error.code).toBe("resource_constraint"); + expect(error.remediation?.action).toContain("inspect_analysis_view"); + const limits = z + .object({ details: z.object({ reported_limits: jsonObjectSchema }) }) + .parse(error).details.reported_limits; + const reference = z + .object({ kind: z.literal("retained-evidence"), evidence_id: z.string() }) + .parse(limits.evidence_reference); + const parent = parseEvidence(session.evidenceById(reference.evidence_id)); + const full = traced(parent); + expect(bytes(parent)).toBeGreaterThan(budget); + const continuation = z + .object({ + name: z.literal("inspect_analysis_view"), + arguments: toolContract("inspect_analysis_view").inputSchema, + }) + .parse(limits.continuation); + expect(continuation.arguments).toEqual({ + source: reference, + view: { + kind: "feature-trace", + collection: "paths", + where: {}, + offset: 0, + limit: 64, + }, + }); + const first = await call(continuation.name, continuation.arguments); + expect(first.evidence.normalized_result).toMatchObject({ + kind: "feature-trace", + parent_evidence_id: parent.evidence_id, + trace_id: full.trace_id, + summary: full.summary, + items: full.paths, + coverage: { total: full.paths.length, exhausted: true }, + }); + expect(bytes(first.result)).toBeLessThan(budget); + // A selected initial view also fits where the complete reply cannot. + const summary = await call("trace_application_feature", { + ...inputs, + view: { kind: "summary" }, + }); + expect(summary.evidence.normalized_result).toMatchObject({ + parent_evidence_id: parent.evidence_id, + }); +}); + +it("names the retained trace and a smaller page when a selected page is oversized", async () => { + const budget = 64 * 1024; + const { session, inputs, reply } = await connect("full", budget); + const selection = { + kind: "feature-trace", + collection: "nodes", + include_observations: true, + limit: 64, + }; + const failed = parseMcpToolError( + await reply("trace_application_feature", { ...inputs, view: selection }), + ).error; + expect(failed.code).toBe("resource_constraint"); + const limits = z + .object({ details: z.object({ reported_limits: jsonObjectSchema }) }) + .parse(failed).details.reported_limits; + const reference = z + .object({ kind: z.literal("retained-evidence"), evidence_id: z.string() }) + .parse(limits.evidence_reference); + // The reference names the retained trace, not the page record that did not fit. + const parent = parseEvidence(session.evidenceById(reference.evidence_id)); + expect(parent.operation).toBe("trace_application_feature"); + const nodes = traced(parent).graph!.nodes; + const continuation = z + .object({ + name: z.literal("inspect_analysis_view"), + arguments: toolContract("inspect_analysis_view").inputSchema, + }) + .parse(limits.continuation); + expect(continuation.arguments).toMatchObject({ + source: reference, + view: { ...selection, offset: 0, limit: 32 }, + }); + // The retained parent pages the same selection once the page fits. + const page = await reply(continuation.name, { + ...continuation.arguments, + view: { ...continuation.arguments.view, limit: 2 }, + }); + expect(page.isError, JSON.stringify(page).slice(0, 500)).not.toBe(true); + expect(bytes(page)).toBeLessThan(budget); + const view = analysisViewResultSchema.parse( + parseEvidence(page.structuredContent).normalized_result, + ); + if (view.kind !== "feature-trace") throw new Error("Expected trace page"); + expect((view.items as Row[]).map(({ node_id: id }) => id)).toEqual( + nodes.slice(0, 2).map(({ node_id: id }) => id), + ); +}); + +it("commits no session facts when cancelled after the trace completes", async () => { + const session = createTestBinarySession(() => { + throw new Error("No deep provider"); + }); + const traceExecution = traceApplicationFeatureEvidence( + JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + ); + if (!traceExecution.ok) throw traceExecution.error; + const artifactResources = new ArtifactResourceScope(); + onTestFinished(() => artifactResources.close()); + const before = session.exportEvidenceBundle(); + for (const view of [undefined, { kind: "summary" } as const]) { + const cancelled = new AbortController(); + cancelled.abort(new Error("client cancelled")); + // Cancellation can only arrive between completion and commit through this + // staging step, which the public workflow cannot schedule deterministically. + const delivered = await deliverApplicationEvidence( + { delivery: createToolResultDelivery(undefined) }, + { + artifactResources, + logger: silentLogger, + evidenceById: (id) => session.evidenceById(id), + recordEvidence: (evidence) => session.recordEvidence(evidence), + recordEvidenceWithUnknown: undefined, + }, + toolContract("trace_application_feature"), + traceExecution.value, + view, + cancelled.signal, + ); + // Same contract as compare_source_to_bundle: a cancelled request leaves the session unchanged. + expect(parseMcpToolError(delivered)).toMatchObject({ + error: { code: "cancelled" }, + }); + expect(session.exportEvidenceBundle()).toEqual(before); + } +});