diff --git a/src/cli.ts b/src/cli.ts index 590887c2a0..36dcd1275b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,7 +31,10 @@ import { registerJavaScriptRuntimeObservationCommands } from "./cliJavaScriptRun import { registerApplicationCommands } from "./cli/applicationCommands.js"; import { presentOmittableDefaults } from "./cli/omittableDefaults.js"; import type { CliInstance } from "./cli/types.js"; -import type { CliResultOutput } from "./cli/streamedJsonOutput.js"; +import { + streamCliCommandResults, + type CliResultOutput, +} from "./cli/streamedJsonOutput.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -64,12 +67,7 @@ export const createCli = ( }); registerSetupCommands(cli, logger, environment); - registerCoreAnalysisCommands( - cli, - logger, - analysis.runDirectAnalysis, - resultOutput, - ); + registerCoreAnalysisCommands(cli, logger, analysis.runDirectAnalysis); registerUtilityCommands(cli, logger, environment, analysis); registerEvmCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); @@ -89,9 +87,10 @@ export const createCli = ( registerJavaScriptRecoveryCommands(cli, logger, environment); registerAdvancedBrowserCommands(cli, logger); registerBrowserScenarioCommands(cli, logger, environment); - registerElectronCommands(cli, logger, environment, resultOutput); + registerElectronCommands(cli, logger, environment); registerJavaScriptRuntimeObservationCommands(cli, logger); registerApplicationCommands(cli, logger); presentOmittableDefaults(cli); + if (resultOutput !== undefined) streamCliCommandResults(cli, resultOutput); return cli; }; diff --git a/src/cli/coreAnalysisCommands.ts b/src/cli/coreAnalysisCommands.ts index f72ab10764..999c1b23e7 100644 --- a/src/cli/coreAnalysisCommands.ts +++ b/src/cli/coreAnalysisCommands.ts @@ -3,15 +3,13 @@ import type { Logger } from "pino"; import { registerCoreBinaryCommands } from "./coreBinaryCommands.js"; import { registerCoreNativeCommands } from "./coreNativeCommands.js"; import type { CliInstance } from "./types.js"; -import type { CliResultOutput } from "./streamedJsonOutput.js"; /** Register core binary and native deep-analysis CLI commands. */ export const registerCoreAnalysisCommands = ( cli: CliInstance, logger: Logger, runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], - resultOutput?: CliResultOutput, ): void => { - registerCoreBinaryCommands(cli, logger, runDirectAnalysis, resultOutput); + registerCoreBinaryCommands(cli, logger, runDirectAnalysis); registerCoreNativeCommands(cli, logger, runDirectAnalysis); }; diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 5130b8634a..a916a2c5a5 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -15,7 +15,6 @@ import { import type { CliInstance } from "./types.js"; import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js"; import { withCommandCancellation } from "./commandCancellation.js"; -import type { CliResultOutput } from "./streamedJsonOutput.js"; import { javascriptApplicationOptions } from "../cliObservationOptions.js"; /** Register provider-neutral binary overview and procedure CLI commands. */ @@ -23,9 +22,8 @@ export const registerCoreBinaryCommands = ( cli: CliInstance, logger: Logger, runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], - resultOutput?: CliResultOutput, ): void => { - registerOverviewCommands(cli, logger, runDirectAnalysis, resultOutput); + registerOverviewCommands(cli, logger, runDirectAnalysis); registerDecompileCommand(cli, logger, runDirectAnalysis); registerFunctionCommand(cli, logger, runDirectAnalysis); registerInstructionsCommand(cli, logger, runDirectAnalysis); @@ -38,7 +36,6 @@ const registerOverviewCommands = ( cli: CliInstance, logger: Logger, runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], - resultOutput?: CliResultOutput, ): void => { const overviewOptions = z.object({ snapshot: z @@ -58,11 +55,7 @@ const registerOverviewCommands = ( integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy, }), alias: { integrityPolicy: "integrity-policy" }, - run: async ({ args, options, format }) => { - const output = - resultOutput === undefined - ? undefined - : { output: resultOutput, command: CLI_COMMANDS.analyze, format }; + run: async ({ args, options }) => { // Route JavaScript targets exactly like analyze-javascript-application, // including typed cancellation; cancellation wraps logging so its exit // code is not replaced by the logged failure status. @@ -74,7 +67,6 @@ const registerOverviewCommands = ( input_path: resolve(args.path), integrity_policy: options.integrityPolicy, }, - output, signal, ), ), diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 79dcd7cc1e..73e59fec53 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -24,7 +24,6 @@ import { javascriptApplicationOptions, } from "../cliObservationOptions.js"; import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js"; -import type { CliResultOutput } from "./streamedJsonOutput.js"; import { withCommandCancellation } from "./commandCancellation.js"; /** Register CLI equivalents of the Electron MCP tools. */ @@ -32,11 +31,10 @@ export const registerElectronCommands = ( cli: ReturnType, logger: Logger, environment: Readonly>, - resultOutput?: CliResultOutput, ): void => { registerElectronObservationCommands(cli, logger); registerElectronActiveCommand(cli, logger, environment); - registerJavaScriptApplicationCommand(cli, logger, resultOutput); + registerJavaScriptApplicationCommand(cli, logger); registerJavaScriptRuntimeReconciliationCommand(cli, logger); }; @@ -184,7 +182,6 @@ const registerElectronPageInspection = ( const registerJavaScriptApplicationCommand = ( cli: ReturnType, logger: Logger, - resultOutput?: CliResultOutput, ): void => { cli.command(CLI_COMMANDS.analyzeJavaScriptApplication, { description: @@ -194,7 +191,7 @@ const registerJavaScriptApplicationCommand = ( }), options: javascriptApplicationOptions, alias: { integrityPolicy: "integrity-policy" }, - run: ({ args, options, format }) => + run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () => runCliJavaScriptApplicationAnalysis( @@ -203,13 +200,6 @@ const registerJavaScriptApplicationCommand = ( format: options.artifactFormat, integrity_policy: options.integrityPolicy, }, - resultOutput === undefined - ? undefined - : { - output: resultOutput, - command: CLI_COMMANDS.analyzeJavaScriptApplication, - format, - }, signal, ), ), diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index 150a7a913c..26fad1e3e3 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -2,15 +2,12 @@ import { analyzeJavaScriptApplication } from "../application/javascript/JavaScri import { createProgressReporter } from "../application/ProgressReporter.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { CliCommandOutput } from "./streamedJsonOutput.js"; /** Execute the shared one-shot CLI boundary for static JavaScript analysis. */ export const runCliJavaScriptApplicationAnalysis = async ( input: unknown, - output?: CliCommandOutput, signal?: AbortSignal, -): Promise => { - const started = performance.now(); +): Promise => { const progress = createProgressReporter( async (update) => { process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`); @@ -21,18 +18,7 @@ export const runCliJavaScriptApplicationAnalysis = async ( progress, ...(signal === undefined ? {} : { signal }), }); - const value = result.ok ? result.value : cliError(result.error); - if ( - output !== undefined && - (await output.output.write(value, { - command: output.command, - format: output.format, - duration: `${Math.round(performance.now() - started)}ms`, - })) - ) - // Keep failures visible to command logging after the stream has handled stdout. - return result.ok ? undefined : value; - return value; + return result.ok ? result.value : cliError(result.error); }; const cliError = ( diff --git a/src/cli/jsonOutput.ts b/src/cli/jsonOutput.ts index 93df9abd31..b42bd82bdf 100644 --- a/src/cli/jsonOutput.ts +++ b/src/cli/jsonOutput.ts @@ -10,7 +10,7 @@ export const writeJsonOutput = async ( destination: Writable, format: JsonOutputFormat = "json", ): Promise => { - for (const part of bufferedJsonParts(documentParts(value, format))) + for (const part of bufferedJsonParts(jsonOutputParts(value, format))) await writePart(destination, part); }; @@ -35,7 +35,8 @@ const writePart = (destination: Writable, part: string): Promise => } }); -function* documentParts( +/** The exact formatted document, including its final newline. */ +export function* jsonOutputParts( value: unknown, format: JsonOutputFormat, ): Generator { diff --git a/src/cli/streamedJsonOutput.ts b/src/cli/streamedJsonOutput.ts index 282316d0ad..64977c22d1 100644 --- a/src/cli/streamedJsonOutput.ts +++ b/src/cli/streamedJsonOutput.ts @@ -1,10 +1,16 @@ +import { constants } from "node:buffer"; import { type Writable } from "node:stream"; -import { Filter } from "incur"; +import { Cli, Filter } from "incur"; import { parseCliOutputArguments } from "../cliOutput.js"; -import type { JsonValue } from "../domain/jsonValue.js"; -import { writeJsonOutput } from "./jsonOutput.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { + projectAnalysisError, + type AnalysisErrorProjection, +} from "../domain/analysisErrorProjection.js"; +import { jsonOutputParts, writeJsonOutput } from "./jsonOutput.js"; +import type { CliInstance } from "./types.js"; /** Result metadata supplied by the command that actually ran. */ export interface CliResultOutputMetadata { @@ -17,15 +23,82 @@ export interface CliResultOutputMetadata { export interface CliResultOutput { readonly handled: boolean; readonly failed: boolean; - write(value: JsonValue, metadata: CliResultOutputMetadata): Promise; + readonly error: AnalysisErrorProjection | undefined; + write(value: unknown, metadata: CliResultOutputMetadata): Promise; } -/** Selected command output context, independent of application workflows. */ -export interface CliCommandOutput { - readonly output: CliResultOutput; - readonly command: string; - readonly format: string; -} +type CommandMap = NonNullable>; +type CommandDefinition = Extract< + CommandMap extends Map ? Entry : never, + { run: unknown } +>; +const registeredCommands: WeakMap = Cli.toCommands; + +/** Install formatting once, after registration, for every ordinary CLI result. */ +export const streamCliCommandResults = ( + cli: CliInstance, + output: CliResultOutput, +): void => { + const commands = registeredCommands.get(cli); + if (commands !== undefined) wrapCommands(commands, [], output); +}; + +const wrapCommands = ( + commands: CommandMap, + parents: readonly string[], + output: CliResultOutput, +): void => { + for (const [name, entry] of commands) { + const path = [...parents, name]; + if ("_group" in entry) { + wrapCommands(entry.commands, path, output); + if (entry.root !== undefined) + wrapCommand(entry.root, path.join(" "), output); + } else if (!("_alias" in entry) && !("_fetch" in entry)) + wrapCommand(entry, path.join(" "), output); + } +}; + +const wrapCommand = ( + command: Pick, + name: string, + output: CliResultOutput, +): void => { + const run = command.run; + command.run = (context) => { + const started = performance.now(); + const result: unknown = run(context); + // Generator results and Incur's sentinel responses retain their own protocol. + if ( + typeof result === "object" && + result !== null && + Symbol.asyncIterator in result + ) + return result; + return Promise.resolve(result).then(async (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + Symbol.for("incur.sentinel") in value + ) + return value; + if ( + !(await output.write(value, { + command: name, + format: context.format, + duration: `${Math.round(performance.now() - started)}ms`, + })) + ) + return value; + if (output.error !== undefined) + return context.error({ + code: output.error.code, + message: output.error.message, + }); + return undefined; + }); + }; +}; /** Preserve Incur's JSON controls while streaming the complete result. */ export const createStreamedCliJsonOutput = ( @@ -35,7 +108,6 @@ export const createStreamedCliJsonOutput = ( const options = parseCliOutputArguments(arguments_); if ( options.parseError || - options.tokenCount || options.tokenWindow || (options.format !== "json" && options.format !== "jsonl") ) @@ -43,6 +115,7 @@ export const createStreamedCliJsonOutput = ( const format = options.format; let handled = false; let failed = false; + let error: AnalysisErrorProjection | undefined; return { get handled() { return handled; @@ -50,6 +123,9 @@ export const createStreamedCliJsonOutput = ( get failed() { return failed; }, + get error() { + return error; + }, async write(value, metadata) { if (metadata.format !== format) return false; const filtered: unknown = options.filterOutput @@ -59,6 +135,36 @@ export const createStreamedCliJsonOutput = ( command: metadata.command, duration: metadata.duration, }; + if (options.tokenCount) { + // Incur/tokenx materialize the formatted value. Measure its UTF-16 + // expansion before allowing that allocation, independently of UTF-8 bytes. + let characters = 0; + if ( + filtered !== undefined && + filtered !== null && + typeof filtered !== "function" && + typeof filtered !== "symbol" + ) + for (const part of jsonOutputParts(filtered, format)) + characters += part.length; + characters = Math.max(0, characters - 1); + if (characters <= constants.MAX_STRING_LENGTH) return false; + error = projectAnalysisError( + new AnalysisResourceConstraintError( + metadata.command, + "memory", + "Token counting requires a formatted string larger than the runtime permits.", + { + formatted_characters: characters, + max_string_characters: constants.MAX_STRING_LENGTH, + }, + { + remediationAction: + "Remove --token-count to stream the complete JSON result, or narrow it with --filter-output.", + }, + ), + ); + } const document = options.fullOutput ? filtered === undefined ? { ok: true, meta } @@ -73,7 +179,15 @@ export const createStreamedCliJsonOutput = ( // document from Incur's error formatter. handled = true; try { - await writeJsonOutput(document, destination, format); + await writeJsonOutput( + error === undefined + ? document + : options.fullOutput + ? { ok: false, error, meta } + : error, + destination, + format, + ); } catch (cause: unknown) { failed = true; throw cause; diff --git a/tests/acceptance/managedCliOutput.test.ts b/tests/acceptance/managedCliOutput.test.ts new file mode 100644 index 0000000000..84cec5222c --- /dev/null +++ b/tests/acceptance/managedCliOutput.test.ts @@ -0,0 +1,62 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "managed CLI preserves evidence, filters, envelopes, and bounded token counts", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-managed-cli-output-"); + const path = join(root, "fixture.dll"); + await writeFile(path, buildManagedPeFixture()); + const argv = ["inspect-managed-members", path, "--json"]; + const result = await cli.run({ arguments: argv }); + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ + operation: "inspect_managed_members", + provider: { id: "rea-dotnet-static" }, + subject: { local_path: path }, + normalized_result: { + methods: [expect.objectContaining({ token: expect.any(String) })], + }, + }); + const filtered = await cli.run({ + arguments: [...argv, "--filter-output", "operation", "--full-output"], + }); + expect(filtered.exitCode).toBe(0); + expect(filtered.json).toMatchObject({ + ok: true, + data: "inspect_managed_members", + meta: { + command: "inspect-managed-members", + duration: expect.stringMatching(/^\d+ms$/u), + }, + }); + const count = await cli.run({ + arguments: [...argv, "--filter-output", "operation", "--token-count"], + }); + expect(count.exitCode).toBe(0); + expect(count.stdout).toMatch(/^\d+\n$/u); + const omittedCount = await cli.run({ + arguments: [...argv, "--filter-output", "toString", "--token-count"], + }); + expect(omittedCount.exitCode).toBe(0); + expect(omittedCount.stdout).toBe("0\n"); + const jsonl = await cli.run({ + arguments: [ + "inspect-managed-members", + path, + "--format", + "jsonl", + "--filter-output", + "operation", + ], + }); + expect(jsonl.exitCode).toBe(0); + expect(jsonl.json).toBe("inspect_managed_members"); + }, +); diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 7c1ff4ddd2..0294073885 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -1,9 +1,17 @@ +import { constants } from "node:buffer"; +import { createHash } from "node:crypto"; import { Writable } from "node:stream"; +import { Cli } from "incur"; import { describe, expect, it } from "vitest"; import { writeJsonOutput } from "../../../src/cli/jsonOutput.js"; -import { createStreamedCliJsonOutput } from "../../../src/cli/streamedJsonOutput.js"; +import { + createStreamedCliJsonOutput, + streamCliCommandResults, +} from "../../../src/cli/streamedJsonOutput.js"; +import { registerManagedCommands } from "../../../src/cli/managedCommands.js"; +import { silentLogger } from "../../../src/logger.js"; const collect = async ( value: unknown, @@ -140,7 +148,6 @@ describe("streamed CLI result surface", () => { it.each([ [], ["--format", "md"], - ["--json", "--token-count"], ["--json", "--token-limit", "5"], ["--json", "--format", "invalid"], ])("delegates unsupported output controls %j", (...arguments_) => { @@ -167,4 +174,104 @@ describe("streamed CLI result surface", () => { expect(output.handled).toBe(true); expect(output.failed).toBe(true); }); + + it("streams managed command output past the engine string limit and declines oversized token counting", async () => { + const leaf = "x".repeat(64 * 1024); + const count = Math.ceil(constants.MAX_STRING_LENGTH / leaf.length) + 1; + const value = { methods: Array.from({ length: count }, () => leaf) }; + const expected = createHash("sha256"); + const encodedLeaf = JSON.stringify(leaf); + const prefix = '{\n "methods": [\n '; + const separator = ",\n "; + const suffix = "\n ]\n}\n"; + expected.update(prefix); + for (let index = 0; index < count; index += 1) { + if (index > 0) expected.update(separator); + expected.update(encodedLeaf); + } + expected.update(suffix); + const expectedCharacters = + prefix.length + + count * encodedLeaf.length + + (count - 1) * separator.length + + suffix.length; + expect(expectedCharacters).toBeGreaterThan(constants.MAX_STRING_LENGTH); + + const actual = createHash("sha256"); + let bytes = 0; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + bytes += chunk.length; + actual.update(chunk); + callback(); + }, + }); + const cli = Cli.create("rea-output-test", { sync: false }); + registerManagedCommands(cli, silentLogger, async (path, operation) => { + expect(path).toBe("fixture.dll"); + expect(operation).toBe("inspect_managed_members"); + return value; + }); + const argv = ["inspect-managed-members", "fixture.dll", "--json"]; + const output = createStreamedCliJsonOutput(argv, destination); + if (output === undefined) throw new Error("Missing JSON output surface"); + streamCliCommandResults(cli, output); + let exitCode = 0; + let fallbackBytes = 0; + await cli.serve(argv, { + stdout: (text) => { + if (!output.handled) fallbackBytes += text.length; + }, + exit: (code) => { + exitCode = code; + }, + }); + expect(exitCode).toBe(0); + expect(fallbackBytes).toBe(0); + expect(bytes).toBe(expectedCharacters); + expect(actual.digest("hex")).toBe(expected.digest("hex")); + + const chunks: Buffer[] = []; + const tokenArgv = [...argv, "--token-count", "--full-output"]; + const tokenOutput = createStreamedCliJsonOutput( + tokenArgv, + new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }), + ); + if (tokenOutput === undefined) + throw new Error("Missing token-count output surface"); + const tokenCli = Cli.create("rea-output-test", { sync: false }); + registerManagedCommands(tokenCli, silentLogger, async () => value); + streamCliCommandResults(tokenCli, tokenOutput); + await tokenCli.serve(tokenArgv, { + stdout: (text) => { + if (!tokenOutput.handled) fallbackBytes += text.length; + }, + exit: (code) => { + exitCode = code; + }, + }); + expect(exitCode).toBe(1); + expect(fallbackBytes).toBe(0); + expect(JSON.parse(Buffer.concat(chunks).toString("utf8"))).toMatchObject({ + ok: false, + error: { + code: "resource_constraint", + remediation: { + action: expect.stringContaining("Remove --token-count"), + }, + details: { + reported_limits: { + formatted_characters: expectedCharacters - 1, + max_string_characters: constants.MAX_STRING_LENGTH, + }, + }, + }, + meta: { command: "inspect-managed-members" }, + }); + }, 60_000); });