@valbuild/cli 0.105.1 → 0.106.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -71,8 +71,13 @@ async function* runValidation({
71
71
  const service = await server.createService(projectRoot, fs);
72
72
 
73
73
  // Modules registered in the project's val.modules. Files found on disk that
74
- // are not registered here are not validated (a warning is emitted instead).
74
+ // are not registered here are not validated; what (if anything) is reported
75
+ // for them is decided by `reportUnregistered` below.
75
76
  const registered = new Set(service.getModuleFilePaths());
77
+
78
+ // Only used for files that are NOT registered, so it never duplicates the
79
+ // evaluation `createService` already did.
80
+ const inspectValModuleFile = server.createValModuleFileInspector(projectRoot, fs);
76
81
  let errors = 0;
77
82
 
78
83
  // Build a single schema/source snapshot up front so the shared resolver
@@ -94,15 +99,51 @@ async function* runValidation({
94
99
  snapshot.sources[moduleFilePath] = valModule.source;
95
100
  }
96
101
  }
97
- async function* validateFile(file) {
98
- const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
99
- if (!registered.has(moduleFilePath)) {
102
+
103
+ /**
104
+ * What to report for a `*.val.ts` file that val.modules does not register.
105
+ *
106
+ * The `*.val.ts` suffix is used for more than Val modules — shared schemas and
107
+ * other content-adjacent helpers wear it too — and those are not meant to be
108
+ * registered, so warning about every unregistered file buries the one warning
109
+ * that matters under a wall of noise. The default export is what separates
110
+ * them: a file that default exports a module is one someone meant to register
111
+ * and forgot (a warning), a file that default exports something else can never
112
+ * be loaded by Val under that name (an error), and a file with no default
113
+ * export is simply not a module (silence).
114
+ */
115
+ async function* reportUnregistered(file) {
116
+ const start = Date.now();
117
+ const inspection = inspectValModuleFile(path__default["default"].join(projectRoot, file));
118
+ if (inspection.status === "no-default-export") {
119
+ return;
120
+ }
121
+ if (inspection.status === "val-module") {
100
122
  yield {
101
123
  type: "unregistered-module",
102
124
  file
103
125
  };
104
126
  return;
105
127
  }
128
+ errors += 1;
129
+ yield {
130
+ type: "fatal-error",
131
+ file: `/${file}`,
132
+ message: inspection.message
133
+ };
134
+ yield {
135
+ type: "file-error-count",
136
+ file: `/${file}`,
137
+ errorCount: 1,
138
+ durationMs: Date.now() - start
139
+ };
140
+ }
141
+ async function* validateFile(file) {
142
+ const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
143
+ if (!registered.has(moduleFilePath)) {
144
+ yield* reportUnregistered(file);
145
+ return;
146
+ }
106
147
  const start = Date.now();
107
148
  const valModule = await service.get(moduleFilePath, "", {
108
149
  validate: true
@@ -71,8 +71,13 @@ async function* runValidation({
71
71
  const service = await server.createService(projectRoot, fs);
72
72
 
73
73
  // Modules registered in the project's val.modules. Files found on disk that
74
- // are not registered here are not validated (a warning is emitted instead).
74
+ // are not registered here are not validated; what (if anything) is reported
75
+ // for them is decided by `reportUnregistered` below.
75
76
  const registered = new Set(service.getModuleFilePaths());
77
+
78
+ // Only used for files that are NOT registered, so it never duplicates the
79
+ // evaluation `createService` already did.
80
+ const inspectValModuleFile = server.createValModuleFileInspector(projectRoot, fs);
76
81
  let errors = 0;
77
82
 
78
83
  // Build a single schema/source snapshot up front so the shared resolver
@@ -94,15 +99,51 @@ async function* runValidation({
94
99
  snapshot.sources[moduleFilePath] = valModule.source;
95
100
  }
96
101
  }
97
- async function* validateFile(file) {
98
- const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
99
- if (!registered.has(moduleFilePath)) {
102
+
103
+ /**
104
+ * What to report for a `*.val.ts` file that val.modules does not register.
105
+ *
106
+ * The `*.val.ts` suffix is used for more than Val modules — shared schemas and
107
+ * other content-adjacent helpers wear it too — and those are not meant to be
108
+ * registered, so warning about every unregistered file buries the one warning
109
+ * that matters under a wall of noise. The default export is what separates
110
+ * them: a file that default exports a module is one someone meant to register
111
+ * and forgot (a warning), a file that default exports something else can never
112
+ * be loaded by Val under that name (an error), and a file with no default
113
+ * export is simply not a module (silence).
114
+ */
115
+ async function* reportUnregistered(file) {
116
+ const start = Date.now();
117
+ const inspection = inspectValModuleFile(path__default["default"].join(projectRoot, file));
118
+ if (inspection.status === "no-default-export") {
119
+ return;
120
+ }
121
+ if (inspection.status === "val-module") {
100
122
  yield {
101
123
  type: "unregistered-module",
102
124
  file
103
125
  };
104
126
  return;
105
127
  }
128
+ errors += 1;
129
+ yield {
130
+ type: "fatal-error",
131
+ file: `/${file}`,
132
+ message: inspection.message
133
+ };
134
+ yield {
135
+ type: "file-error-count",
136
+ file: `/${file}`,
137
+ errorCount: 1,
138
+ durationMs: Date.now() - start
139
+ };
140
+ }
141
+ async function* validateFile(file) {
142
+ const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
143
+ if (!registered.has(moduleFilePath)) {
144
+ yield* reportUnregistered(file);
145
+ return;
146
+ }
106
147
  const start = Date.now();
107
148
  const valModule = await service.get(moduleFilePath, "", {
108
149
  validate: true
@@ -5,7 +5,7 @@ import pc from 'picocolors';
5
5
  import fs$1 from 'fs/promises';
6
6
  import { glob } from 'fast-glob';
7
7
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
8
- import { createService, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError } from '@valbuild/server';
8
+ import { createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError } from '@valbuild/server';
9
9
  import { resolveSchemaSourceFixes } from '@valbuild/shared/internal';
10
10
  import fs from 'fs';
11
11
  import ts from 'typescript';
@@ -39,8 +39,13 @@ async function* runValidation({
39
39
  const service = await createService(projectRoot, fs);
40
40
 
41
41
  // Modules registered in the project's val.modules. Files found on disk that
42
- // are not registered here are not validated (a warning is emitted instead).
42
+ // are not registered here are not validated; what (if anything) is reported
43
+ // for them is decided by `reportUnregistered` below.
43
44
  const registered = new Set(service.getModuleFilePaths());
45
+
46
+ // Only used for files that are NOT registered, so it never duplicates the
47
+ // evaluation `createService` already did.
48
+ const inspectValModuleFile = createValModuleFileInspector(projectRoot, fs);
44
49
  let errors = 0;
45
50
 
46
51
  // Build a single schema/source snapshot up front so the shared resolver
@@ -62,15 +67,51 @@ async function* runValidation({
62
67
  snapshot.sources[moduleFilePath] = valModule.source;
63
68
  }
64
69
  }
65
- async function* validateFile(file) {
66
- const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
67
- if (!registered.has(moduleFilePath)) {
70
+
71
+ /**
72
+ * What to report for a `*.val.ts` file that val.modules does not register.
73
+ *
74
+ * The `*.val.ts` suffix is used for more than Val modules — shared schemas and
75
+ * other content-adjacent helpers wear it too — and those are not meant to be
76
+ * registered, so warning about every unregistered file buries the one warning
77
+ * that matters under a wall of noise. The default export is what separates
78
+ * them: a file that default exports a module is one someone meant to register
79
+ * and forgot (a warning), a file that default exports something else can never
80
+ * be loaded by Val under that name (an error), and a file with no default
81
+ * export is simply not a module (silence).
82
+ */
83
+ async function* reportUnregistered(file) {
84
+ const start = Date.now();
85
+ const inspection = inspectValModuleFile(path.join(projectRoot, file));
86
+ if (inspection.status === "no-default-export") {
87
+ return;
88
+ }
89
+ if (inspection.status === "val-module") {
68
90
  yield {
69
91
  type: "unregistered-module",
70
92
  file
71
93
  };
72
94
  return;
73
95
  }
96
+ errors += 1;
97
+ yield {
98
+ type: "fatal-error",
99
+ file: `/${file}`,
100
+ message: inspection.message
101
+ };
102
+ yield {
103
+ type: "file-error-count",
104
+ file: `/${file}`,
105
+ errorCount: 1,
106
+ durationMs: Date.now() - start
107
+ };
108
+ }
109
+ async function* validateFile(file) {
110
+ const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
111
+ if (!registered.has(moduleFilePath)) {
112
+ yield* reportUnregistered(file);
113
+ return;
114
+ }
74
115
  const start = Date.now();
75
116
  const valModule = await service.get(moduleFilePath, "", {
76
117
  validate: true
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.105.1",
4
+ "version": "0.106.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -27,11 +27,11 @@
27
27
  "open": "^9.1.0",
28
28
  "picocolors": "^1.1.1",
29
29
  "zod": "^4.3.5",
30
- "@valbuild/core": "0.105.1",
30
+ "@valbuild/core": "0.106.0",
31
31
  "@valbuild/eslint-plugin": "0.93.0",
32
- "@valbuild/language-server": "0.105.1",
33
- "@valbuild/server": "0.105.1",
34
- "@valbuild/shared": "0.105.1"
32
+ "@valbuild/language-server": "0.106.0",
33
+ "@valbuild/server": "0.106.0",
34
+ "@valbuild/shared": "0.106.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "prettier": "*",
@@ -0,0 +1,5 @@
1
+ import { s } from "../val.config";
2
+
3
+ // A shared schema that happens to wear the `.val.ts` suffix. It has no default
4
+ // export, so it is not a Val module and `validate` says nothing about it.
5
+ export const helperSchema = s.object({ text: s.string() });
@@ -0,0 +1,9 @@
1
+ import { s, c } from "../val.config";
2
+
3
+ // Deliberately NOT in val.modules: a real Val module someone forgot to
4
+ // register, which `validate` warns about.
5
+ export default c.define(
6
+ "/content/unregistered-module.val.ts",
7
+ s.object({ text: s.string() }),
8
+ { text: "hi" },
9
+ );
@@ -0,0 +1,5 @@
1
+ import { s } from "../val.config";
2
+
3
+ // A default export that is not a Val module: nothing can ever load this file
4
+ // under this name, so `validate` reports it as an error.
5
+ export default s.object({ text: s.string() });
@@ -0,0 +1,3 @@
1
+ // Throws while being imported, so anything that inspects it - or a file that
2
+ // imports it - has to survive a half-built module cache entry.
3
+ throw Error("helper boom");
@@ -0,0 +1,3 @@
1
+ import "./unregistered-throwing-helper.val";
2
+
3
+ export default "a";
@@ -0,0 +1,3 @@
1
+ import "./unregistered-throwing-helper.val";
2
+
3
+ export default "b";
@@ -0,0 +1,7 @@
1
+ import { s } from "../val.config";
2
+
3
+ // A type-only default export: it does not survive transpilation, so this file
4
+ // has no runtime default export and is just a helper.
5
+ export const typeDefaultSchema = s.object({ text: s.string() });
6
+ type TypeDefault = { text: string };
7
+ export type { TypeDefault as default };
@@ -485,6 +485,104 @@ describe("runValidation", () => {
485
485
  expect(events.filter((e) => e.type === "validation-error")).toHaveLength(0);
486
486
  });
487
487
 
488
+ describe("unregistered files", () => {
489
+ const runOn = async (valFiles: string[]) => {
490
+ const events: ValidationEvent[] = [];
491
+ for await (const event of runValidation({
492
+ root: tmpDir,
493
+ fix: false,
494
+ valFiles,
495
+ project: undefined,
496
+ remote: mockRemote,
497
+ fs: createDefaultValFSHost(),
498
+ })) {
499
+ events.push(event);
500
+ }
501
+ return events;
502
+ };
503
+
504
+ test("warns about an unregistered file that IS a Val module", async () => {
505
+ const events = await runOn(["content/unregistered-module.val.ts"]);
506
+
507
+ expect(events).toEqual(
508
+ expect.arrayContaining([
509
+ {
510
+ type: "unregistered-module",
511
+ file: "content/unregistered-module.val.ts",
512
+ },
513
+ ]),
514
+ );
515
+ expect(events.at(-1)).toEqual({ type: "summary-success" });
516
+ });
517
+
518
+ test("says nothing about an unregistered file with no default export", async () => {
519
+ // The `.val.ts` suffix is used for shared schemas and other helpers too.
520
+ // Those are not meant to be registered, so warning about them buried the
521
+ // one warning that matters under a wall of noise.
522
+ const events = await runOn(["content/unregistered-helper.val.ts"]);
523
+
524
+ expect(events.filter((e) => e.type !== "summary-success")).toHaveLength(
525
+ 0,
526
+ );
527
+ expect(events.at(-1)).toEqual({ type: "summary-success" });
528
+ });
529
+
530
+ test("says nothing about a type-only default export", async () => {
531
+ // `export type { T as default }` does not survive transpilation, so the
532
+ // file has no runtime default export - evaluating it would report a pure
533
+ // helper as an error.
534
+ const events = await runOn(["content/unregistered-type-default.val.ts"]);
535
+
536
+ expect(events.filter((e) => e.type !== "summary-success")).toHaveLength(
537
+ 0,
538
+ );
539
+ expect(events.at(-1)).toEqual({ type: "summary-success" });
540
+ });
541
+
542
+ test("errors when an unregistered file default exports a non-module", async () => {
543
+ // Nothing can ever load this file under this name, so it is not something
544
+ // to skip quietly - it is a mistake.
545
+ const events = await runOn(["content/unregistered-not-a-module.val.ts"]);
546
+
547
+ expect(events).toEqual(
548
+ expect.arrayContaining([
549
+ expect.objectContaining({
550
+ type: "fatal-error",
551
+ file: "/content/unregistered-not-a-module.val.ts",
552
+ message: expect.stringContaining("not a Val module"),
553
+ }),
554
+ ]),
555
+ );
556
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
557
+ });
558
+
559
+ test("reports every file broken by the same throwing import", async () => {
560
+ // Regression: the inspector shares one module cache across files, and the
561
+ // loader caches a module BEFORE evaluating it so cycles resolve. A module
562
+ // that threw therefore left a half-built entry behind, and the next file
563
+ // to import it hit that entry, saw empty exports, and got reported as
564
+ // "default export is undefined" - downgrading a real error to a warning,
565
+ // in an order-dependent way.
566
+ const events = await runOn([
567
+ "content/unregistered-throws-a.val.ts",
568
+ "content/unregistered-throws-b.val.ts",
569
+ ]);
570
+
571
+ const fatal = events.filter((e) => e.type === "fatal-error");
572
+ expect(fatal).toEqual([
573
+ expect.objectContaining({
574
+ file: "/content/unregistered-throws-a.val.ts",
575
+ message: expect.stringContaining("helper boom"),
576
+ }),
577
+ expect.objectContaining({
578
+ file: "/content/unregistered-throws-b.val.ts",
579
+ message: expect.stringContaining("helper boom"),
580
+ }),
581
+ ]);
582
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 2 });
583
+ });
584
+ });
585
+
488
586
  describe("jsonValues", () => {
489
587
  const runOn = async (valFiles: string[]) => {
490
588
  const events: ValidationEvent[] = [];
@@ -3,6 +3,7 @@ import {
3
3
  createDefaultValFSHost,
4
4
  createFixPatch,
5
5
  createService,
6
+ createValModuleFileInspector,
6
7
  fixHandlers,
7
8
  type FixHandlerContext,
8
9
  type IValFSHost,
@@ -61,9 +62,14 @@ export async function* runValidation({
61
62
  const service = await createService(projectRoot, fs);
62
63
 
63
64
  // Modules registered in the project's val.modules. Files found on disk that
64
- // are not registered here are not validated (a warning is emitted instead).
65
+ // are not registered here are not validated; what (if anything) is reported
66
+ // for them is decided by `reportUnregistered` below.
65
67
  const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());
66
68
 
69
+ // Only used for files that are NOT registered, so it never duplicates the
70
+ // evaluation `createService` already did.
71
+ const inspectValModuleFile = createValModuleFileInspector(projectRoot, fs);
72
+
67
73
  let errors = 0;
68
74
 
69
75
  // Build a single schema/source snapshot up front so the shared resolver
@@ -83,10 +89,48 @@ export async function* runValidation({
83
89
  }
84
90
  }
85
91
 
92
+ /**
93
+ * What to report for a `*.val.ts` file that val.modules does not register.
94
+ *
95
+ * The `*.val.ts` suffix is used for more than Val modules — shared schemas and
96
+ * other content-adjacent helpers wear it too — and those are not meant to be
97
+ * registered, so warning about every unregistered file buries the one warning
98
+ * that matters under a wall of noise. The default export is what separates
99
+ * them: a file that default exports a module is one someone meant to register
100
+ * and forgot (a warning), a file that default exports something else can never
101
+ * be loaded by Val under that name (an error), and a file with no default
102
+ * export is simply not a module (silence).
103
+ */
104
+ async function* reportUnregistered(
105
+ file: string,
106
+ ): AsyncGenerator<ValidationEvent> {
107
+ const start = Date.now();
108
+ const inspection = inspectValModuleFile(path.join(projectRoot, file));
109
+ if (inspection.status === "no-default-export") {
110
+ return;
111
+ }
112
+ if (inspection.status === "val-module") {
113
+ yield { type: "unregistered-module", file };
114
+ return;
115
+ }
116
+ errors += 1;
117
+ yield {
118
+ type: "fatal-error",
119
+ file: `/${file}`,
120
+ message: inspection.message,
121
+ };
122
+ yield {
123
+ type: "file-error-count",
124
+ file: `/${file}`,
125
+ errorCount: 1,
126
+ durationMs: Date.now() - start,
127
+ };
128
+ }
129
+
86
130
  async function* validateFile(file: string): AsyncGenerator<ValidationEvent> {
87
131
  const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
88
132
  if (!registered.has(moduleFilePath)) {
89
- yield { type: "unregistered-module", file };
133
+ yield* reportUnregistered(file);
90
134
  return;
91
135
  }
92
136
  const start = Date.now();