From dd36b4a8a0c2c93216961f351856d54dd23e7156 Mon Sep 17 00:00:00 2001 From: Nianze Wu Date: Wed, 14 Oct 2026 04:00:21 +0800 Subject: [PATCH] feat(analysis-view): report observed query parameter names (#2982) --- .../references/evidence-workflows.md | 4 + docs/web-network-captures.md | 21 +++- .../analysisView/AnalysisViewService.ts | 5 +- .../WebNetworkApiSurfaceAccumulator.test.ts | 30 +++++ .../WebNetworkApiSurfaceAccumulator.ts | 65 ++++++++++- .../webNetworkApiSurfaceView.test.ts | 105 +++++++++++++++++- .../analysisView/webNetworkApiSurfaceView.ts | 24 +++- src/domain/browserObservation.ts | 3 +- src/domain/recordedTransportUrl.ts | 20 +++- tests/boundary/cli/analysisViewCli.test.ts | 50 +++++++++ tests/boundary/mcp/analysisViewMcp.test.ts | 51 +++++++++ 11 files changed, 368 insertions(+), 10 deletions(-) diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md index c651287f3..539bfb6b2 100644 --- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md +++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md @@ -65,3 +65,7 @@ Use `inspect_analysis_view` with `view: {kind: "api-surface"}` on retained method, origin and path template. Format matches are inferences, not handler identity or schemas. Group pages cite parent record ordinals; consult record coverage and exclusions before claiming completeness. No requests are replayed. +When the connected schema advertises `include_query_parameter_names`, select +it in the view for distinct observed names with original record ordinals. Query +values are omitted; the names establish neither required parameters nor a +complete server schema. Keep the original parent Evidence for exact URLs. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index c447c4783..2d5bdcae1 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -224,6 +224,22 @@ are 0 and 50. The view groups request lines by method, WHATWG URL origin and path template, sorted by origin, template and method. Query strings, fragments, credentials, headers and bodies are not copied into groups. +Select `include_query_parameter_names: true` in the view to include distinct +observed query names per group. The default and explicit `false` omit the +`query_parameter_names` field. Selected groups include an array of +`{name, record_ordinals}` entries, sorted by Unicode code point; each name's +ordinals follow the parent's selected record order and occur once per record. +For `/products?category=books&page=1` and +`/products?category=games&sort=price`, names are `category`, `page` and `sort`. +Query values and fragments are not copied into this inventory. + +Names use WHATWG `URLSearchParams` form-query decoding: literal `+` becomes +space, `%2B` becomes `+`, percent escapes decode as UTF-8, and empty names are +retained. Empty query pairs are ignored. Original URLs stay in parent Evidence; +name decoding does not normalize the recorded paths used for grouping. +The inventory describes observed names, without inferring required parameters, +value types or a complete API schema. Sensitive-parent refusals still apply. + | Rule | Exact segment format | Placeholder | | ---------- | ---------------------------------------------------- | ------------ | | `numeric` | ASCII digits only | `{numeric}` | @@ -269,13 +285,16 @@ and validation scratch by walking the parent without cloning it. Inline requests borrow the Evidence value through CLI/MCP envelope validation; the application admits the entire portable envelope before authenticated parsing or hashing. The envelope clone, hash scratch and capture clone stay charged during grouping. -Temporary URL/path/MIME work and retained state must +Temporary URL/path/MIME/query work and retained state must fit together within a 96 MiB projection representation budget. A whole group or merge is admitted before allocating its retained sets, map entries or array values. Fixed group storage, distinct text, exclusions and limitations count as well as matched segments. Per-record reservations are released after each record; repeated values do not accumulate another retained charge. Only the requested page is materialized as output groups. +Opt-in query projection reserves parser tuples and the per-record name set +before decoding, then admits distinct retained names and source ordinals before +changing a group. Repeated names within a request retain that ordinal once. The representation budget uses conservative storage and worst-case JSON-escaping allowances; it is not an enforced V8 heap or process-memory limit. It is separate diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index baaa6b7ca..f12033ee0 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -49,7 +49,10 @@ export const inspectAnalysisViewValidated = ( ): Result => { const accumulator = input.view.kind === "api-surface" - ? new WebNetworkApiSurfaceAccumulator() + ? new WebNetworkApiSurfaceAccumulator( + undefined, + input.view.include_query_parameter_names === true, + ) : undefined; if (input.source.kind === "inline" && accumulator !== undefined) { const admitted = accumulator.admitInlineEvidence(input.source.evidence); diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts index 410546543..d75a23b1a 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts @@ -26,6 +26,36 @@ const add = ( if (!result.ok) throw result.error; }; +it("bounds query parser expansion before publishing or merging observed names", () => { + const accumulator = new WebNetworkApiSurfaceAccumulator(64 * 1024, true); + add(accumulator, record(0, "/products?kept=1")); + const before = accumulator.page(0, 1); + expect( + accumulator.add(record(1, `/products?${"x=1&".repeat(200)}`)), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + expect(accumulator.page(0, 1)).toEqual(before); + add(accumulator, record(2, "/products?after=2")); + expect(accumulator.page(0, 1).groups[0]?.query_parameter_names).toEqual([ + { name: "after", record_ordinals: [2] }, + { name: "kept", record_ordinals: [0] }, + ]); +}); + +it("charges each retained name once and each name's ordinal once per request", () => { + const accumulator = new WebNetworkApiSurfaceAccumulator(1024 * 1024, true); + for (let ordinal = 0; ordinal < 1000; ordinal += 1) + add(accumulator, record(ordinal, "/products?category=one&category=two")); + expect(accumulator.page(0, 1).groups[0]?.query_parameter_names).toEqual([ + { + name: "category", + record_ordinals: Array.from({ length: 1000 }, (_, index) => index), + }, + ]); +}); + it("admits the capture schema clone and grouping against the same budget", () => { const sample = record(0, `/${"a".repeat(1200)}`); const isolated = new WebNetworkApiSurfaceAccumulator(64 * 1024); diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts index 0abc64675..16f3d8efb 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts @@ -5,7 +5,10 @@ import { } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; -import { parseRecordedTransportUrl } from "../recordedTransportUrl.js"; +import { + parseRecordedTransportUrl, + parseUrlQueryParameterNames, +} from "../recordedTransportUrl.js"; import { err, ok, type Result } from "../result.js"; import { MAX_JSON_DEPTH } from "../jsonValue.js"; import { @@ -41,6 +44,7 @@ interface GroupState { readonly ordinals: number[]; readonly statuses: Set; readonly contentTypes: Set; + readonly queryNames?: Map; } type Buckets = Map>>>; @@ -120,7 +124,10 @@ export class WebNetworkApiSurfaceAccumulator { #retainedBytes = 0; #temporaryBytes = 0; - constructor(readonly maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes) {} + constructor( + readonly maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes, + readonly includeQueryParameterNames = false, + ) {} #retain(bytes: number): boolean { if (this.#retainedBytes + this.#temporaryBytes + bytes > this.maximumBytes) @@ -305,7 +312,9 @@ export class WebNetworkApiSurfaceAccumulator { if (!this.#temporary(RECORD_SCRATCH_BYTES + textBytes(url.length))) return this.#overBudget(); try { - const parsed = parseRecordedTransportUrl(url); + const parsed = parseRecordedTransportUrl(url, { + includeQuery: this.includeQueryParameterNames, + }); if (parsed.origin === null) return this.#exclude(record.ordinal, "url-unparseable"); if (typeof method !== "string" || method.length === 0) @@ -325,6 +334,8 @@ export class WebNetworkApiSurfaceAccumulator { return this.#overBudget(); const route = this.#route(parsed.path); if (!route.ok) return route; + const queryNames = this.#queryNames(parsed.query); + if (!queryNames.ok) return queryNames; return this.#append( record.ordinal, method, @@ -332,6 +343,7 @@ export class WebNetworkApiSurfaceAccumulator { route.value, typeof status === "number" && status !== 0 ? status : null, mediaTypeEssence(rawMime), + queryNames.value, ); } finally { // Per-record strings and descriptors never become cumulative scratch. @@ -339,6 +351,26 @@ export class WebNetworkApiSurfaceAccumulator { } } + #queryNames( + query: string | undefined, + ): Result | undefined, AnalysisError> { + if (query === undefined) return ok(undefined); + // Reserve form-parser tuples, decoded strings, the iterator and the name + // set before URLSearchParams materializes them. Delimiter count is an upper + // bound: empty query pairs are ignored by the WHATWG parser. + let pairs = query.length > 1 ? 1 : 0; + for (const character of query) if (character === "&") pairs += 1; + if ( + !this.#temporary( + RECORD_SCRATCH_BYTES + + textBytes(query.length) + + pairs * ENTRY_BYTES * 4, + ) + ) + return this.#overBudget(); + return ok(new Set(parseUrlQueryParameterNames(query))); + } + #append( ordinal: number, method: string, @@ -346,6 +378,7 @@ export class WebNetworkApiSurfaceAccumulator { route: Route, status: number | null, mime: string | null, + queryNames: ReadonlySet | undefined, ): Result { let group = this.#buckets .get(method) @@ -371,6 +404,14 @@ export class WebNetworkApiSurfaceAccumulator { if (status !== null && !group?.statuses.has(status)) bytes += ENTRY_BYTES; if (mime !== null && !group?.contentTypes.has(mime)) bytes += ENTRY_BYTES + textBytes(mime.length); + if (queryNames !== undefined) { + if (group === undefined) bytes += CAPTURE_CONTAINER_BYTES; + for (const name of queryNames) { + bytes += ENTRY_BYTES; // This name's source ordinal, once per record. + if (!group?.queryNames?.has(name)) + bytes += ENTRY_BYTES + textBytes(name.length); + } + } // Reserve the whole mutation while route scratch is still live. No sets, // map entries, arrays or new observed values are retained before admission. if (!this.#retain(bytes)) return this.#overBudget(); @@ -387,6 +428,7 @@ export class WebNetworkApiSurfaceAccumulator { ordinals: [], statuses: new Set(), contentTypes: new Set(), + ...(queryNames === undefined ? {} : { queryNames: new Map() }), }; const byOrigin = this.#buckets.get(method) ?? new Map(); this.#buckets.set(method, byOrigin); @@ -404,6 +446,13 @@ export class WebNetworkApiSurfaceAccumulator { if (status === null || mime === null) this.#unavailableResponse = true; if (status !== null) group.statuses.add(status); if (mime !== null) group.contentTypes.add(mime); + if (queryNames !== undefined && group.queryNames !== undefined) { + for (const name of queryNames) { + const ordinals = group.queryNames.get(name); + if (ordinals === undefined) group.queryNames.set(name, [ordinal]); + else ordinals.push(ordinal); + } + } return ok(undefined); } @@ -440,6 +489,16 @@ export class WebNetworkApiSurfaceAccumulator { record_ordinals: [...group.ordinals], statuses: [...group.statuses].sort((a, b) => a - b), content_types: [...group.contentTypes].sort(compare), + ...(group.queryNames === undefined + ? {} + : { + query_parameter_names: [...group.queryNames] + .sort(([a], [b]) => compare(a, b)) + .map(([name, record_ordinals]) => ({ + name, + record_ordinals: [...record_ordinals], + })), + }), flags: [ ...(group.ordinals.length === 1 ? ["single-sample" as const] : []), ...(group.statuses.size > 1 ? ["mixed-statuses" as const] : []), diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts index b57d54b59..0c5e0b5bd 100644 --- a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts +++ b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts @@ -10,6 +10,7 @@ const project = ( offset = 0, limit = 50, parameters: unknown = { sensitive_value_count: 0 }, + includeQueryParameterNames?: boolean, ) => { const parent = analysisViewCaptureEvidence(capture); return projectAnalysisView( @@ -20,9 +21,111 @@ const project = ( limitations: [], parameters, }, - { kind: "api-surface", offset, limit }, + { + kind: "api-surface", + offset, + limit, + ...(includeQueryParameterNames === undefined + ? {} + : { + include_query_parameter_names: includeQueryParameterNames, + }), + }, ); }; + +it("selects query names without values, retains source ordinals and leaves default groups unchanged", () => { + const capture = analysisViewCaptureFixture(); + capture.records = capture.records.slice(0, 3); + capture.total_records = 3; + const urls = [ + "https://api.test/products?category=books&page=1&category=repeat#fragment-only", + "https://api.test/products?category=games&sort=price", + "https://api.test/health", + ]; + for (const [index, record] of capture.records.entries()) + record.reported = { + request: { method: "GET", url: urls[index]! }, + response: { status: 200, content: { mimeType: "application/json" } }, + }; + const selected = project(capture, 0, 50, { sensitive_value_count: 0 }, true); + const omitted = project(capture); + const disabled = project(capture, 0, 50, { sensitive_value_count: 0 }, false); + if ( + !selected.ok || + selected.value.kind !== "api-surface" || + !omitted.ok || + omitted.value.kind !== "api-surface" || + !disabled.ok || + disabled.value.kind !== "api-surface" + ) + throw new Error("Expected surfaces"); + expect(disabled.value.groups).toEqual(omitted.value.groups); + expect(selected.value.groups).toEqual([ + { ...omitted.value.groups[0], query_parameter_names: [] }, + { + ...omitted.value.groups[1], + query_parameter_names: [ + { name: "category", record_ordinals: [0, 1] }, + { name: "page", record_ordinals: [0] }, + { name: "sort", record_ordinals: [1] }, + ], + }, + ]); + for (const literal of ["books", "games", "repeat", "price", "fragment-only"]) + expect(JSON.stringify(selected.value)).not.toContain(literal); + expect(selected.value.parent_evidence_id).toBe( + omitted.value.parent_evidence_id, + ); +}); + +it("preserves decoded empty and Unicode query names, plus distinctions and caller ordinal order", () => { + const capture = analysisViewCaptureFixture(); + capture.records = [capture.records[3]!, capture.records[1]!]; + const urls = [ + "https://api.test/a/%2e%2e/products?b=1&a+b=2&a%2Bb=3&=4&%E9%9B%AA=5&b=6&&%FF=7&__proto__=8#ignored=9", + "https://api.test/a/%2e%2e/products?b=10", + ]; + capture.records.forEach((record, index) => { + record.reported = { request: { method: "GET", url: urls[index]! } }; + }); + const result = project(capture, 0, 1, { sensitive_value_count: 0 }, true); + if (!result.ok || result.value.kind !== "api-surface") + throw new Error("Expected surface"); + expect(result.value.groups[0]).toMatchObject({ + path_template: "/a/%2e%2e/products", + record_ordinals: [3, 1], + query_parameter_names: [ + { name: "", record_ordinals: [3] }, + { name: "__proto__", record_ordinals: [3] }, + { name: "a b", record_ordinals: [3] }, + { name: "a+b", record_ordinals: [3] }, + { name: "b", record_ordinals: [3, 1] }, + { name: "雪", record_ordinals: [3] }, + { name: "�", record_ordinals: [3] }, + ], + }); + expect(result.value.records.total).toBe(15); + expect(result.value.limitations.join(" ")).toContain("ordinal subset"); +}); + +it("preserves sensitive-parent refusal when query names are selected", () => { + expect( + project( + analysisViewCaptureFixture(), + 0, + 50, + { sensitive_value_count: 1 }, + true, + ), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + reason: "sensitive_values_unretained", + }, + }); +}); it("groups only exact formats, preserves literal routes, excludes URLs and produces deterministic digests", () => { const result = project(); if (!result.ok || result.value.kind !== "api-surface") diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.ts b/src/domain/analysisView/webNetworkApiSurfaceView.ts index a01a92d3f..142d151cb 100644 --- a/src/domain/analysisView/webNetworkApiSurfaceView.ts +++ b/src/domain/analysisView/webNetworkApiSurfaceView.ts @@ -15,6 +15,12 @@ export const apiSurfaceViewSchema = z.strictObject({ kind: z.literal("api-surface"), offset: z.number().int().nonnegative().default(0), limit: z.number().int().positive().default(50), + include_query_parameter_names: z + .boolean() + .optional() + .describe( + "Include distinct observed query parameter names and their original record ordinals; names do not establish required parameters or a complete API schema.", + ), }); const ruleSchema = z.enum(["numeric", "uuid", "hex", "iso-date"]); const groupSchema = z.strictObject({ @@ -32,6 +38,14 @@ const groupSchema = z.strictObject({ record_ordinals: z.array(z.number().int().nonnegative()), statuses: z.array(z.number()), content_types: z.array(z.string()), + query_parameter_names: z + .array( + z.strictObject({ + name: z.string(), + record_ordinals: z.array(z.number().int().nonnegative()), + }), + ) + .optional(), flags: z.array( z.enum(["single-sample", "mixed-statuses", "mixed-content-types"]), ), @@ -66,7 +80,10 @@ export const apiSurfaceFields = { export const projectWebNetworkApiSurfaceView = ( parent: AnalysisViewParent, view: AnalysisViewRequest, - accumulator = new WebNetworkApiSurfaceAccumulator(), + accumulator = new WebNetworkApiSurfaceAccumulator( + undefined, + view.kind === "api-surface" && view.include_query_parameter_names === true, + ), ): Result => { const unsupported = (reason: string) => err( @@ -111,6 +128,11 @@ export const projectWebNetworkApiSurfaceView = ( ...parent.limitations, ...capture.limitations, "Grouping does not establish handler identity, URL-space schemas or causality.", + ...(view.include_query_parameter_names === true + ? [ + "Observed query parameter names do not establish required parameters, value types or a complete API schema.", + ] + : []), ...(capture.records.length < capture.total_records ? [ "Parent contains an ordinal subset; the surface covers only retained records.", diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index e15265305..a1e669b26 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { urlDeclaresQueryOrFragment } from "./urlComponents.js"; +import { parseUrlQueryParameterNames } from "./recordedTransportUrl.js"; const parseExactOrigin = (value: string): string | undefined => { let url: URL; @@ -254,7 +255,7 @@ export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => { }; } const hadCredentials = parsed.username !== "" || parsed.password !== ""; - const names = [...parsed.searchParams.keys()]; + const names = [...parseUrlQueryParameterNames(parsed.search)]; const sanitizedUrl = hadCredentials ? removeUrlUserInfo(value, parsed) : value; diff --git a/src/domain/recordedTransportUrl.ts b/src/domain/recordedTransportUrl.ts index c524ae1a8..429c0f6ca 100644 --- a/src/domain/recordedTransportUrl.ts +++ b/src/domain/recordedTransportUrl.ts @@ -2,6 +2,8 @@ export interface RecordedTransportUrl { readonly origin: string | null; readonly path: string | null; + /** WHATWG query spelling, present only when explicitly selected for projection. */ + readonly query?: string; readonly credentials: | { readonly start: number; readonly end: number } | "ambiguous" @@ -15,6 +17,7 @@ export interface RecordedTransportUrl { */ export const parseRecordedTransportUrl = ( value: string, + options: { readonly includeQuery?: boolean } = {}, ): RecordedTransportUrl => { const prefix = /^[\x00-\x20]*(?:([A-Za-z][A-Za-z0-9+.-]*):)?\/\//u.exec( value, @@ -67,9 +70,22 @@ export const parseRecordedTransportUrl = ( (special && value[authorityEnd] === "\\") ) return { origin, path: null, credentials }; - if (value[authorityEnd] !== "/") return { origin, path: "/", credentials }; + const query = + options.includeQuery === true ? { query: absolute!.search } : {}; + if (value[authorityEnd] !== "/") + return { origin, path: "/", credentials, ...query }; let pathEnd = authorityEnd; while (pathEnd < value.length && !"?#".includes(value[pathEnd]!)) pathEnd += 1; - return { origin, path: value.slice(authorityEnd, pathEnd), credentials }; + return { + origin, + path: value.slice(authorityEnd, pathEnd), + credentials, + ...query, + }; }; + +/** WHATWG form-query name decoding; preserve order, repeats and empty names. */ +export const parseUrlQueryParameterNames = ( + query: string, +): IterableIterator => new URLSearchParams(query).keys(); diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 4e63358d7..d4b1f3222 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -20,6 +20,56 @@ import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture. import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; +cliTest( + "projects opted-in query names through compiled CLI without query values", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-query-names-cli-"); + const path = join(root, "view.json"); + const capture = analysisViewCaptureFixture(); + capture.records = capture.records.slice(0, 2); + capture.total_records = 2; + capture.records.forEach((record, index) => { + record.reported = { + request: { + method: "GET", + url: + index === 0 + ? "https://api.test/products?category=books&page=1&category=repeat" + : "https://api.test/products?category=games&sort=price#ignored", + }, + }; + }); + await writeFile( + path, + JSON.stringify({ + source: { + kind: "inline", + evidence: analysisViewCaptureEvidence(capture), + }, + view: { kind: "api-surface", include_query_parameter_names: true }, + }), + ); + const result = await cli.run({ + arguments: ["inspect-analysis-view", path, "--json"], + }); + expect(result.exitCode).toBe(0); + const evidence = parseEvidence(result.json); + expect(evidence.normalized_result).toMatchObject({ + groups: [ + { + query_parameter_names: [ + { name: "category", record_ordinals: [0, 1] }, + { name: "page", record_ordinals: [0] }, + { name: "sort", record_ordinals: [1] }, + ], + }, + ], + }); + for (const value of ["books", "games", "repeat", "price", "ignored"]) + expect(result.stdout).not.toContain(value); + }, +); + cliTest( "projects portable artifact inventory after the original ZIP is removed", async ({ cli }) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 120ed5f12..74815bc61 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -70,6 +70,57 @@ const connect = async ( return { client, session }; }; +it("projects opted-in query names identically from retained and portable MCP Evidence", async () => { + const { client, session } = await connect(); + const capture = analysisViewCaptureFixture(); + capture.records = capture.records.slice(0, 2); + capture.total_records = 2; + capture.records.forEach((record, index) => { + record.reported = { + request: { + method: "GET", + url: + index === 0 + ? "https://api.test/products?a+b=one&a%2Bb=two&=three&a+b=four" + : "https://api.test/products?a+b=five&snow=six#ignored", + }, + }; + }); + const parent = analysisViewCaptureEvidence(capture); + const retained = session.recordEvidence(parent); + if (!retained.ok) throw retained.error; + const outputs = []; + for (const source of [ + { kind: "inline", evidence: parent }, + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + ]) { + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "api-surface", include_query_parameter_names: true }, + }, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + parent_evidence_id: parent.evidence_id, + groups: [ + { + query_parameter_names: [ + { name: "", record_ordinals: [0] }, + { name: "a b", record_ordinals: [0, 1] }, + { name: "a+b", record_ordinals: [0] }, + { name: "snow", record_ordinals: [1] }, + ], + }, + ], + }); + outputs.push(evidence.normalized_result); + } + expect(outputs[0]).toEqual(outputs[1]); +}); + it("discovers an oversized native dossier by exact metadata and reads its retained view", async () => { const { client, session } = await connect(); const advertised = (await client.listTools()).tools.find(