Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/validation-error-code-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@valbuild/server": patch
"@valbuild/cli": patch
---

Validation errors from the CLI now point at the offending location in the `.val.ts` file. Each error shows a clickable `file:line:col` and a code frame (the line above, the offending line, and the line below) with carets underlining the exact source. The carets target the **value** by default and the **key** when the error is about an object/record key (`keyError`), and the output is labelled `(key)` / `(value)` accordingly. Gallery metadata errors now point at the specific record entry instead of the whole module.

The module-path → source-range utility used for this (`createModulePathMap`, `getModulePathRange`, `ModulePathMap`) is now exported from `@valbuild/server`.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@valbuild/server": "workspace:*",
"@valbuild/shared": "workspace:*",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"cors": "^2.8.5",
"fast-glob": "^3.3.3",
"meow": "^9.0.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/__fixtures__/basic/val.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { initVal } from "@valbuild/core";

const { s, c } = initVal();
const { s, c, config } = initVal();

export { s, c };
export { s, c, config };
16 changes: 16 additions & 0 deletions packages/cli/src/__fixtures__/basic/val.modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { modules } from "@valbuild/core";
import { config } from "./val.config";

export default modules(config, [
{ def: () => import("./content/basic-valid.val") },
{ def: () => import("./content/basic-errors.val") },
{ def: () => import("./content/basic-files.val") },
{ def: () => import("./content/basic-image.val") },
{ def: () => import("./content/basic-image-from-gallery.val") },
{ def: () => import("./content/basic-image-from-galleries.val") },
{ def: () => import("./content/basic-gallery.val") },
{ def: () => import("./content/basic-gallery-2.val") },
{ def: () => import("./content/basic-gallery-fail-on-non-unique-dir.val") },
{ def: () => import("./content/basic-gallery-missing-tracked.val") },
{ def: () => import("./content/basic-gallery-wrong-metadata.val") },
]);
11 changes: 11 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ async function main(): Promise<void> {
Options:
--root [root], -r [root] Set project root directory (default process.cwd())
--fix [fix] Attempt to fix validation errors
--watch, -w Re-validate on changes to val.config, val.modules and *.val files


Command: login
Expand Down Expand Up @@ -62,6 +63,10 @@ async function main(): Promise<void> {
fix: {
type: "boolean",
},
watch: {
type: "boolean",
alias: "w",
},
noEslint: {
type: "boolean",
},
Expand Down Expand Up @@ -112,9 +117,15 @@ async function main(): Promise<void> {
if (flags.managedDir) {
return error(`Command "validate" does not support --managedDir flag`);
}
if (flags.watch && flags.fix) {
return error(
`Command "validate" does not support --watch together with --fix`,
);
}
return validate({
root: flags.root,
fix: flags.fix,
watch: flags.watch,
});
default:
return error(`Unknown command "${input.join(" ")}"`);
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/listUnusedFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function listUnusedFiles({ root }: { root?: string }) {
);

const service = await createService(projectRoot, {});
const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());

const valFiles: string[] = await glob("**/*.val.{js,ts}", {
ignore: ["node_modules/**"],
Expand All @@ -32,10 +33,12 @@ export async function listUnusedFiles({ root }: { root?: string }) {
const filesUsedByVal: string[] = [];
async function pushFilesUsedByVal(file: string) {
const moduleId = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
if (!registered.has(moduleId)) {
// Not registered in val.modules - skip (e.g. reusable schema fragments).
return;
}
const valModule = await service.get(moduleId, "" as ModulePath, {
validate: true,
source: true,
schema: true,
});
// TODO: not sure using validation is the best way to do this, but it works currently.
if (valModule.errors) {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/runValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ describe("runValidation", () => {
const result = await service.get(
"/content/basic-gallery-missing-tracked.val.ts" as ModuleFilePath,
"" as ModulePath,
{ source: true, schema: true, validate: true },
{ validate: true },
);
expect(result.source).not.toHaveProperty(
"/public/val/images4/missing.png",
Expand Down Expand Up @@ -327,7 +327,7 @@ describe("runValidation", () => {
const result = await service.get(
"/content/basic-gallery-wrong-metadata.val.ts" as ModuleFilePath,
"" as ModulePath,
{ source: true, schema: true, validate: true },
{ validate: true },
);
expect(result.source).toMatchObject({
"/public/val/images3/image.png": {
Expand Down Expand Up @@ -361,7 +361,7 @@ describe("runValidation", () => {
const result = await service.get(
"/content/basic-image.val.ts" as ModuleFilePath,
"" as ModulePath,
{ source: true, schema: true, validate: true },
{ validate: true },
);
// The schema always emits image:check-metadata when metadata exists
// (actual metadata verification happens in the fix handler).
Expand Down
47 changes: 36 additions & 11 deletions packages/cli/src/runValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export type ValidationError = {
message: string;
value?: unknown;
fixes?: ValidationFix[];
// True when the error is about an object/record key rather than its value.
keyError?: boolean;
};

export type FixHandlerContext = {
Expand Down Expand Up @@ -109,14 +111,26 @@ export type ValidationEvent =
errorCount: number;
durationMs: number;
}
| { type: "validation-error"; sourcePath: string; message: string }
| {
type: "validation-error";
sourcePath: string;
message: string;
keyError?: boolean;
}
| {
type: "validation-fixable-error";
sourcePath: string;
message: string;
fixable: boolean;
keyError?: boolean;
}
| { type: "unknown-fix"; sourcePath: string; fixes: string[] }
| {
type: "unknown-fix";
sourcePath: string;
fixes: string[];
keyError?: boolean;
}
| { type: "unregistered-module"; file: string }
| { type: "fix-applied"; file: string; sourcePath: string }
| { type: "fatal-error"; file: string; message: string }
| { type: "remote-uploading"; ref: string }
Expand Down Expand Up @@ -443,7 +457,7 @@ export async function handleUniqueFolderCheck(
const otherModule = await ctx.service.get(
otherModuleFilePath,
"" as ModulePath,
{ source: false, schema: true, validate: false },
{ validate: false },
);
const schema = otherModule.schema as
| { type?: string; directory?: string; mediaType?: string }
Expand Down Expand Up @@ -604,17 +618,19 @@ export async function* runValidation({

const service = await createService(projectRoot, {}, fs);

// Modules registered in the project's val.modules. Files found on disk that
// are not registered here are not validated (a warning is emitted instead).
const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());

let errors = 0;

// Build a single schema/source snapshot up front so the shared resolver
// can resolve keyof:check-keys / router:check-route references that span
// multiple val files.
// multiple val files. Use the full registry so cross-module references
// resolve even against modules not in the validated subset.
const snapshot: SchemaSourceSnapshot = { schemas: {}, sources: {} };
for (const file of valFiles) {
const moduleFilePath = `/${file}` as ModuleFilePath;
for (const moduleFilePath of registered) {
const valModule = await service.get(moduleFilePath, "" as ModulePath, {
source: true,
schema: true,
validate: false,
});
if (valModule.schema) {
Expand All @@ -627,10 +643,12 @@ export async function* runValidation({

async function* validateFile(file: string): AsyncGenerator<ValidationEvent> {
const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
if (!registered.has(moduleFilePath)) {
yield { type: "unregistered-module", file };
return;
}
const start = Date.now();
const valModule = await service.get(moduleFilePath, "" as ModulePath, {
source: true,
schema: true,
validate: true,
});
const remoteFiles: Record<
Expand Down Expand Up @@ -670,6 +688,7 @@ export async function* runValidation({
type: "validation-error",
sourcePath,
message: v.message,
...(v.keyError ? { keyError: true } : {}),
};
continue;
}
Expand All @@ -683,6 +702,7 @@ export async function* runValidation({
type: "unknown-fix",
sourcePath,
fixes: v.fixes,
...(v.keyError ? { keyError: true } : {}),
};
fileErrors += 1;
continue;
Expand Down Expand Up @@ -728,6 +748,7 @@ export async function* runValidation({
type: "validation-error",
sourcePath,
message: result.errorMessage ?? "Unknown error",
...(v.keyError ? { keyError: true } : {}),
};
fileErrors += 1;
continue;
Expand Down Expand Up @@ -760,16 +781,20 @@ export async function* runValidation({
sourcePath,
message: v.message,
fixable: true,
...(v.keyError ? { keyError: true } : {}),
};
}

for (const e of fixPatch?.remainingErrors ?? []) {
fileErrors += 1;
yield {
type: "validation-fixable-error",
sourcePath,
// Gallery checks expand into per-entry errors that point at
// the individual entry; fall back to the record sourcePath.
sourcePath: e.sourcePath ?? sourcePath,
message: e.message,
fixable: !!(e.fixes && e.fixes.length),
...(e.keyError ? { keyError: true } : {}),
};
}
}
Expand Down
Loading
Loading