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.
This commit is contained in:
morluto authored and GitHub committed 2026-11-09 19:43:01 +08:00
1 parent 8dc2d7bec4
commit 400a91b5f9
7 files changed
+79 -12

No files matched your search

+3 -1
View File
@@ -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.
+2 -1
View File
@@ -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<Record<string, string>>;
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. */
+4 -2
View File
@@ -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<Result<Evidence, AnalysisError>>,
additionalPaths?: Readonly<Record<string, string>>,
additionalPaths?: CallerInputPaths,
): (() => Promise<Result<Evidence, AnalysisError>>) =>
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,
+11
View File
@@ -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),
),
),
+10 -1
View File
@@ -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(
+7 -3
View File
@@ -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<string, string | readonly string[]>
>;
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<Record<string, string>>,
additionalPaths?: CallerInputPaths,
): Evidence => {
const callerInputPath = nulFreeStringSchema.parse(rawPath);
const owned = immutableEvidence(evidence);
@@ -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],