From 400a91b5f91b2fcfeb98f74e925db9b9b8f11b28 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:43:01 +0800 Subject: [PATCH] fix(cli)!: retain repeated WASM input paths (#9624) BREAKING CHANGE: caller_input_paths values may now be ordered arrays of strings as well as strings. Clients must accept both forms. --- docs/getting-started/cli.md | 4 +- src/cli/analysisEvidenceDelivery.ts | 3 +- src/cli/cliEvidenceCallerPath.ts | 6 ++- src/cli/wasmCommands.ts | 11 +++++ src/domain/evidence.test.ts | 11 ++++- src/domain/evidence.ts | 10 ++-- .../cli/wasmAcquiredInputsCli.test.ts | 46 +++++++++++++++++-- 7 files changed, 79 insertions(+), 12 deletions(-) diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index 8d84f60cfe..dfcd93de79 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -633,7 +633,9 @@ including relative spelling and symlink aliases. Its subject identifies the inspected artifact; `caller_input_path` does not change the Evidence ID or snapshot query. A replay reports the current invocation's path while leaving the saved Evidence unchanged. Provider inspections continue to include the -established `parameters.input_path` field. +established `parameters.input_path` field. Named auxiliary inputs use +`caller_input_paths`; each value is a string or an ordered array of strings +for repeated selections. Consumers must accept both forms. Importing an Evidence bundle refreshes retained request and subject display metadata. It preserves cached analysis results, including observations for other targets. diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index e6fcc29cb3..25ff79417c 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -10,6 +10,7 @@ import { parseEvidence, withEvidenceCallerInputPath, type Evidence, + type CallerInputPaths, } from "../domain/evidence.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -60,7 +61,7 @@ interface CliEvidenceDelivery { /** Exact one-shot CLI target spelling, when this invocation selects a file. */ readonly callerInputPath?: string; /** Verbatim named auxiliary path choices for the same direct invocation. */ - readonly callerInputPaths?: Readonly>; + readonly callerInputPaths?: CallerInputPaths; /** Derived workflows publish their selected source closure with the result. */ readonly sourceEvidence?: readonly Evidence[]; /** Invocation exclusions cover final cleanup and publication paths/diagnostics. */ diff --git a/src/cli/cliEvidenceCallerPath.ts b/src/cli/cliEvidenceCallerPath.ts index 01d7516c8e..082bbef561 100644 --- a/src/cli/cliEvidenceCallerPath.ts +++ b/src/cli/cliEvidenceCallerPath.ts @@ -2,6 +2,7 @@ import { parseEvidence, withEvidenceCallerInputPath, type Evidence, + type CallerInputPaths, } from "../domain/evidence.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Result } from "../domain/result.js"; @@ -13,7 +14,7 @@ export const bindCliEvidenceCallerPath = ( callerPath: string, run: () => Promise>, - additionalPaths?: Readonly>, + additionalPaths?: CallerInputPaths, ): (() => Promise>) => async () => { const result = await run(); @@ -49,9 +50,10 @@ export const finishCliEvidenceCommand = ( providerId: string, operation: string, signal: AbortSignal, + additionalPaths?: CallerInputPaths, ) => finishAnalysisCommand( - bindCliEvidenceCallerPath(callerPath, run), + bindCliEvidenceCallerPath(callerPath, run, additionalPaths), service, providerId, operation, diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index f76c5540e0..14cad741b1 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -107,6 +107,17 @@ export const registerWasmCommands = ( "wabt-artifact", "inspect_wasm_artifact", signal, + options.glue_paths === undefined && + options.candidate_paths === undefined + ? undefined + : { + ...(options.glue_paths === undefined + ? {} + : { glue_paths: options.glue_paths }), + ...(options.candidate_paths === undefined + ? {} + : { candidate_paths: options.candidate_paths }), + }, ).then(projectCliAnalysisResult), ), ), diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index d7a5263b98..588f48bc33 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -331,16 +331,24 @@ it("retains named auxiliary path choices without changing identity, history or s result: { rows: ["source facts"] }, }), ); - const paths = { operations_path: "./queries 雪.graphql" }; + const paths = { + operations_path: "./queries 雪.graphql", + candidate_paths: ["./a", "./a", "./b"], + }; const rebound = withEvidenceCallerInputPath( original, "./schema.graphql", paths, ); paths.operations_path = "later unrelated choice"; + paths.candidate_paths.push("./later"); expect(rebound.caller_input_paths).toEqual({ operations_path: "./queries 雪.graphql", + candidate_paths: ["./a", "./a", "./b"], }); + expect(Object.isFrozen(rebound.caller_input_paths?.candidate_paths)).toBe( + true, + ); expect(original.caller_input_paths).toBeUndefined(); expect(rebound.evidence_id).toBe(original.evidence_id); expect(rebound.normalized_result).toBe(original.normalized_result); @@ -349,6 +357,7 @@ it("retains named auxiliary path choices without changing identity, history or s expect( withEvidenceCallerInputPath(rebound, "./schema.graphql", { operations_path: "./queries 雪.graphql", + candidate_paths: ["./a", "./a", "./b"], }), ).toBe(rebound); expect( diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index b0b1cc0428..fc07594dc4 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -88,8 +88,12 @@ const executionEnvironmentSchema = z.strictObject({ }); const callerInputPathsSchema = z.record( nulFreeStringSchema, - nulFreeStringSchema, + z.union([nulFreeStringSchema, z.array(nulFreeStringSchema)]), ); +/** Caller-selected auxiliary paths, retaining ordered repeated options. */ +export type CallerInputPaths = Readonly< + Record +>; const evidenceBaseSchema = z .object({ @@ -107,7 +111,7 @@ const evidenceBaseSchema = z caller_input_paths: callerInputPathsSchema .exactOptional() .describe( - "Verbatim auxiliary input paths keyed by argument name; caller assertions outside semantic identity.", + "Verbatim auxiliary input paths keyed by argument name; repeated selections retain their order. Caller assertions are outside semantic identity.", ), raw_result: jsonValueSchema.nullable(), normalized_result: jsonValueSchema, @@ -375,7 +379,7 @@ export const rebindEvidenceToTargetPath = ( export const withEvidenceCallerInputPath = ( evidence: Evidence, rawPath: string, - additionalPaths?: Readonly>, + additionalPaths?: CallerInputPaths, ): Evidence => { const callerInputPath = nulFreeStringSchema.parse(rawPath); const owned = immutableEvidence(evidence); diff --git a/tests/boundary/cli/wasmAcquiredInputsCli.test.ts b/tests/boundary/cli/wasmAcquiredInputsCli.test.ts index 4adb3dfe59..a9b1f91594 100644 --- a/tests/boundary/cli/wasmAcquiredInputsCli.test.ts +++ b/tests/boundary/cli/wasmAcquiredInputsCli.test.ts @@ -6,8 +6,14 @@ import { z } from "zod"; import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidence, + parseEvidence, + withEvidenceCallerInputPath, +} from "../../../src/domain/evidence.js"; +import { wasmArtifactFixture } from "../../fixtures/wasm/artifact.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -98,15 +104,18 @@ ${body} const invocation = await cli.run({ arguments: [ "inspect-wasm-artifact", - selected, + "./selected.wasm", "--candidate_paths", - candidate, + "./candidate.wasm", + "--candidate_paths", + "./candidate.wasm", "--glue_paths", - glue, + "./glue.js", "--format", "json", ], environment, + cwd: directory, }); expect(invocation.exitCode, invocation.stderr).toBe(1); const cliError = analysisErrorProjectionSchema.parse(invocation.json); @@ -114,12 +123,41 @@ ${body} code: "invalid_request", details: { partial_observation: { + caller_input_paths: { + candidate_paths: ["./candidate.wasm", "./candidate.wasm"], + glue_paths: ["./glue.js"], + }, normalized_result: expected, }, }, }); const { client, call } = await connectLocalToolsMcp(environment); + const schema = await advertisedMcpToolSchemas( + client, + "inspect_wasm_artifact", + ); + const valid = withEvidenceCallerInputPath( + createEvidence( + undefined, + { id: "fixture-wabt", name: "fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: {}, + result: wasmArtifactFixture(), + confidence: "observed", + }, + ), + "./selected.wasm", + { + candidate_paths: ["./candidate.wasm", "./candidate.wasm"], + glue_paths: ["./glue.js"], + }, + ); + expect( + schema.ajv.validate(schema.outputSchema, valid), + JSON.stringify(schema.ajv.errors), + ).toBe(true); const response = await call("inspect_wasm_artifact", { path: selected, candidate_paths: [candidate],