Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cli/commands/build/build-error.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertRejects } from "#veryfront/testing/assert";
import { assertEquals, assertInstanceOf, assertRejects } from "#veryfront/testing/assert";
import { describe, it } from "#veryfront/testing/bdd";
import { VeryfrontError } from "veryfront/errors";
import { writeTextFile } from "#veryfront/compat/fs.ts";
import { buildCommand } from "./command.ts";
import { withTestContext } from "../../../tests/_helpers/context.ts";
Expand All @@ -13,9 +14,16 @@ describe("cli build", () => {
`export default { security: { cors: { origin: 123 } } };`,
);

await assertRejects(() =>
const error = await assertRejects(() =>
buildCommand({ projectDir: context.projectDir, dryRun: true } as any)
);

assertInstanceOf(error, VeryfrontError);
assertEquals(error.slug, "config-validation-failed");
assertEquals(
error.message.includes("Invalid veryfront.config at security.cors"),
true,
);
});
});
});
52 changes: 52 additions & 0 deletions cli/commands/build/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "#veryfront/schemas/_test-setup.ts";

import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { setVerboseMode } from "#cli/utils";
import { handleBuildError } from "./error-handler.ts";

describe("build/error-handler", () => {
Expand Down Expand Up @@ -54,5 +55,56 @@ describe("build/error-handler", () => {
true,
);
});

it("prints the underlying cause stack in verbose mode", () => {
const absoluteSourcePath = `${Deno.cwd()}/.cache/veryfront-http-bundle/http-deadbeef.mjs`;
const cause = new ReferenceError("document is not defined");
cause.stack = [
"ReferenceError: document is not defined",
` at file://${absoluteSourcePath}:3:7`,
" at renderAppRouteToHTML (static-generation.ts:401:13)",
].join("\n");
const error = new Error("Static site generation failed", { cause });
const originalError = console.error;
const output: string[] = [];

setVerboseMode(true);
try {
console.error = (...args: unknown[]) => output.push(args.map(String).join(" "));
assertThrows(() => handleBuildError(error));
} finally {
console.error = originalError;
setVerboseMode(false);
}

const rendered = output.join("\n");
assertEquals(rendered.includes("Underlying stack trace:"), true);
assertEquals(rendered.includes("ReferenceError: document is not defined"), true);
assertEquals(rendered.includes("<REDACTED>/http-deadbeef.mjs:3:7"), true);
assertEquals(rendered.includes(absoluteSourcePath), false);
});

it("points non-verbose failures with a cause to the diagnostic flag", () => {
const originalError = console.error;
const output: string[] = [];

try {
console.error = (...args: unknown[]) => output.push(args.map(String).join(" "));
assertThrows(() =>
handleBuildError(
new Error("Static site generation failed", {
cause: new ReferenceError("document is not defined"),
}),
)
);
} finally {
console.error = originalError;
}

assertEquals(
output.some((line) => line.includes("veryfront build --verbose")),
true,
);
});
});
});
48 changes: 45 additions & 3 deletions cli/commands/build/error-handler.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,59 @@
import { brand, dim } from "#cli/ui";
import { cliLogger, isVerbose, logError } from "#cli/utils";
import { sanitizeTerminalDiagnosticText } from "#veryfront/errors/safe-diagnostics.ts";
import { exit, getStdout } from "veryfront/platform";

const STACK_FRAME_WITH_PARENS =
/^(\s*at\s+.*\()(file:\/\/\/[^)\r\n]+|\/[^)\r\n]+|[A-Za-z]:[\\/][^)\r\n]+)(:\d+:\d+\).*)$/;
const STACK_FRAME_DIRECT =
/^(\s*at\s+(?:async\s+)?)(file:\/\/\/[^\r\n]+|\/[^\r\n]+|[A-Za-z]:[\\/][^\r\n]+)(:\d+:\d+.*)$/;

function sanitizeStackFrame(line: string): string {
const sanitized = sanitizeTerminalDiagnosticText(line);
const match = STACK_FRAME_WITH_PARENS.exec(sanitized) ??
STACK_FRAME_DIRECT.exec(sanitized);
if (!match) return sanitized;

const sourceName = match[2]!
.replace(/^file:\/\/\//, "")
.replaceAll("\\", "/")
.split("/")
.at(-1) || "source";
return `${match[1]}<REDACTED>/${sourceName}${match[3]}`;
}

function deepestErrorCause(error: Error): Error {
const seen = new Set<Error>();
let current = error;
for (let depth = 0; depth < 8; depth++) {
if (seen.has(current)) break;
seen.add(current);
const cause = Object.getOwnPropertyDescriptor(current, "cause");
if (!cause || !("value" in cause) || !(cause.value instanceof Error)) break;
current = cause.value;
}
return current;
}

export function handleBuildError(error: unknown): never {
getStdout()?.write?.(`\r${" ".repeat(80)}\r`);

const message = error instanceof Error ? error.message : String(error);
console.log();
logError(message);

if (isVerbose() && error instanceof Error && error.stack) {
cliLogger.error(`\n${dim("Stack trace:")}`);
cliLogger.error(dim(error.stack.split("\n").slice(1, 5).join("\n")));
const diagnosticError = error instanceof Error ? deepestErrorCause(error) : undefined;
if (isVerbose() && diagnosticError?.stack) {
const isUnderlyingCause = diagnosticError !== error;
cliLogger.error(`\n${dim(isUnderlyingCause ? "Underlying stack trace:" : "Stack trace:")}`);
const firstLine = isUnderlyingCause ? 0 : 1;
const stack = diagnosticError.stack.split("\n").slice(firstLine, 5)
.map(sanitizeStackFrame).join("\n");
cliLogger.error(dim(stack));
} else if (diagnosticError && diagnosticError !== error) {
cliLogger.error(
` Run ${brand("veryfront build --verbose")} to show the underlying stack trace.`,
);
}
cliLogger.error(` Run ${brand("veryfront build --help")} for usage.`);
cliLogger.error("");
Expand Down
28 changes: 28 additions & 0 deletions cli/commands/build/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,33 @@ describe("commands/build/handler", () => {
assertEquals(result.success, true);
if (result.success) assertEquals(result.data.dryRun, true);
});

it("rejects unknown options instead of silently ignoring them", () => {
const result = parseBuildArgs(
parseCliArgs(["build", "--totally-bogus-flag=xyz"]),
);

assertEquals(result.success, false);
if (!result.success) {
assertEquals(
result.error.message,
"Unknown option --totally-bogus-flag.",
);
}
});

it("suggests the closest documented option for a typo", () => {
const result = parseBuildArgs(
parseCliArgs(["build", "--output-dir", "custom-dist"]),
);

assertEquals(result.success, false);
if (!result.success) {
assertEquals(
result.error.message,
"Unknown option --output-dir. Did you mean --output?",
);
}
});
});
});
2 changes: 1 addition & 1 deletion cli/commands/build/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const parseBuildArgs = createArgParser(BuildArgsSchema, {
include: { keys: ["include"], type: "array" },
exclude: { keys: ["exclude"], type: "array" },
dryRun: CommonArgs.dryRun,
});
}, { rejectUnknown: true });

export async function handleBuildCommand(args: ParsedArgs): Promise<void> {
showHeader();
Expand Down
61 changes: 61 additions & 0 deletions cli/shared/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { Schema } from "veryfront/extensions/schema";
import { COMMANDS } from "../help/command-definitions.ts";
import { suggestCommand } from "./suggest.ts";
import type { ParsedArgs } from "./types.ts";

/** Compat type for safeParse result (SafeParseReturnType removed in zod v4). */
Expand All @@ -34,6 +35,60 @@ export type ArgMap<T> = {
[K in keyof T]?: ArgSpec;
};

export interface ArgParserOptions {
/** Reject option keys that the command parser does not consume. */
rejectUnknown?: boolean;
}

const ROUTER_ARG_KEYS = new Set([
"color",
"h",
"help",
"j",
"json",
"no-animation",
"no-color",
"no-input",
"o",
"output",
"q",
"quiet",
"v",
"verbose",
"version",
"y",
"yes",
]);

function optionName(key: string): string {
return `${key.length === 1 ? "-" : "--"}${key}`;
}

function validateKnownOptions<T>(
args: ParsedArgs,
argMap: ArgMap<T>,
): SafeParseResult<undefined> {
const commandKeys = (Object.values(argMap) as (ArgSpec | undefined)[])
.flatMap((spec) => spec?.keys ?? []);
const allowedKeys = new Set([...commandKeys, ...ROUTER_ARG_KEYS]);
const unknownKey = Object.keys(args).find((key) =>
key !== "_" && key !== "__explicit" && !allowedKeys.has(key)
);
if (!unknownKey) return { success: true, data: undefined };

const suggestion = suggestCommand(
unknownKey,
commandKeys.filter((key) => key.length > 1),
Math.min(4, Math.max(2, Math.ceil(unknownKey.length * 0.35))),
)[0];
const hint = suggestion ? ` Did you mean ${optionName(suggestion)}?` : "";
const error = Object.assign(
new Error(`Unknown option ${optionName(unknownKey)}.${hint}`),
{ issues: [] },
);
return { success: false, error };
}

function coerceValue(
value: unknown,
type: ArgSpec["type"],
Expand Down Expand Up @@ -119,8 +174,14 @@ export function extractArgs<T>(
export function createArgParser<T>(
schema: Schema<T>,
argMap: ArgMap<T>,
options: ArgParserOptions = {},
): (args: ParsedArgs) => SafeParseResult<T> {
return function parseArgs(args: ParsedArgs): SafeParseResult<T> {
if (options.rejectUnknown) {
const knownOptions = validateKnownOptions(args, argMap);
if (!knownOptions.success) return knownOptions;
}

const result = schema.safeParse(extractArgs(args, argMap));
if (result.success) {
return { success: true, data: result.data };
Expand Down