From ddb3987bb4b01f6f6dd09614938da7c2373dcfde Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:29:14 +0800 Subject: [PATCH] fix(reference): project import failures through the canonical error contract --- docs/cli.md | 6 + .../ReferenceSourceImportTypes.test.ts | 30 ++-- src/application/ReferenceSourceImportTypes.ts | 130 +++++++++++------- src/cli/referenceSourceImportStatus.ts | 26 ---- src/cli/utilityCommands.ts | 5 +- .../cli/referenceSourceImportStatus.test.ts | 13 +- 6 files changed, 121 insertions(+), 89 deletions(-) delete mode 100644 src/cli/referenceSourceImportStatus.ts diff --git a/docs/cli.md b/docs/cli.md index a1470e84f8..9d40e4a47b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -404,6 +404,12 @@ output, not the amount of source examined. A shallow tree or many exclusions can still produce a large summary; depth 0 removes the outline but preserves the other facts. No fixed output-size or runtime improvement is promised. +Import failures use the shared error contract (`code`, `category`, `message`, +`retryable`, and `remediation`). When reads completed before failure, +`details.diagnostics.partial` retains the reader facts, with file bytes as +arrays of byte values. Unresolved cleanup uses `cleanup_incomplete` and +retains the source failure and resource paths in `details`. + The import records hashes and metadata for the supplied files, separately from observations of the current app. For a directory inside a Git checkout, it records the HEAD of the nearest enclosing repository; whether the working diff --git a/src/application/ReferenceSourceImportTypes.test.ts b/src/application/ReferenceSourceImportTypes.test.ts index 1bc4b25dd9..1ddfe70eb6 100644 --- a/src/application/ReferenceSourceImportTypes.test.ts +++ b/src/application/ReferenceSourceImportTypes.test.ts @@ -4,6 +4,7 @@ import { projectReferenceSourceImportError, type ReferenceSourceImportError, } from "./ReferenceSourceImportTypes.js"; +import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; import type { ReferenceSourceRead } from "../reference/ReferenceSourceReaderTypes.js"; describe("reference-source import error projection", () => { @@ -11,7 +12,7 @@ describe("reference-source import error projection", () => { const expectedCategories = { cancelled: "cancelled", "invalid-root": "invalid_input", - unsupported: "unsupported_host", + unsupported: "unsupported_provider", io: "execution_failure", parse: "execution_failure", } as const; @@ -21,6 +22,9 @@ describe("reference-source import error projection", () => { code: code as ReferenceSourceImportError["code"], message: "EIO while reading /selected/source/src/main.ts", }); + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe( + true, + ); expect(projected.category).toBe(category); expect(projected.message).toContain( "EIO while reading /selected/source/src/main.ts", @@ -63,18 +67,22 @@ describe("reference-source import error projection", () => { message: expect.stringContaining( "Traversal cancelled after reading src/main.ts", ), - cleanup: { - reason: "directory close rejected", + code: "cleanup_incomplete", + details: { + cleanup: "incomplete", + cleanup_reason: "directory close rejected", resources: ["/selected/source"], - }, - partial: { - root: "/selected/source", - entries: [{ path: "src/main.ts", bytes }], + diagnostics: { + reference_source_code: "cancelled", + partial: { + root: "/selected/source", + entries: [{ path: "src/main.ts", bytes: Array.from(bytes) }], + }, + }, }, }); - expect(projected.message).toContain("Cleanup failed during import"); - expect(projected.message).toContain("directory close rejected"); - expect(projected.message).toContain("/selected/source"); - expect(projected.message).not.toContain("remains unconfirmed"); + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe( + true, + ); }); }); diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index f8474315af..45236c64dd 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -1,5 +1,19 @@ +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { + projectAnalysisError, + type AnalysisErrorProjection, +} from "../domain/analysisErrorProjection.js"; +import type { + AnalysisError, + AnalysisErrorOptions, + AnalysisCleanupObservation, +} from "../domain/analysisErrorBase.js"; import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; -import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import type { ReferenceSourceRead } from "../reference/ReferenceSourceReaderTypes.js"; /** Typed expected failure returned by historical-source imports. */ @@ -13,56 +27,80 @@ export interface ReferenceSourceImportError { } const IMPORT_FAILURE_GUIDANCE = { - cancelled: { - category: "cancelled", - message: - "Reference source import was cancelled. Start it again when ready.", - }, - "invalid-root": { - category: "invalid_input", - message: - "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", - }, - unsupported: { - category: "unsupported_host", - message: - "Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.", - }, - io: { - category: "execution_failure", - message: - "Reference source files could not be read. Check directory permissions and try again.", - }, - parse: { - category: "execution_failure", - message: - "Reference source could not be indexed. Check that the source tree is readable, then try again.", - }, -} satisfies Record< - ReferenceSourceImportError["code"], - { readonly category: string; readonly message: string } ->; + cancelled: + "Reference source import was cancelled. Start it again when ready.", + "invalid-root": + "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", + unsupported: + "Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.", + io: "Reference source files could not be read. Check directory permissions and try again.", + parse: + "Reference source could not be indexed. Check that the source tree is readable, then try again.", +} satisfies Record; -/** Safe CLI projection for a historical-source import failure. */ +/** Canonical CLI/MCP projection, retaining source facts under diagnostic details. */ export const projectReferenceSourceImportError = ( error: ReferenceSourceImportError, -): Readonly<{ - category: string; - message: string; - cleanup?: AnalysisCleanupObservation; - partial?: ReferenceSourceRead; -}> => { - const primary = IMPORT_FAILURE_GUIDANCE[error.code]; - const cleanupMessage = - error.cleanup === undefined - ? "" - : ` Cleanup failed during import for ${error.cleanup.resources.join(", ")}: ${error.cleanup.reason}.`; - return { - category: primary.category, - message: `${error.message} ${primary.message}${cleanupMessage}`, +): AnalysisErrorProjection => { + const guidance = IMPORT_FAILURE_GUIDANCE[error.code]; + const message = `${error.message} ${guidance}`; + const partial = error.partial; + const options: AnalysisErrorOptions = { + cause: error.cause, ...(error.cleanup === undefined ? {} : { cleanup: error.cleanup }), - ...(error.partial === undefined ? {} : { partial: error.partial }), + diagnostics: { + reference_source_code: error.code, + ...(partial === undefined + ? {} + : { + partial: { + root: partial.root, + ...(partial.rootFailure === undefined + ? {} + : { rootFailure: { ...partial.rootFailure } }), + entries: partial.entries.map((entry) => + entry.status === "read" && entry.kind === "file" + ? { ...entry, bytes: Array.from(entry.bytes) } + : { ...entry }, + ), + bytesRead: partial.bytesRead, + limitations: [...partial.limitations], + }, + }), + }, }; + let primary: AnalysisError; + switch (error.code) { + case "cancelled": + primary = new AnalysisCancelledError("import_reference_source", options); + break; + case "invalid-root": + primary = new AnalysisInputError( + "import_reference_source", + { ...options, userMessage: message }, + [{ path: ["root"], reason: "invalid_value", message: error.message }], + ); + break; + case "unsupported": + primary = new AnalysisCapabilityUnavailableError( + "filesystem", + "import_reference_source", + error.message, + { ...options, userMessage: message, remediationAction: guidance }, + ); + break; + case "io": + case "parse": + primary = new AnalysisOutputError( + "import_reference_source", + error.message, + { ...options, userMessage: message, remediationAction: guidance }, + ); + break; + } + // Cancellation uses a shared fixed message; the producing boundary's reason + // is caller evidence and remains useful for every category. + return { ...projectAnalysisError(primary), message }; }; /** Import a caller-selected local source tree as historical reference. */ diff --git a/src/cli/referenceSourceImportStatus.ts b/src/cli/referenceSourceImportStatus.ts deleted file mode 100644 index c9f8e7087c..0000000000 --- a/src/cli/referenceSourceImportStatus.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod"; - -import { isCliOperationFailure } from "../cliLogging.js"; - -const referenceSourceImportFailureSchema = z.strictObject({ - error: z.literal("Import failed"), - category: z.enum([ - "cancelled", - "invalid_input", - "unsupported_host", - "execution_failure", - ]), - message: z.string().min(1), - cleanup: z - .strictObject({ - reason: z.string().min(1), - resources: z.array(z.string().min(1)), - }) - .optional(), - partial: z.unknown().optional(), -}); - -/** 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 a472f7b2d5..aa2b105e08 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -19,11 +19,10 @@ import { nativeUiDeadlineField, } from "../domain/native/nativeDeadlines.js"; import { PRODUCT_IDENTITY } from "../identity.js"; -import { logCliCommand } from "../cliLogging.js"; +import { isCliOperationFailure, logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { swiftSymbolsSchema } from "../contracts/native/nativeToolContracts.js"; import type { Logger } from "pino"; -import { isReferenceSourceImportCliFailure } from "./referenceSourceImportStatus.js"; import type { CliInstance } from "./types.js"; import { registerNativeCallCommands } from "./nativeCallCommands.js"; @@ -339,7 +338,7 @@ const registerReferenceSourceCommand = ( : undefined, ); }, - isReferenceSourceImportCliFailure, + isCliOperationFailure, ), }); }; diff --git a/tests/boundary/cli/referenceSourceImportStatus.test.ts b/tests/boundary/cli/referenceSourceImportStatus.test.ts index 7d309958f1..7dc11c9adf 100644 --- a/tests/boundary/cli/referenceSourceImportStatus.test.ts +++ b/tests/boundary/cli/referenceSourceImportStatus.test.ts @@ -4,15 +4,16 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -const unsupportedHostOutput = { +const unsupportedHostOutput = expect.objectContaining({ error: "Import failed", - category: "unsupported_host", + category: "unsupported_provider", message: expect.stringContaining( "Safe no-follow file opens are unavailable on this host.", ), -}; +}); const permissionChecksUnavailable = process.platform === "win32" || process.getuid?.() === 0; @@ -120,6 +121,12 @@ describe("compiled reference-source import preflight failures", () => { } else { expect(result.json).toMatchObject(invalidRootOutput); } + const projection = scenario.fullOutput + ? (result.json as { error: unknown }).error + : result.json; + expect( + analysisCliErrorEnvelopeSchema.safeParse(projection).success, + ).toBe(true); const messagePath = scenario.fullOutput ? "error.message" : "message"; expect(result.json).toHaveProperty( messagePath,