diff --git a/src/cli/referenceSourceImportStatus.ts b/src/cli/referenceSourceImportStatus.ts new file mode 100644 index 000000000..11f77047b --- /dev/null +++ b/src/cli/referenceSourceImportStatus.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { isCliOperationFailure } from "../cliLogging.js"; + +const referenceSourceImportFailureSchema = z.strictObject({ + error: z.literal("Import failed"), + category: z.enum(["cancelled", "invalid_input", "execution_failure"]), + message: z.string().min(1), +}); + +/** Identify the historical-source import command's failed operation outputs. */ +export const isReferenceSourceImportCliFailure = (value: unknown): boolean => + referenceSourceImportFailureSchema.safeParse(value).success || + isCliOperationFailure(value); diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index d5ee22407..d5b723d01 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -14,6 +14,7 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import type { Logger } from "../logger.js"; +import { isReferenceSourceImportCliFailure } from "./referenceSourceImportStatus.js"; import type { CliInstance } from "./types.js"; export const registerUtilityCommands = ( @@ -181,26 +182,31 @@ const registerReferenceSourceCommand = ( .describe("Local source directory to import as historical reference"), }), run: ({ args }) => - logCliCommand(logger, "import-reference-source", async () => { - const config = parseConfig(environment); - if (!config.ok) - return { - error: "Import failed", - ...projectAnalysisError(config.error), - }; - const imported = await importReferenceSource({ - root: args.root, - caller: "rea-cli", - policy: config.value.referenceSourcePolicy, - importer: PRODUCT_IDENTITY.packageName, - importerVersion: null, - }); - return imported.ok - ? imported.value - : { + logCliCommand( + logger, + "import-reference-source", + async () => { + const config = parseConfig(environment); + if (!config.ok) + return { error: "Import failed", - ...projectReferenceSourceImportError(imported.error), + ...projectAnalysisError(config.error), }; - }), + const imported = await importReferenceSource({ + root: args.root, + caller: "rea-cli", + policy: config.value.referenceSourcePolicy, + importer: PRODUCT_IDENTITY.packageName, + importerVersion: null, + }); + return imported.ok + ? imported.value + : { + error: "Import failed", + ...projectReferenceSourceImportError(imported.error), + }; + }, + isReferenceSourceImportCliFailure, + ), }); }; diff --git a/src/cliReferenceSourceImportStatus.test.ts b/src/cliReferenceSourceImportStatus.test.ts new file mode 100644 index 000000000..1f19457c3 --- /dev/null +++ b/src/cliReferenceSourceImportStatus.test.ts @@ -0,0 +1,125 @@ +import pino from "pino"; +import { describe, expect, it } from "vitest"; + +import { + projectReferenceSourceImportError, + type ReferenceSourceImportError, +} from "./application/ReferenceSourceImportTypes.js"; +import { isReferenceSourceImportCliFailure } from "./cli/referenceSourceImportStatus.js"; +import { isCliOperationFailure, logCliCommand } from "./cliLogging.js"; +import { ConfigurationError } from "./domain/configurationErrors.js"; +import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; + +const importFailure = (code: ReferenceSourceImportError["code"]) => ({ + error: "Import failed", + ...projectReferenceSourceImportError({ + tag: "reference-source-import", + code, + message: "Internal import diagnostic", + }), +}); + +const configurationFailure = { + error: "Import failed", + ...projectAnalysisError(new ConfigurationError("Invalid import setting")), +}; + +const invalidRootFailure = importFailure("invalid-root"); + +const successfulValues: readonly (readonly [string, unknown])[] = [ + ["undefined", undefined], + ["null", null], + ["array", [invalidRootFailure]], + ["string", "Import failed"], + ["missing error label", { category: "invalid_input", message: "failure" }], + ["missing category", { error: "Import failed", message: "failure" }], + ["missing message", { error: "Import failed", category: "invalid_input" }], + ["empty message", { ...invalidRootFailure, message: "" }], + ["non-string message", { ...invalidRootFailure, message: 1 }], + ["null message", { ...invalidRootFailure, message: null }], + ["empty category", { ...invalidRootFailure, category: "" }], + ["non-string category", { ...invalidRootFailure, category: 1 }], + ["near category", { ...invalidRootFailure, category: "invalid-root" }], + ["foreign category", { ...invalidRootFailure, category: "timeout" }], + ["near error label", { ...invalidRootFailure, error: "Import failure" }], + ["label suffix", { ...invalidRootFailure, error: "Import failed " }], + ["empty error label", { ...invalidRootFailure, error: "" }], + ["non-string error label", { ...invalidRootFailure, error: true }], + ["extra field", { ...invalidRootFailure, source: "ordinary data" }], + ["extra undefined field", { ...invalidRootFailure, source: undefined }], + ["nested legacy error", { error: invalidRootFailure }], + ["ordinary error-like data", { error: "source error", message: "data" }], + [ + "ordinary code and message", + { code: "configuration_invalid", message: "data" }, + ], + ["nested evidence", { result: invalidRootFailure }], + ["partial result", { status: "partial" }], + ["unsupported result", { status: "unsupported" }], + ["successful result", { status: "complete", entries: [] }], +]; + +describe("reference-source import CLI failure classification", () => { + it.each(["cancelled", "invalid-root", "io", "parse"] as const)( + "recognizes the projected %s failure only at this command boundary", + (code) => { + const value = importFailure(code); + expect(isCliOperationFailure(value)).toBe(false); + expect(isReferenceSourceImportCliFailure(value)).toBe(true); + }, + ); + + it("retains canonical configuration failure classification", () => { + expect(isReferenceSourceImportCliFailure(configurationFailure)).toBe(true); + }); + + it.each(successfulValues)("keeps %s successful", (_label, value) => { + expect(isReferenceSourceImportCliFailure(value)).toBe(false); + }); +}); + +describe("reference-source import CLI logging seam", () => { + it.each([ + ["cancelled", importFailure("cancelled"), true], + ["invalid-root", invalidRootFailure, true], + ["io", importFailure("io"), true], + ["parse", importFailure("parse"), true], + ["configuration", configurationFailure, true], + ["success", { status: "complete", entries: [] }, false], + ] as const)( + "preserves %s output and logs its status", + async (_label, value, failed) => { + const records: unknown[] = []; + const logger = pino( + { level: "info" }, + { + write: (message) => { + records.push(JSON.parse(message) as unknown); + }, + }, + ); + const previousExitCode = process.exitCode; + try { + process.exitCode = undefined; + const output = await logCliCommand( + logger, + "import-reference-source", + async () => value, + isReferenceSourceImportCliFailure, + ); + expect(output).toBe(value); + expect.soft(process.exitCode).toBe(failed ? 1 : undefined); + expect(records).toEqual([ + expect.objectContaining({ + command: "import-reference-source", + status: failed ? "error" : "ok", + level: failed ? 50 : 30, + msg: failed ? "CLI command failed" : "CLI command completed", + }), + ]); + } finally { + process.exitCode = previousExitCode; + } + }, + ); +}); diff --git a/tests/boundary/cli/referenceSourceImportStatus.test.ts b/tests/boundary/cli/referenceSourceImportStatus.test.ts new file mode 100644 index 000000000..47aab8517 --- /dev/null +++ b/tests/boundary/cli/referenceSourceImportStatus.test.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const invalidRootOutput = { + error: "Import failed", + category: "invalid_input", + message: + "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", +}; + +describe("compiled reference-source import preflight failures", () => { + for (const rootKind of ["missing", "regular-file"] as const) { + for (const fullOutput of [false, true]) { + for (const logging of [false, true]) { + cliTest( + `${rootKind} root, ${fullOutput ? "full-output" : "JSON"}, logging ${String(logging)}`, + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-reference-cli-status-", + ); + const root = join(directory, "input"); + if (rootKind === "regular-file") + await writeFile(root, "inert input\n"); + const result = await cli.run({ + arguments: [ + "import-reference-source", + root, + "--json", + ...(fullOutput ? ["--full-output"] : []), + ], + cwd: directory, + environment: { + HOME: directory, + USERPROFILE: directory, + XDG_CONFIG_HOME: directory, + XDG_CACHE_HOME: directory, + ...(logging ? { REA_LOG_LEVEL: "info" } : {}), + }, + }); + if (fullOutput) { + // Incur's legacy wrapper remains unchanged; operation status is + // conveyed by the exit code and command log, not this `ok` field. + expect(result.json).toMatchObject({ + ok: true, + data: invalidRootOutput, + }); + expect(result.json).toHaveProperty("data", invalidRootOutput); + } else { + expect(result.json).toEqual(invalidRootOutput); + } + expect.soft(result.exitCode).toBe(1); + if (logging) { + const records: unknown[] = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line) as unknown); + expect(records).toEqual([ + expect.objectContaining({ + command: "import-reference-source", + status: "error", + level: 50, + msg: "CLI command failed", + }), + ]); + } else { + expect(result.stderr).toBe(""); + } + }, + ); + } + } + } +});