fix(reference): project import failures through the canonical error contract

This commit is contained in:
morluto committed 2026-10-24 02:35:22 +08:00
1 parent 4345a45982
commit ddb3987bb4
6 files changed
+121 -89

No files matched your search

+6
View File
@@ -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
@@ -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,
);
});
});
+84 -46
View File
@@ -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<ReferenceSourceImportError["code"], string>;
/** 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. */
-26
View File
@@ -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);
+2 -3
View File
@@ -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,
),
});
};
@@ -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,