From 3b94f2c2ef3fea23efb370af98b1f4929358a23e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:28:06 +0800 Subject: [PATCH] fix(signals): honor selected files and input format options Keep staged recording names as relative file operands, including a literal dash. Recognize exact selected input-option refusal captions even when sigrok exits successfully or a caller key contains a newline; retain producer diagnostics and typed request failures across CLI/MCP. Fixes #6875 Fixes #6839 --- docs/signal-recordings.md | 18 +- src/signals/SigrokRecordingProvider.ts | 8 +- src/signals/sigrokSelectorRejection.ts | 54 ++- .../signals/recordingProvider.test.ts | 323 +++++++++++++----- 4 files changed, 298 insertions(+), 105 deletions(-) diff --git a/docs/signal-recordings.md b/docs/signal-recordings.md index e89745b7fd..04d51708b2 100644 --- a/docs/signal-recordings.md +++ b/docs/signal-recordings.md @@ -40,12 +40,22 @@ unrelated producer warnings do not change request status. Omitting `input_format` requests sigrok's autodetection. REA uses an immutable private copy whose filename retains the selected recording's final suffix, including a dotfile basename such as `.csv`. Filename-based format detection -receives that hint without exposing the -caller's directory to sigrok. Omitting `annotations` -retains every annotation class. Both omitted selections are recorded as null. +receives that hint without exposing the caller's directory to sigrok. The +input operand is an explicit relative path within the private workspace, +so a recording named `-` is opened as a file rather than +[sigrok's stdin operand](https://github.com/sigrokproject/sigrok-cli/blob/sigrok-cli-0.7.2/input.c#L49-L75). +Omitting `annotations` retains every annotation class. Both omitted selections +are recorded as null. If sigrok reports an explicitly selected input format as unknown, REA returns `invalid_request` for `input_format`, retains the producer diagnostic, and -advises correcting the format or omitting it for autodetection. See the +advises correcting the format or omitting it for autodetection. The +[tagged option parser](https://github.com/sigrokproject/sigrok-cli/blob/sigrok-cli-0.7.2/parsers.c#L387-L404) +warns when an option key is unknown. When that diagnostic's key exactly matches +a selected `input_format` option, REA returns `invalid_request`, even on exit zero. +Fallback output and the diagnostic remain in `captured_output`; unrelated +option warnings stay visible without rejecting the request. Matching keeps +the complete diagnostic caption, including an LF inside a selected key; +embedded warning substrings do not establish a refusal. See the [upstream manual](https://sigrok.org/wiki/Sigrok-cli) for formats, decoder stacking and annotation selectors. No arbitrary command flags are accepted. diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index f37c8cdcbc..50a9c5056e 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -240,8 +240,8 @@ export class SigrokRecordingProvider implements SignalRecordingPort { }, }, ); - // Some selected-channel refusals are diagnostics, not process failures: - // sigrok can emit fallback annotations and still exit successfully. + // Selected-channel and input-option refusals can be diagnostics rather + // than process failures: fallback annotations may still exit successfully. const rejection = selection === undefined ? undefined @@ -331,7 +331,9 @@ export class SigrokRecordingProvider implements SignalRecordingPort { phase = "producer"; const args = [ "--dont-scan", - `--input-file=${snapshotName}`, + // An explicit relative path preserves filename hints while keeping + // a recording named "-" distinct from sigrok's stdin operand. + `--input-file=./${snapshotName}`, `--protocol-decoders=${input.decoders}`, "--protocol-decoder-samplenum", ...(input.input_format === null diff --git a/src/signals/sigrokSelectorRejection.ts b/src/signals/sigrokSelectorRejection.ts index 3b54b9f50c..e1f0b1b084 100644 --- a/src/signals/sigrokSelectorRejection.ts +++ b/src/signals/sigrokSelectorRejection.ts @@ -14,6 +14,8 @@ export type SigrokSelection = > | Pick; +type SigrokDiagnosticMatcher = (message: string) => boolean; + const rejectedSelector = ( field: "decoder" | "decoders" | "annotations" | "input_format", producerMessage: string, @@ -32,6 +34,8 @@ const CHANNEL_RECOVERY = "Set each decoder channel target to a channel present in the recording; inspect_signal_decoders shows the selected decoder's channel IDs."; const INPUT_FORMAT_RECOVERY = "Use a supported sigrok input format name, or omit input_format to let sigrok-cli autodetect the recording."; +const INPUT_OPTION_RECOVERY = + "Use options supported by the selected sigrok input format, or remove the rejected option."; /** * sigrok-cli loads selected decoders before decoding or showing help, so an @@ -40,13 +44,13 @@ const INPUT_FORMAT_RECOVERY = * whose own imports or code fail to load. */ const unknownDecoderRefusal = ( - lines: ReadonlySet, + hasDiagnostic: SigrokDiagnosticMatcher, id: string, ): string | undefined => { const producerMessage = `Failed to load protocol decoder ${id}.`; return id !== "" && - lines.has(producerMessage) && - lines.has( + hasDiagnostic(producerMessage) && + hasDiagnostic( `srd: ModuleNotFoundError: Failed to load decoder ${id}: import by name failed: No module named '${id}'`, ) ? producerMessage @@ -55,12 +59,12 @@ const unknownDecoderRefusal = ( /** * The sigrok-cli 0.7.2 channel mapper reports missing and unknown targets as - * plain stderr lines, even though it continues with the other mappings. Match + * plain stderr diagnostics, even though it continues with the other mappings. Match * the exact selected channel key or target value rather than searching the * selector text for a substring. */ const missingSelectedChannelRefusal = ( - lines: ReadonlySet, + hasDiagnostic: SigrokDiagnosticMatcher, decoders: string, ): string | undefined => { const selectedValues = new Set(); @@ -69,14 +73,14 @@ const missingSelectedChannelRefusal = ( const separator = assignment.indexOf("="); if (separator < 0 && assignment !== "") { const producerMessage = `Channel name for "${assignment}" missing.`; - if (lines.has(`cli: ${producerMessage}`)) return producerMessage; + if (hasDiagnostic(`cli: ${producerMessage}`)) return producerMessage; } if (separator > 0) selectedValues.add(assignment.slice(separator + 1)); } } for (const channel of selectedValues) { const producerMessage = `No channel with name "${channel}" found.`; - if (lines.has(`cli: ${producerMessage}`)) return producerMessage; + if (hasDiagnostic(`cli: ${producerMessage}`)) return producerMessage; } return undefined; }; @@ -89,32 +93,54 @@ export const parseSigrokSelectorRejection = ( stderr: string, selection: SigrokSelection, ): SigrokSelectorRejection | undefined => { - const lines = new Set(stderr.split("\n")); + // Selected option keys may contain LF, which the tagged producer prints verbatim. + // Match the complete caption at diagnostic boundaries without splitting it. + const hasDiagnostic: SigrokDiagnosticMatcher = (message) => + stderr === message || + stderr.startsWith(`${message}\n`) || + stderr.endsWith(`\n${message}`) || + stderr.includes(`\n${message}\n`); if ("decoder" in selection) { const refusal = selection.decoder === null ? undefined - : unknownDecoderRefusal(lines, selection.decoder); + : unknownDecoderRefusal(hasDiagnostic, selection.decoder); return refusal === undefined ? undefined : rejectedSelector("decoder", refusal, HELP_RECOVERY); } - const inputFormat = selection.input_format?.split(":")[0] ?? ""; + const [inputFormat = "", ...inputOptions] = + selection.input_format?.split(":") ?? []; const inputFormatRefusal = `Error: unknown input module '${inputFormat}'.`; - if (inputFormat !== "" && lines.has(`cli: ${inputFormatRefusal}`)) + if (inputFormat !== "" && hasDiagnostic(`cli: ${inputFormatRefusal}`)) return rejectedSelector( "input_format", inputFormatRefusal, INPUT_FORMAT_RECOVERY, ); + // sigrok-cli 0.7.2 parses format:option=value with colon separators and + // warns on unknown option keys, then continues with the supported options. + for (const option of inputOptions) { + const key = option.split("=", 1)[0] ?? ""; + const producerMessage = `Unknown keyword: ${key}.`; + if (key !== "" && hasDiagnostic(producerMessage)) + return rejectedSelector( + "input_format", + producerMessage, + INPUT_OPTION_RECOVERY, + ); + } // sigrok-cli stacks decoders with "," and appends options with ":". for (const entry of selection.decoders.split(",")) { - const refusal = unknownDecoderRefusal(lines, entry.split(":")[0] ?? ""); + const refusal = unknownDecoderRefusal( + hasDiagnostic, + entry.split(":")[0] ?? "", + ); if (refusal !== undefined) return rejectedSelector("decoders", refusal, DECODE_RECOVERY); } const missingChannel = missingSelectedChannelRefusal( - lines, + hasDiagnostic, selection.decoders, ); if (missingChannel !== undefined) @@ -125,7 +151,7 @@ export const parseSigrokSelectorRejection = ( if (decoder === "" || classes === undefined || rest.length > 0) continue; for (const annotation of classes.split(":")) { const producerMessage = `Annotation '${annotation}' not found for protocol decoder '${decoder}'.`; - if (annotation !== "" && lines.has(producerMessage)) + if (annotation !== "" && hasDiagnostic(producerMessage)) return rejectedSelector( "annotations", producerMessage, diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts index 94b4c61aaf..1e483ed76c 100644 --- a/tests/boundary/signals/recordingProvider.test.ts +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -6,7 +6,8 @@ import { signalDecodersSchema } from "../../../src/domain/signals/signalDecoders import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; import { it, expect, onTestFinished } from "vitest"; -import { writeFile, readdir, chmod, symlink } from "node:fs/promises"; +import { writeFile, readFile, readdir, chmod, symlink } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { basename, join } from "node:path"; import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; import { SigrokRecordingProvider } from "../../../src/signals/SigrokRecordingProvider.js"; @@ -30,7 +31,7 @@ const producerSource = (mode: string, snapshotName: string): string => `import {readFileSync, writeFileSync, chmodSync, truncateSync} from 'node:fs'; const inputFileArgument = process.argv.find((argument) => argument.startsWith('--input-file=')); const snapshotName = inputFileArgument?.slice('--input-file='.length); -if (inputFileArgument !== undefined && inputFileArgument !== ${JSON.stringify(`--input-file=${snapshotName}`)}) throw Error('wrong snapshot name'); +if (${JSON.stringify(mode)} !== 'input-operand' && inputFileArgument !== undefined && inputFileArgument !== ${JSON.stringify(`--input-file=./${snapshotName}`)}) throw Error('wrong snapshot name'); if (process.argv.includes('--version')) { if (${JSON.stringify(mode)} === 'invalid-version-stdout') process.stdout.write(Buffer.concat([Buffer.from('sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3\\n'), Buffer.from([0xff])])); else console.log(${JSON.stringify(mode)} === 'bad-version' ? 'sigrok-cli 0.7.1' : 'sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3'); @@ -40,6 +41,10 @@ if (process.argv.includes('--version')) { else if (${JSON.stringify(mode)} === 'bad-version') console.log('unexpected version fallback'); else if (${JSON.stringify(mode)} === 'nonzero') { console.error('invalid decoder'); process.exitCode = 1; } else if (${JSON.stringify(mode)} === 'unknown-input-format') { if (!process.argv.includes('--input-format=missingformat')) console.error('fixture: selected input format was not forwarded'); console.error("cli: Error: unknown input module 'missingformat'."); process.exitCode = 1; } +else if (${JSON.stringify(mode)} === 'unknown-input-option') { if (!process.argv.includes('--input-format=vcd:down_sample=2')) throw Error('selected input option was not forwarded'); process.stdout.write('1104-1938 uart-1: 52\\n'); console.error('Unknown keyword: down_sample.'); } +else if (${JSON.stringify(mode)} === 'unknown-multiline-input-option') { if (!process.argv.includes(${JSON.stringify("--input-format=vcd:down\nsample=2")})) throw Error('selected input option was not forwarded'); process.stdout.write('1104-1938 uart-1: 52\\n'); console.error(${JSON.stringify("Unknown keyword: down\nsample.")}); } +else if (${JSON.stringify(mode)} === 'other-input-option-warning') { process.stdout.write('10-20 uart-1: 42\\n'); console.error('Unknown keyword: down_sample.'); } +else if (${JSON.stringify(mode)} === 'embedded-input-option-warning') { process.stdout.write('10-20 uart-1: 42\\n'); console.error('context: Unknown keyword: down_sample. suffix'); } else if (${JSON.stringify(mode)} === 'unknown-decoder') { console.error("srd: ModuleNotFoundError: Failed to load decoder nosuchdecoder: import by name failed: No module named 'nosuchdecoder'\\nFailed to load protocol decoder nosuchdecoder."); process.exitCode = 1; } else if (${JSON.stringify(mode)} === 'broken-decoder') { console.error("srd: ModuleNotFoundError: Failed to load decoder uart: import by name failed: No module named 'serial'\\nFailed to load protocol decoder uart."); process.exitCode = 1; } else if (${JSON.stringify(mode)} === 'unknown-annotation') { console.error("Annotation 'nosuchclass' not found for protocol decoder 'uart'."); process.exitCode = 1; } @@ -60,7 +65,8 @@ else if (${JSON.stringify(mode)} === 'wait') { console.log('ready'); setInterval else if (${JSON.stringify(mode)} === 'many') process.stdout.write(Array.from({length:90},(_,i)=>'line '+(i+1)+'\\r\\n').join('')); else if (process.argv.includes('--list-supported')) { console.log(${JSON.stringify(mode)} === 'unicode-search' ? 'Supported protocol decoders:\\n uart Universal Asynchronous Receiver/Transmitter\\n sigma ΟΣ\\n' : 'Supported hardware drivers:\\n ignored Device\\nSupported protocol decoders:\\n uart Universal Asynchronous Receiver/Transmitter\\n i2c Inter-Integrated Circuit\\n'); console.error('catalog warning'); } else if (process.argv.includes('--show')) console.log(${JSON.stringify(mode)} === 'unicode-search' ? 'ID: uart\\nOptions:\\n- label: ΟΣ' : 'ID: uart\\nOptions:\\n- baudrate: Baud rate (default 115200)\\n- parity: Parity'); -else if (${JSON.stringify(mode)} === 'csv-autodetect') { if (!process.argv.includes(${JSON.stringify(`--input-file=${snapshotName}`)}) || readFileSync(snapshotName, 'utf8') !== 'CLK,MOSI\\n0,0\\n1,0\\n') throw Error('CSV suffix or copy bytes lost'); console.log('10-20 spi-1: 1'); } +else if (${JSON.stringify(mode)} === 'csv-autodetect') { if (!process.argv.includes(${JSON.stringify(`--input-file=./${snapshotName}`)}) || readFileSync(snapshotName, 'utf8') !== 'CLK,MOSI\\n0,0\\n1,0\\n') throw Error('CSV suffix or copy bytes lost'); console.log('10-20 spi-1: 1'); } +else if (${JSON.stringify(mode)} === 'input-operand') { console.log(readFileSync(snapshotName === '-' ? 0 : snapshotName).toString('hex')); } else if (${JSON.stringify(mode)} === 'long-basename') { if (!process.argv.includes('--input-format=vcd') || readFileSync(snapshotName, 'utf8') !== 'recording bytes') throw Error('long source basename or explicit format was lost'); console.log('10-20 uart-1: 42'); } else { if(readFileSync(snapshotName, 'utf8') !== 'recording bytes') throw Error('wrong copy'); console.log('10-20 uart-1: 42'); console.error('producer warning'); } `; @@ -215,7 +221,7 @@ it("pages literal output without changing decoder arguments or normalizing text" expect(report.output_window.returned_lines).toBe(count); expect(report.tool.arguments).toEqual([ "--dont-scan", - `--input-file=${basename(f.path)}`, + `--input-file=./${basename(f.path)}`, "--protocol-decoders=uart", "--protocol-decoder-samplenum", ]); @@ -258,7 +264,7 @@ it("decodes an immutable copy, preserves options and warnings, and releases its expect(report.selection.input_format).toBeNull(); expect(report.tool.arguments).toEqual([ "--dont-scan", - "--input-file=recorded bytes.vcd", + "--input-file=./recorded bytes.vcd", "--protocol-decoders=uart:rx=RX:baudrate=9600", "--protocol-decoder-samplenum", "--protocol-decoder-annotations=uart=rx-data", @@ -285,7 +291,7 @@ it.each(["capture.csv", ".capture.csv", ".csv"])( }); expect(report.tool.arguments).toEqual([ "--dont-scan", - `--input-file=${recordingName}`, + `--input-file=./${recordingName}`, "--protocol-decoders=spi:clk=CLK:mosi=MOSI", "--protocol-decoder-samplenum", ]); @@ -307,13 +313,44 @@ it("stages a long source basename without expanding it before decoding", async ( expect(report.artifact.path).toBe(f.path); expect(report.selection.input_format).toBe("vcd"); expect(report.annotations_text).toBe("10-20 uart-1: 42\n"); - expect(report.tool.arguments).toContain(`--input-file=${recordingName}`); + expect(report.tool.arguments).toContain(`--input-file=./${recordingName}`); expect(f.calls()).toBe(2); expect( (await readdir(f.directory)).filter((name) => name.startsWith("decoder-")), ).toEqual([]); }); +it.each(["control.vcd", "-"])( + "opens the complete private recording for %s rather than stdin", + async (recordingName) => { + // Model only the tagged sigrok input operand rule, not protocol decoding. + const f = await fixture("input-operand", false, recordingName); + const source = await readFile(f.path); + const result = await f.service.decode({ + path: f.path, + decoders: "uart", + input_format: "vcd", + }); + if (!result.ok) throw result.error; + const report = signalRecordingSchema.parse(result.value.normalized_result); + expect(report.artifact).toEqual({ + path: f.path, + bytes: source.length, + sha256: createHash("sha256").update(source).digest("hex"), + }); + expect(report.selection).toMatchObject({ + path: f.path, + input_format: "vcd", + }); + expect(report.annotations_text).toBe(`${source.toString("hex")}\n`); + expect(report.tool.arguments).not.toContain("--input-file=-"); + expect(await readFile(f.path)).toEqual(source); + expect((await readdir(f.directory)).sort()).toEqual( + [recordingName, "producer.mjs"].sort(), + ); + }, +); + it.each([ ["nonzero", "ProviderAdapterError"], ["malformed-copy", "AnalysisArtifactChangedError"], @@ -395,93 +432,211 @@ it.each([ }, ); -it("reports an unknown selected input format through CLI and MCP", async () => { - const f = await fixture("unknown-input-format"); - const refusal = "Error: unknown input module 'missingformat'."; - const issueMessage = - `sigrok-cli rejected the input_format selector: ${refusal} ` + - "Use a supported sigrok input format name, or omit input_format to let sigrok-cli autodetect the recording."; - const harness = await createApplicationMcpHarness({ - serverOptions: { environment: {}, signalRecording: f.service }, - }); - onTestFinished(harness.close); - const { error } = parseMcpToolError( - await harness.client.callTool({ - name: "decode_signal_recording", - arguments: { - path: f.path, - decoders: "uart", - input_format: "missingformat", - }, - }), - ); - expect(error).toMatchObject({ - code: "invalid_request", - category: "invalid_input", - message: expect.stringContaining(issueMessage), - details: { - issues: [ - { - path: ["input_format"], - reason: "invalid_value", - message: expect.stringContaining(issueMessage), +it.each([ + { + mode: "unknown-input-format", + inputFormat: "missingformat", + refusal: "Error: unknown input module 'missingformat'.", + recovery: + "Use a supported sigrok input format name, or omit input_format to let sigrok-cli autodetect the recording.", + producerExit: 1, + producerStdout: "", + }, + { + mode: "unknown-input-option", + inputFormat: "vcd:down_sample=2", + refusal: "Unknown keyword: down_sample.", + recovery: + "Use options supported by the selected sigrok input format, or remove the rejected option.", + producerExit: 0, + producerStdout: "1104-1938 uart-1: 52\n", + }, + { + mode: "unknown-multiline-input-option", + inputFormat: "vcd:down\nsample=2", + refusal: "Unknown keyword: down\nsample.", + recovery: + "Use options supported by the selected sigrok input format, or remove the rejected option.", + producerExit: 0, + producerStdout: "1104-1938 uart-1: 52\n", + }, +])( + "reports $mode through CLI and MCP without successful fallback Evidence", + async ({ + mode, + inputFormat, + refusal, + recovery, + producerExit, + producerStdout, + }) => { + const f = await fixture(mode); + const issueMessage = + `sigrok-cli rejected the input_format selector: ${refusal} ` + recovery; + const harness = await createApplicationMcpHarness({ + serverOptions: { environment: {}, signalRecording: f.service }, + }); + onTestFinished(harness.close); + const { error } = parseMcpToolError( + await harness.client.callTool({ + name: "decode_signal_recording", + arguments: { + path: f.path, + decoders: "uart", + input_format: inputFormat, + }, + }), + ); + expect(error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining(issueMessage), + details: { + issues: [ + { + path: ["input_format"], + reason: "invalid_value", + message: expect.stringContaining(issueMessage), + }, + ], + captured_output: { + exit_code: producerExit, + stdout: producerStdout, + stderr: expect.stringContaining(refusal), }, - ], - captured_output: { - exit_code: 1, - stderr: expect.stringContaining(refusal), }, - }, - }); - expect(JSON.stringify(error)).not.toContain( - "fixture: selected input format was not forwarded", - ); + }); + expect(JSON.stringify(error)).not.toContain( + "fixture: selected input format was not forwarded", + ); - const cli = Cli.create("signal-input-format-refusal-test", { sync: false }); - registerSignalCommands(cli, silentLogger, {}, f.service); - let stdout = ""; - await cli.serve( - [ - "decode-signal-recording", - f.path, - "--decoders", - "uart", - "--input_format", - "missingformat", - "--format", - "json", - ], - { - stdout: (text) => { - stdout += text; - }, - exit: () => {}, - }, - ); - expect(JSON.parse(stdout)).toMatchObject({ - code: "invalid_request", - message: expect.stringContaining(issueMessage), - details: { - issues: [ + const cli = Cli.create("signal-input-format-refusal-test", { sync: false }); + registerSignalCommands(cli, silentLogger, {}, f.service); + let stdout = ""; + let commandExitCode: typeof process.exitCode; + const previousExitCode = process.exitCode; + try { + process.exitCode = 0; + await cli.serve( + [ + "decode-signal-recording", + f.path, + "--decoders", + "uart", + "--input_format", + inputFormat, + "--format", + "json", + ], { - path: ["input_format"], - reason: "invalid_value", - message: expect.stringContaining(issueMessage), + stdout: (text) => { + stdout += text; + }, + exit: () => {}, }, - ], + ); + commandExitCode = process.exitCode; + } finally { + process.exitCode = previousExitCode; + } + expect(commandExitCode).toBe(1); + const envelope: unknown = JSON.parse(stdout); + expect(envelope).not.toHaveProperty("evidence_id"); + expect(envelope).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining(issueMessage), + details: { + issues: [ + { + path: ["input_format"], + reason: "invalid_value", + message: expect.stringContaining(issueMessage), + }, + ], + captured_output: { + exit_code: producerExit, + stdout: producerStdout, + stderr: expect.stringContaining(refusal), + }, + }, + }); + expect(stdout).not.toContain( + "fixture: selected input format was not forwarded", + ); + expect(f.calls()).toBe(4); + expect( + (await readdir(f.directory)).filter((name) => + name.startsWith("decoder-"), + ), + ).toEqual([]); + }, +); + +it.each([ + "vcd:downsample=2", + "vcd:down_sample_extra=2", + "vcd:label=down_sample", + null, +])( + "retains an unrelated input-option warning with selection %s", + async (inputFormat) => { + const f = await fixture("other-input-option-warning"); + const result = await f.service.decode({ + path: f.path, + decoders: "uart", + input_format: inputFormat, + }); + if (!result.ok) throw result.error; + const report = signalRecordingSchema.parse(result.value.normalized_result); + expect(report.selection.input_format).toBe(inputFormat); + expect(report.annotations_text).toBe("10-20 uart-1: 42\n"); + expect(report.diagnostics).toBe("Unknown keyword: down_sample.\n"); + if (inputFormat !== null) + expect(report.tool.arguments).toContain(`--input-format=${inputFormat}`); + else + expect( + report.tool.arguments.some((argument) => + argument.startsWith("--input-format="), + ), + ).toBe(false); + }, +); + +it("recognizes a rejected bare input-format option key", async () => { + const f = await fixture("other-input-option-warning"); + const result = await f.service.decode({ + path: f.path, + decoders: "uart", + input_format: "vcd:down_sample", + }); + if (result.ok) throw new Error("Expected selected bare option refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: ["input_format"], reason: "invalid_value" }], captured_output: { - exit_code: 1, - stderr: expect.stringContaining(refusal), + exit_code: 0, + stdout: "10-20 uart-1: 42\n", + stderr: "Unknown keyword: down_sample.\n", }, }, }); - expect(stdout).not.toContain( - "fixture: selected input format was not forwarded", +}); + +it("does not treat an embedded warning substring as a selected option refusal", async () => { + const f = await fixture("embedded-input-option-warning"); + const result = await f.service.decode({ + path: f.path, + decoders: "uart", + input_format: "vcd:down_sample=2", + }); + if (!result.ok) throw result.error; + const report = signalRecordingSchema.parse(result.value.normalized_result); + expect(report.selection.input_format).toBe("vcd:down_sample=2"); + expect(report.annotations_text).toBe("10-20 uart-1: 42\n"); + expect(report.diagnostics).toBe( + "context: Unknown keyword: down_sample. suffix\n", ); - expect(f.calls()).toBe(4); - expect( - (await readdir(f.directory)).filter((name) => name.startsWith("decoder-")), - ).toEqual([]); }); it("projects sigrok's zero-exit missing-channel refusal through CLI and MCP with partial output", async () => {