Skip to content

Commit 87d6bae

Browse files
fix(cli): a define* helper's ZodError at config load is reported as one located refusal, not a raw JSON array (#29087)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB5s2CDytRmfVUEgwyX8tMK)_ Fixes #28956 Clause-②: no — CLI output text only. No schema, export or accepted input changes; a refused config is refused with the same exit code as before. Metadata-protocol: none ## 维护者速读 - 改了什么:配置加载时 `defineView` 等 `define*` helper 抛出的 ZodError,现在打印成一条带定位的拒绝(文件:行:列 · helper · 路径 · zod 原话),不再是 40 多行 JSON 数组。`os validate`、`os build`、`os lint`、`os dev`、`os serve` 一致。 - 为什么改:新手第一次写视图(字符串列和对象列混用)就撞上这个错误,`npm run validate` 只给 JSON,不说哪个文件、哪个视图,人和 coding agent 都没法自我纠正。 - 风险与回滚:只改 CLI 的输出文字,schema 和接受集不变,退出码不变;语法错误、缺失 import、helper 自己的文字拒绝、`defineStack` 的拒绝原样不动。回滚 = revert 本 PR。 - 先看:`packages/cli/src/utils/config-load-refusal.ts`(定位逻辑,`locateConfigLoadZodError` 只在栈和 source map 都对得上时才给文件);`packages/cli/src/utils/config.ts` 的 `bundleConfigModule`;`packages/cli/src/commands/serve.ts` 一行改动。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/config-load-zod-refusal.test.ts`,预期 9/9 通过,用例里能看到 `src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue):`。 - 需要你做的:无,席位落地。 ## What changes `defineView`, `defineAction` and the other strict helpers call `Schema.parse` and throw the `ZodError` as is. On the normal load path nothing wrapped it, and each command's catch-all printed `error.message`, which for a `ZodError` is its issues as a JSON array. - **One seam.** `bundleConfigModule` in `packages/cli/src/utils/config.ts` is now the one place the config module graph is bundled and evaluated. `loadConfig` (validate, build, lint, dev's compile step, info, diff, generate, doctor, verify, i18n, `migrate meta`) and `os serve`'s own load both go through it. `os serve` had its own `bundleRequire` call and printed the same raw array, so it is routed through the seam too (one line in `serve.ts`). - **The refusal.** A raw `ZodError` thrown while the module evaluates is rethrown as a plain `ConfigLoadRefusalError` whose message is `FILE:LINE:COL · HELPER validation failed (N issues):` followed by the block `formatZodError` already renders (the same formatter the `os migrate meta` shim's `__refusal` and the `defineStack validation failed` block use). There is no second formatter. The `ZodError` rides as `cause`. Because it is a plain `Error`, no catch-all changed: the text face prints it where it printed the array, and `--json` copies it into `error`. No `code` is added. - **The location.** bundle-require evaluates one esbuild bundle with an inline source map. Its text is read just before the import (bundle-require deletes the file afterwards). The innermost stack frame inside the bundle is the authored call; the map turns it back into the authored file, line and column. The frame directly above it names the helper, kept only when it is a `define*` function and the authored line spells that name. With `--enable-source-maps` the frame is already authored and is matched against the map's sources. - **Never a wrong file.** No bundle frame on the stack, no map entry on the frame's own generated line, or a mapped file not on disk: the refusal names the config entry and says the stack does not say which module threw. - Every other load failure passes through untouched: a syntax error, a missing import, a helper's own prose refusal (`defineView: the container defines no views …`), `defineStack`'s formatted refusal. Card output, before and after (`os validate` on the card's project, a `defineView` with `columns: [{ field: 'subject', link: true }, 'status', 'priority']` in `src/views/ticket.view.ts`, re-exported through `src/views/index.ts`): ```text before (main 0be247c): → Loading configuration... ✗ [ { "code": "invalid_union", "errors": [ … 40-odd lines of JSON, exit 1 after: → Loading configuration... ✗ src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue): ✗ list.columns: Invalid input ✗ list.columns.0: Invalid input: expected string, received object ``` ## Tests New `packages/cli/test/config-load-zod-refusal.test.ts` (unit tier, in-process, src imports): - `os validate --json`: exit 1, `error` names `src/views/ticket.view.ts:5:28`, `defineView`, `list.columns` and the branch issue; it does not start with `[`. - `os validate` text face: no `✗ [` line, no `"code": "invalid_union"`. - `os build --json`: the same located head, and the same `error` as `os validate`; exit code unchanged. - A refused call that is not a `define*` helper (`ObjectSchema.create` with a bad `name`) is located (`objectstack.config.ts:3:N · Validation failed (1 issue):`) without naming a helper. - A stack with no bundle frame names the config entry with the "stack does not say which module" words, never a guessed module. - Controls: a clean config validates; a missing import, a syntax error and a helper's prose refusal each keep exactly the message bundle-require throws. ## Acceptance notes - **The union branch words are the formatter's.** For the card's mixed columns, the existing branch ranking (`union-branch-policy.ts`) picks the `string[]` branch, so the line reads `list.columns.0: Invalid input: expected string, received object`. It is located and correctable, but it is not the card's suggested "mix of strings and column objects; use one form". That wording would need a union message in `packages/spec` (a spec change with contract review), which the order told this card not to make. - **Helper naming is `define*` only.** `ObjectSchema.create`, `Field.*` and other non-`define*` calls get the file, line and column, and the default `Validation failed` header. - **Not routed:** `os create`'s `utils/scaffold-validate.ts` has its own `bundleRequire` over the template it just generated, which is not authored input. - **File surface beyond the claim:** the claim named `config.ts` and the `validate.ts` catch-all. `validate.ts` is not touched. Added: `utils/config-load-refusal.ts` (new) and one line in `commands/serve.ts`, because `os serve` loads the config without `loadConfig` and printed the same array. - **Overlap re-read:** open PRs touching `packages/cli/src/utils/config.ts`, `commands/serve.ts`: none. #29043 touches `commands/compile.ts` and `commands/validate.ts` (not touched here); #29045 touches `migrate/meta.ts` (not touched here). ## Verification <details><summary>Real CLI, red on main 0be247c and green on this branch (validate, build, lint, dev, serve)</summary> Red, built CLI from `origin/main` 0be247c, card project: - `os validate`: exit 1, `✗ [` then the array (45 lines of output). - `os build`: exit 2, same array. - `os lint`: exit 1, same array. - `os dev -p 38956`: exit 1, the compile step prints the same array. - `os serve -p 38958`: exit 1, `Loading objectstack.config.ts...` then the same array. Green, this branch: - `os validate`: exit 1, `✗ src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue):` and the two issue lines. - `os build`: exit 2 (unchanged), same block. - `os lint`: exit 1, same block. - `os dev`: exit 1, same block then `✗ Compile failed — fix errors above before starting dev server`. - `os serve`: exit 1, same block. - `os validate --json`: `"error": "src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue):\n\n ✗ list.columns: Invalid input\n ✗ list.columns.0: Invalid input: expected string, received object"`. - `NODE_OPTIONS=--enable-source-maps os validate`: same located head. - Run from `src/` with `os validate ../objectstack.config.ts`: `views/ticket.view.ts:5:28 · …` (relative to the working directory). Controls on this branch: a syntax-error config prints esbuild's `Build failed with 1 error: … Expected identifier but found end of file`; a missing import prints `Could not resolve "./src/not-there"`; a clean config validates (`✓ Validation passed`); a bad `manifest.id` still prints `✗ defineStack validation failed (1 issue):`; `os migrate meta --from 17` on the card project still prints the shim's `[authored-source] defineView(): the current schema refuses this artifact … defineView validation failed (1 issue):` warning and exits 0. </details> <details><summary>Ablation (src-level; the test imports src, no dist hop)</summary> `node scripts/ablation-replace.mjs --file packages/cli/src/utils/config.ts --anchor ' if (isRawZodError(error)) throw configLoadRefusal(error, absolutePath, bundle, cwd);' --delete -- pnpm exec vitest run test/config-load-zod-refusal.test.ts`: - `ok mutation landed: anchor 1 -> 0, blob 82be1e8 -> f63bad0a3d26` - `Tests 4 failed | 5 passed (9)`: the four seam cases (validate `--json`, validate text face, build `--json`, the non-`define*` locate). The renderer's unit case and the four controls stay green. - `ok restored: blob == HEAD (82be1e8) and git diff HEAD is empty` </details> <details><summary>Local gates</summary> Head `5af532cc`. - cli unit tier (`vitest run --project unit`): Test Files 369 passed (369), Tests 5205 passed (5205). The integration tier is declared to CI (no spawn entry or integration file touched). - cli `typecheck` (`tsc --noEmit` + `check:test-typecheck`): exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 63 derived families, every one run with its exit code captured before any pipe: 63 × exit 0. `--ran`: `63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero)`. - `check:i18n-coverage` first answered exit 3 (PREREQUISITE: `@objectstack/connector-slack` / `service-print` had no build output); after building the `./examples/*^...` closure it answered exit 0 (`OK (14 config(s), 426 baselined untranslated string(s), none new)`). ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mch1VryTyQsMG5U86)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 692a4e4 commit 87d6bae

5 files changed

Lines changed: 556 additions & 12 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): a `define*` helper that refuses its input while the config loads is reported as one located refusal (file, line, helper, path, the issue in words), not a raw JSON array
6+
7+
Clause-②: no — CLI output text only. No schema, export or accepted input changes; a refused config is refused with the same exit code as before.
8+
Metadata-protocol: none
9+
10+
`defineView`, `defineAction` and the other strict helpers call `Schema.parse` and throw the `ZodError` as is. `os validate`, `os build`, `os lint`, `os dev` and `os serve` printed that error's message, which is its issues as a JSON array, with no file and no helper. The config module now loads through one seam that turns such an error into a refusal rendered by `formatZodError`:
11+
12+
```text
13+
✗ src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue):
14+
15+
✗ list.columns: Invalid input
16+
✗ list.columns.0: Invalid input: expected string, received object
17+
```
18+
19+
The file and line come from the evaluated bundle's source map. When the stack cannot tell which module threw, the refusal names the config entry and says so. The `--json` envelope's `error` carries the same text. Every other load failure (a syntax error, a missing import, a helper's own prose refusal, `defineStack`'s formatted refusal) is unchanged.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import path from 'path';
55
import fs from 'fs';
66
import net from 'net';
77
import chalk, { chalkStderr } from 'chalk';
8-
import { bundleRequire } from 'bundle-require';
98
import { stampDatasourceEnvCredentialsDefault } from '../utils/datasource-env-credentials-default.js';
10-
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
9+
import { loadConfig, bundleConfigModule } from '../utils/config.js';
1110
import { mergeBootConfig } from '../utils/merge-boot-config.js';
1211
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
1312
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
@@ -2762,7 +2761,7 @@ export default class Serve extends Command {
27622761
? { mod: { default: {} as any } }
27632762
: flags.prebuilt
27642763
? { mod: await import(absolutePath.startsWith('/') ? `file://${absolutePath}` : absolutePath) }
2765-
: await bundleRequire({ filepath: absolutePath, external: BUNDLE_REQUIRE_EXTERNALS });
2764+
: await bundleConfigModule(absolutePath);
27662765

27672766
let config = mod.default || mod;
27682767

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The refusal a config load raises when a `define*` helper's `Schema.parse`
5+
* throws a raw `ZodError` while the config MODULE evaluates.
6+
*
7+
* ## The defect this closes
8+
*
9+
* `defineView`, `defineAction` and the other strict helpers call
10+
* `Schema.parse` and let the `ZodError` out as is. Nothing on the normal load
11+
* path wrapped it, and every command's catch-all printed `error.message` —
12+
* which for a `ZodError` is its issues as a JSON array. Measured on this tree
13+
* with a `defineView` mixing string and object `list.columns`: `os validate`,
14+
* `os build`, `os lint`, `os dev` and `os serve` all printed `✗ [` and 40-odd
15+
* lines of JSON, naming no file, no helper and no remedy.
16+
*
17+
* ## What it renders, and with what
18+
*
19+
* The issues go through the project's own `formatZodError` — the formatter the
20+
* `os migrate meta` shim's `__refusal` and the loader's own `defineStack
21+
* validation failed` block already use, union branch expansion included. There
22+
* is no second formatter here: this file only works out WHERE the refused call
23+
* is, and puts that in front of the block.
24+
*
25+
* ## How the location is read, and when it is not
26+
*
27+
* bundle-require evaluates one esbuild bundle of the config and every module
28+
* it imports (an `@objectstack/*` package or a `node_modules` dependency stays
29+
* external), with an inline source map. The innermost stack frame inside that
30+
* bundle is therefore the authored call that threw, and the map turns it back
31+
* into the authored file, line and column. The frame directly above it is the
32+
* function that call entered: the helper, named only when it is a `define*`
33+
* function whose name the authored line actually spells.
34+
*
35+
* ⛔ Never a guessed file. When the stack holds no frame in the bundle (a
36+
* truncated stack, an error rethrown from a promise), when the map has no
37+
* entry on that generated line, or when the mapped file is not on disk,
38+
* {@link locateConfigLoadZodError} answers `undefined` and the refusal names
39+
* the config entry it was loading, saying the module that threw is unknown.
40+
*/
41+
42+
import fs from 'fs';
43+
import path from 'path';
44+
import { SourceMap } from 'node:module';
45+
import { fileURLToPath } from 'node:url';
46+
import { formatZodError } from '@objectstack/spec';
47+
48+
/** The bundle bundle-require evaluated: its path on disk and its text. */
49+
export interface EvaluatedConfigBundle {
50+
readonly file: string;
51+
readonly text: string;
52+
}
53+
54+
/** Where the refused call is in the authored source. */
55+
export interface ConfigLoadZodLocation {
56+
/** Absolute path of the authored module that made the refused call. */
57+
readonly file: string;
58+
/** 1-based. */
59+
readonly line: number;
60+
/** 1-based. */
61+
readonly column: number;
62+
/** The `define*` helper the call entered, when the stack and the line agree on it. */
63+
readonly helper?: string;
64+
}
65+
66+
/** A `ZodError` as `Schema.parse` throws it: by shape, not `instanceof` (two zod copies can be live). */
67+
export function isRawZodError(error: unknown): error is Error & { issues: unknown[] } {
68+
return (
69+
error instanceof Error
70+
&& error.name === 'ZodError'
71+
&& Array.isArray((error as { issues?: unknown }).issues)
72+
);
73+
}
74+
75+
const DEFINE_HELPER_NAME_RE = /^define[A-Z][A-Za-z0-9]*$/;
76+
const INLINE_SOURCE_MAP_RE = /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/;
77+
const STACK_FRAME_RE = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/;
78+
79+
interface StackFrame {
80+
readonly fn?: string;
81+
readonly file: string;
82+
readonly line: number;
83+
readonly column: number;
84+
}
85+
86+
function stackFramesOf(stack: string): StackFrame[] {
87+
const frames: StackFrame[] = [];
88+
for (const raw of stack.split('\n')) {
89+
const match = STACK_FRAME_RE.exec(raw);
90+
if (!match) continue;
91+
const [, fn, location, line, column] = match;
92+
let file = location;
93+
if (file.startsWith('async ')) file = file.slice('async '.length);
94+
if (file.startsWith('file://')) {
95+
try {
96+
file = fileURLToPath(file);
97+
} catch {
98+
continue;
99+
}
100+
}
101+
frames.push({ fn: fn?.replace(/^async /, ''), file, line: Number(line), column: Number(column) });
102+
}
103+
return frames;
104+
}
105+
106+
function sourceMapOf(bundle: EvaluatedConfigBundle): { map: SourceMap; sources: Set<string> } | undefined {
107+
const match = INLINE_SOURCE_MAP_RE.exec(bundle.text);
108+
if (!match) return undefined;
109+
try {
110+
const payload = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8'));
111+
return {
112+
map: new SourceMap(payload),
113+
sources: new Set<string>(Array.isArray(payload.sources) ? payload.sources : []),
114+
};
115+
} catch {
116+
return undefined;
117+
}
118+
}
119+
120+
/** A map `sources` entry as an absolute path: esbuild writes them relative to its working directory. */
121+
function absoluteSource(source: string, cwd: string): string | undefined {
122+
if (source.startsWith('file://')) {
123+
try {
124+
return fileURLToPath(source);
125+
} catch {
126+
return undefined;
127+
}
128+
}
129+
return path.resolve(cwd, source);
130+
}
131+
132+
/** The helper the frame names, when it is a `define*` function and the authored line spells that name. */
133+
function helperOf(frame: StackFrame | undefined, file: string, line: number): string | undefined {
134+
const name = frame?.fn?.split('.').pop();
135+
if (!name || !DEFINE_HELPER_NAME_RE.test(name)) return undefined;
136+
let text: string | undefined;
137+
try {
138+
text = fs.readFileSync(file, 'utf8').split(/\r?\n/)[line - 1];
139+
} catch {
140+
return undefined;
141+
}
142+
return text !== undefined && new RegExp(`\\b${name}\\b`).test(text) ? name : undefined;
143+
}
144+
145+
/**
146+
* The authored file, line, column and helper of the call that threw `stack`
147+
* while `bundle` evaluated, or `undefined` when the stack cannot tell.
148+
*
149+
* @param stack the thrown error's `stack`
150+
* @param bundle the bundle bundle-require evaluated, read before it was deleted
151+
* @param cwd the working directory esbuild resolved the map's `sources` against
152+
*/
153+
export function locateConfigLoadZodError(
154+
stack: string | undefined,
155+
bundle: EvaluatedConfigBundle | undefined,
156+
cwd: string,
157+
): ConfigLoadZodLocation | undefined {
158+
if (!stack || !bundle) return undefined;
159+
const mapped = sourceMapOf(bundle);
160+
if (!mapped) return undefined;
161+
const authored = new Set<string>();
162+
for (const source of mapped.sources) {
163+
const absolute = absoluteSource(source, cwd);
164+
if (absolute) authored.add(absolute);
165+
}
166+
167+
const frames = stackFramesOf(stack);
168+
for (let i = 0; i < frames.length; i++) {
169+
const frame = frames[i];
170+
let located: { file: string; line: number; column: number } | undefined;
171+
if (frame.file === bundle.file) {
172+
// V8 frames are 1-based; the map is 0-based. The entry must sit on the
173+
// frame's own generated line — `findEntry` otherwise answers the nearest
174+
// earlier mapping, which can be another module's code.
175+
const entry = mapped.map.findEntry(frame.line - 1, frame.column - 1) as {
176+
generatedLine?: number;
177+
originalSource?: string;
178+
originalLine?: number;
179+
originalColumn?: number;
180+
};
181+
if (
182+
entry.generatedLine !== frame.line - 1
183+
|| typeof entry.originalSource !== 'string'
184+
|| typeof entry.originalLine !== 'number'
185+
|| typeof entry.originalColumn !== 'number'
186+
) {
187+
return undefined;
188+
}
189+
const file = absoluteSource(entry.originalSource, cwd);
190+
if (!file) return undefined;
191+
located = { file, line: entry.originalLine + 1, column: entry.originalColumn + 1 };
192+
} else if (authored.has(frame.file)) {
193+
// `--enable-source-maps`: V8 already mapped the frame to the authored file.
194+
located = { file: frame.file, line: frame.line, column: frame.column };
195+
}
196+
if (!located) continue;
197+
if (!fs.existsSync(located.file)) return undefined;
198+
const helper = helperOf(frames[i - 1], located.file, located.line);
199+
return helper ? { ...located, helper } : located;
200+
}
201+
return undefined;
202+
}
203+
204+
/** A path as the author reads it: relative to the working directory when it is under it. */
205+
function displayPath(file: string, cwd: string): string {
206+
const relative = path.relative(cwd, file);
207+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
208+
}
209+
210+
/**
211+
* The readable refusal for a raw `ZodError` a config load threw.
212+
*
213+
* A plain `Error`, so every command's catch-all prints it exactly where it
214+
* printed the JSON array before — `--json` copies `message` into its
215+
* envelope's `error` — and no catch-all changes. The `ZodError` rides as
216+
* `cause`.
217+
*/
218+
export class ConfigLoadRefusalError extends Error {
219+
constructor(message: string, cause: Error) {
220+
super(message, { cause });
221+
this.name = 'ConfigLoadRefusalError';
222+
}
223+
}
224+
225+
/**
226+
* Turn a raw `ZodError` thrown while `configPath` loaded into
227+
* {@link ConfigLoadRefusalError}:
228+
*
229+
* ```text
230+
* src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue):
231+
*
232+
* ✗ list.columns: Invalid input
233+
* ✗ list.columns.0: Invalid input: expected string, received object
234+
* ```
235+
*/
236+
export function configLoadRefusal(
237+
error: Error & { issues: unknown[] },
238+
configPath: string,
239+
bundle: EvaluatedConfigBundle | undefined,
240+
cwd: string,
241+
): ConfigLoadRefusalError {
242+
const location = locateConfigLoadZodError(error.stack, bundle, cwd);
243+
const where = location
244+
? `${displayPath(location.file, cwd)}:${location.line}:${location.column}`
245+
: `${displayPath(configPath, cwd)} (loading it threw; the stack does not say which module)`;
246+
const label = location?.helper ? `${location.helper} validation failed` : undefined;
247+
const block = formatZodError(error as unknown as Parameters<typeof formatZodError>[0], label);
248+
return new ConfigLoadRefusalError(`${where} · ${block}`, error);
249+
}

‎packages/cli/src/utils/config.ts‎

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import path from 'path';
44
import fs from 'fs';
55
import { createRequire } from 'node:module';
6-
import { pathToFileURL } from 'node:url';
6+
import { fileURLToPath, pathToFileURL } from 'node:url';
77
import chalk from 'chalk';
8-
import { bundleRequire } from 'bundle-require';
8+
import { bundleRequire, dynamicImport } from 'bundle-require';
99
import type { Plugin } from 'esbuild';
1010
import { hasStackProvenance, stackConversionsOf, type ConversionNotice } from '@objectstack/spec';
1111
import { printErrorToStderr, printWarningToStderr } from './format.js';
12+
import { configLoadRefusal, isRawZodError, type EvaluatedConfigBundle } from './config-load-refusal.js';
1213

1314
export interface LoadedConfig {
1415
config: any;
@@ -769,6 +770,51 @@ function authoredArgumentOf(value: unknown): unknown {
769770
return current;
770771
}
771772

773+
/**
774+
* Bundle and evaluate the config module — the ONE place a config's module
775+
* graph runs, for {@link loadConfig} and for `os serve`'s own load.
776+
*
777+
* A raw `ZodError` thrown while the module evaluates — a `define*` helper's
778+
* `Schema.parse` refusing its input — is rethrown as the located, formatted
779+
* refusal of `./config-load-refusal.ts` instead of reaching a catch-all whose
780+
* `error.message` is the issues as a JSON array. Every other throw (a syntax
781+
* error, a missing import, a helper's own prose refusal, `defineStack`'s
782+
* formatted one) passes through untouched.
783+
*
784+
* The bundle's text is read just before bundle-require imports it, because
785+
* bundle-require deletes the file once the import settles; the refusal needs
786+
* its inline source map to turn the stack frame back into the authored file.
787+
*
788+
* @param plugins esbuild plugins to bundle with (the authored-source shim)
789+
*/
790+
export async function bundleConfigModule(
791+
absolutePath: string,
792+
plugins?: Plugin[],
793+
): Promise<{ mod: any }> {
794+
const cwd = process.cwd();
795+
let bundle: EvaluatedConfigBundle | undefined;
796+
try {
797+
return await bundleRequire({
798+
filepath: absolutePath,
799+
cwd,
800+
external: BUNDLE_REQUIRE_EXTERNALS,
801+
...(plugins ? { esbuildOptions: { plugins } } : {}),
802+
require: (outfile, ctx) => {
803+
const file = outfile.startsWith('file:') ? fileURLToPath(outfile) : outfile;
804+
try {
805+
bundle = { file, text: fs.readFileSync(file, 'utf8') };
806+
} catch {
807+
// Unreadable: the refusal then names the config entry, never a guess.
808+
}
809+
return dynamicImport(outfile, ctx);
810+
},
811+
});
812+
} catch (error) {
813+
if (isRawZodError(error)) throw configLoadRefusal(error, absolutePath, bundle, cwd);
814+
throw error;
815+
}
816+
}
817+
772818
/**
773819
* Load and bundle a config file using bundle-require.
774820
* Returns the resolved config object, its load time, and the provenance of
@@ -815,13 +861,10 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions):
815861
const absolutePath = resolveConfigPath(source);
816862
const start = Date.now();
817863

818-
const { mod } = await bundleRequire({
819-
filepath: absolutePath,
820-
external: BUNDLE_REQUIRE_EXTERNALS,
821-
...(options?.authoredSource
822-
? { esbuildOptions: { plugins: [authoredSourcePlugin(absolutePath)] } }
823-
: {}),
824-
});
864+
const { mod } = await bundleConfigModule(
865+
absolutePath,
866+
options?.authoredSource ? [authoredSourcePlugin(absolutePath)] : undefined,
867+
);
825868

826869
const baseConfig = mod.default || mod;
827870
if (!baseConfig) {

0 commit comments

Comments
 (0)