From f9775f85f29ea88a466d831fc6b87a8797b02af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:40:39 +0000 Subject: [PATCH 1/2] fix(cli): report a config-load ZodError as one located refusal A define* helper's Schema.parse throws its ZodError as is, and every command that loads the config printed error.message: the issues as a JSON array, with no file and no helper. The config module now loads through bundleConfigModule, which reads the evaluated bundle's inline source map to locate the authored call and renders the issues with formatZodError. os serve's own config load goes through the same seam. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5mch1VryTyQsMG5U86 --- .changeset/28956-config-load-zod-refusal.md | 19 ++ packages/cli/src/commands/serve.ts | 5 +- packages/cli/src/utils/config-load-refusal.ts | 249 ++++++++++++++++++ packages/cli/src/utils/config.ts | 61 ++++- .../cli/test/config-load-zod-refusal.test.ts | 233 ++++++++++++++++ 5 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 .changeset/28956-config-load-zod-refusal.md create mode 100644 packages/cli/src/utils/config-load-refusal.ts create mode 100644 packages/cli/test/config-load-zod-refusal.test.ts diff --git a/.changeset/28956-config-load-zod-refusal.md b/.changeset/28956-config-load-zod-refusal.md new file mode 100644 index 00000000000..7bfea0d777a --- /dev/null +++ b/.changeset/28956-config-load-zod-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +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 + +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`, `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`: + +```text +✗ 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 +``` + +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. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6df3659aba8..6ca64393f5c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5,9 +5,8 @@ import path from 'path'; import fs from 'fs'; import net from 'net'; import chalk, { chalkStderr } from 'chalk'; -import { bundleRequire } from 'bundle-require'; import { stampDatasourceEnvCredentialsDefault } from '../utils/datasource-env-credentials-default.js'; -import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; +import { loadConfig, bundleConfigModule } from '../utils/config.js'; import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; @@ -2762,7 +2761,7 @@ export default class Serve extends Command { ? { mod: { default: {} as any } } : flags.prebuilt ? { mod: await import(absolutePath.startsWith('/') ? `file://${absolutePath}` : absolutePath) } - : await bundleRequire({ filepath: absolutePath, external: BUNDLE_REQUIRE_EXTERNALS }); + : await bundleConfigModule(absolutePath); let config = mod.default || mod; diff --git a/packages/cli/src/utils/config-load-refusal.ts b/packages/cli/src/utils/config-load-refusal.ts new file mode 100644 index 00000000000..a6e3776f5c7 --- /dev/null +++ b/packages/cli/src/utils/config-load-refusal.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The refusal a config load raises when a `define*` helper's `Schema.parse` + * throws a raw `ZodError` while the config MODULE evaluates. + * + * ## The defect this closes + * + * `defineView`, `defineAction` and the other strict helpers call + * `Schema.parse` and let the `ZodError` out as is. Nothing on the normal load + * path wrapped it, and every command's catch-all printed `error.message` — + * which for a `ZodError` is its issues as a JSON array. Measured on this tree + * with a `defineView` mixing string and object `list.columns`: `os validate`, + * `os build`, `os lint`, `os dev` and `os serve` all printed `✗ [` and 40-odd + * lines of JSON, naming no file, no helper and no remedy. + * + * ## What it renders, and with what + * + * The issues go through the project's own `formatZodError` — the formatter the + * `os migrate meta` shim's `__refusal` and the loader's own `defineStack + * validation failed` block already use, union branch expansion included. There + * is no second formatter here: this file only works out WHERE the refused call + * is, and puts that in front of the block. + * + * ## How the location is read, and when it is not + * + * bundle-require evaluates one esbuild bundle of the config and every module + * it imports (an `@objectstack/*` package or a `node_modules` dependency stays + * external), with an inline source map. The innermost stack frame inside that + * bundle is therefore the authored call that threw, and the map turns it back + * into the authored file, line and column. The frame directly above it is the + * function that call entered: the helper, named only when it is a `define*` + * function whose name the authored line actually spells. + * + * ⛔ Never a guessed file. When the stack holds no frame in the bundle (a + * truncated stack, an error rethrown from a promise), when the map has no + * entry on that generated line, or when the mapped file is not on disk, + * {@link locateConfigLoadZodError} answers `undefined` and the refusal names + * the config entry it was loading, saying the module that threw is unknown. + */ + +import fs from 'fs'; +import path from 'path'; +import { SourceMap } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { formatZodError } from '@objectstack/spec'; + +/** The bundle bundle-require evaluated: its path on disk and its text. */ +export interface EvaluatedConfigBundle { + readonly file: string; + readonly text: string; +} + +/** Where the refused call is in the authored source. */ +export interface ConfigLoadZodLocation { + /** Absolute path of the authored module that made the refused call. */ + readonly file: string; + /** 1-based. */ + readonly line: number; + /** 1-based. */ + readonly column: number; + /** The `define*` helper the call entered, when the stack and the line agree on it. */ + readonly helper?: string; +} + +/** A `ZodError` as `Schema.parse` throws it: by shape, not `instanceof` (two zod copies can be live). */ +export function isRawZodError(error: unknown): error is Error & { issues: unknown[] } { + return ( + error instanceof Error + && error.name === 'ZodError' + && Array.isArray((error as { issues?: unknown }).issues) + ); +} + +const DEFINE_HELPER_NAME_RE = /^define[A-Z][A-Za-z0-9]*$/; +const INLINE_SOURCE_MAP_RE = /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; +const STACK_FRAME_RE = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/; + +interface StackFrame { + readonly fn?: string; + readonly file: string; + readonly line: number; + readonly column: number; +} + +function stackFramesOf(stack: string): StackFrame[] { + const frames: StackFrame[] = []; + for (const raw of stack.split('\n')) { + const match = STACK_FRAME_RE.exec(raw); + if (!match) continue; + const [, fn, location, line, column] = match; + let file = location; + if (file.startsWith('async ')) file = file.slice('async '.length); + if (file.startsWith('file://')) { + try { + file = fileURLToPath(file); + } catch { + continue; + } + } + frames.push({ fn: fn?.replace(/^async /, ''), file, line: Number(line), column: Number(column) }); + } + return frames; +} + +function sourceMapOf(bundle: EvaluatedConfigBundle): { map: SourceMap; sources: Set } | undefined { + const match = INLINE_SOURCE_MAP_RE.exec(bundle.text); + if (!match) return undefined; + try { + const payload = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')); + return { + map: new SourceMap(payload), + sources: new Set(Array.isArray(payload.sources) ? payload.sources : []), + }; + } catch { + return undefined; + } +} + +/** A map `sources` entry as an absolute path: esbuild writes them relative to its working directory. */ +function absoluteSource(source: string, cwd: string): string | undefined { + if (source.startsWith('file://')) { + try { + return fileURLToPath(source); + } catch { + return undefined; + } + } + return path.resolve(cwd, source); +} + +/** The helper the frame names, when it is a `define*` function and the authored line spells that name. */ +function helperOf(frame: StackFrame | undefined, file: string, line: number): string | undefined { + const name = frame?.fn?.split('.').pop(); + if (!name || !DEFINE_HELPER_NAME_RE.test(name)) return undefined; + let text: string | undefined; + try { + text = fs.readFileSync(file, 'utf8').split(/\r?\n/)[line - 1]; + } catch { + return undefined; + } + return text !== undefined && new RegExp(`\\b${name}\\b`).test(text) ? name : undefined; +} + +/** + * The authored file, line, column and helper of the call that threw `stack` + * while `bundle` evaluated, or `undefined` when the stack cannot tell. + * + * @param stack the thrown error's `stack` + * @param bundle the bundle bundle-require evaluated, read before it was deleted + * @param cwd the working directory esbuild resolved the map's `sources` against + */ +export function locateConfigLoadZodError( + stack: string | undefined, + bundle: EvaluatedConfigBundle | undefined, + cwd: string, +): ConfigLoadZodLocation | undefined { + if (!stack || !bundle) return undefined; + const mapped = sourceMapOf(bundle); + if (!mapped) return undefined; + const authored = new Set(); + for (const source of mapped.sources) { + const absolute = absoluteSource(source, cwd); + if (absolute) authored.add(absolute); + } + + const frames = stackFramesOf(stack); + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + let located: { file: string; line: number; column: number } | undefined; + if (frame.file === bundle.file) { + // V8 frames are 1-based; the map is 0-based. The entry must sit on the + // frame's own generated line — `findEntry` otherwise answers the nearest + // earlier mapping, which can be another module's code. + const entry = mapped.map.findEntry(frame.line - 1, frame.column - 1) as { + generatedLine?: number; + originalSource?: string; + originalLine?: number; + originalColumn?: number; + }; + if ( + entry.generatedLine !== frame.line - 1 + || typeof entry.originalSource !== 'string' + || typeof entry.originalLine !== 'number' + || typeof entry.originalColumn !== 'number' + ) { + return undefined; + } + const file = absoluteSource(entry.originalSource, cwd); + if (!file) return undefined; + located = { file, line: entry.originalLine + 1, column: entry.originalColumn + 1 }; + } else if (authored.has(frame.file)) { + // `--enable-source-maps`: V8 already mapped the frame to the authored file. + located = { file: frame.file, line: frame.line, column: frame.column }; + } + if (!located) continue; + if (!fs.existsSync(located.file)) return undefined; + const helper = helperOf(frames[i - 1], located.file, located.line); + return helper ? { ...located, helper } : located; + } + return undefined; +} + +/** A path as the author reads it: relative to the working directory when it is under it. */ +function displayPath(file: string, cwd: string): string { + const relative = path.relative(cwd, file); + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file; +} + +/** + * The readable refusal for a raw `ZodError` a config load threw. + * + * A plain `Error`, so every command's catch-all prints it exactly where it + * printed the JSON array before — `--json` copies `message` into its + * envelope's `error` — and no catch-all changes. The `ZodError` rides as + * `cause`. + */ +export class ConfigLoadRefusalError extends Error { + constructor(message: string, cause: Error) { + super(message, { cause }); + this.name = 'ConfigLoadRefusalError'; + } +} + +/** + * Turn a raw `ZodError` thrown while `configPath` loaded into + * {@link ConfigLoadRefusalError}: + * + * ```text + * 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 + * ``` + */ +export function configLoadRefusal( + error: Error & { issues: unknown[] }, + configPath: string, + bundle: EvaluatedConfigBundle | undefined, + cwd: string, +): ConfigLoadRefusalError { + const location = locateConfigLoadZodError(error.stack, bundle, cwd); + const where = location + ? `${displayPath(location.file, cwd)}:${location.line}:${location.column}` + : `${displayPath(configPath, cwd)} (loading it threw; the stack does not say which module)`; + const label = location?.helper ? `${location.helper} validation failed` : undefined; + const block = formatZodError(error as unknown as Parameters[0], label); + return new ConfigLoadRefusalError(`${where} · ${block}`, error); +} diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 3c669569443..82be1e8df47 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -3,12 +3,13 @@ import path from 'path'; import fs from 'fs'; import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import chalk from 'chalk'; -import { bundleRequire } from 'bundle-require'; +import { bundleRequire, dynamicImport } from 'bundle-require'; import type { Plugin } from 'esbuild'; import { hasStackProvenance, stackConversionsOf, type ConversionNotice } from '@objectstack/spec'; import { printErrorToStderr, printWarningToStderr } from './format.js'; +import { configLoadRefusal, isRawZodError, type EvaluatedConfigBundle } from './config-load-refusal.js'; export interface LoadedConfig { config: any; @@ -769,6 +770,51 @@ function authoredArgumentOf(value: unknown): unknown { return current; } +/** + * Bundle and evaluate the config module — the ONE place a config's module + * graph runs, for {@link loadConfig} and for `os serve`'s own load. + * + * A raw `ZodError` thrown while the module evaluates — a `define*` helper's + * `Schema.parse` refusing its input — is rethrown as the located, formatted + * refusal of `./config-load-refusal.ts` instead of reaching a catch-all whose + * `error.message` is the issues as a JSON array. Every other throw (a syntax + * error, a missing import, a helper's own prose refusal, `defineStack`'s + * formatted one) passes through untouched. + * + * The bundle's text is read just before bundle-require imports it, because + * bundle-require deletes the file once the import settles; the refusal needs + * its inline source map to turn the stack frame back into the authored file. + * + * @param plugins esbuild plugins to bundle with (the authored-source shim) + */ +export async function bundleConfigModule( + absolutePath: string, + plugins?: Plugin[], +): Promise<{ mod: any }> { + const cwd = process.cwd(); + let bundle: EvaluatedConfigBundle | undefined; + try { + return await bundleRequire({ + filepath: absolutePath, + cwd, + external: BUNDLE_REQUIRE_EXTERNALS, + ...(plugins ? { esbuildOptions: { plugins } } : {}), + require: (outfile, ctx) => { + const file = outfile.startsWith('file:') ? fileURLToPath(outfile) : outfile; + try { + bundle = { file, text: fs.readFileSync(file, 'utf8') }; + } catch { + // Unreadable: the refusal then names the config entry, never a guess. + } + return dynamicImport(outfile, ctx); + }, + }); + } catch (error) { + if (isRawZodError(error)) throw configLoadRefusal(error, absolutePath, bundle, cwd); + throw error; + } +} + /** * Load and bundle a config file using bundle-require. * Returns the resolved config object, its load time, and the provenance of @@ -815,13 +861,10 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): const absolutePath = resolveConfigPath(source); const start = Date.now(); - const { mod } = await bundleRequire({ - filepath: absolutePath, - external: BUNDLE_REQUIRE_EXTERNALS, - ...(options?.authoredSource - ? { esbuildOptions: { plugins: [authoredSourcePlugin(absolutePath)] } } - : {}), - }); + const { mod } = await bundleConfigModule( + absolutePath, + options?.authoredSource ? [authoredSourcePlugin(absolutePath)] : undefined, + ); const baseConfig = mod.default || mod; if (!baseConfig) { diff --git a/packages/cli/test/config-load-zod-refusal.test.ts b/packages/cli/test/config-load-zod-refusal.test.ts new file mode 100644 index 00000000000..cd079b9eca2 --- /dev/null +++ b/packages/cli/test/config-load-zod-refusal.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `define*` helper's `ZodError` thrown while the config module loads reaches + * the author as ONE located, formatted refusal — never the issues as a JSON + * array — on every command that loads the config. + * + * The case is the one a newcomer hits first: a `defineView` whose + * `list.columns` mixes a column object with bare field names, in its own view + * module re-exported through an index. Before the load seam, `os validate` + * and `os build` printed `✗ [` and the raw issue array, naming no file and no + * helper. + * + * Controls: a config that loads cleanly, a module that does not resolve, a + * syntax error and a helper's own prose refusal all keep exactly what the + * bundle load threw. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bundleRequire } from 'bundle-require'; +import { z } from 'zod'; +import Compile from '../src/commands/compile'; +import Validate from '../src/commands/validate'; +import { BUNDLE_REQUIRE_EXTERNALS, loadConfig } from '../src/utils/config'; +import { configLoadRefusal } from '../src/utils/config-load-refusal'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); + +interface Run { + code: number; + stdout: string; +} + +async function run(command: typeof Compile | typeof Validate, argv: string[]): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined; + done?.(null); + return true; + }) as never); + const exitCodeBefore = process.exitCode; + let code = 0; + try { + await command.run(argv, { root: CLI_ROOT }); + } catch (error) { + const exit = (error as { oclif?: { exit?: unknown } }).oclif?.exit; + if (typeof exit !== 'number') throw error; + code = exit; + } finally { + spy.mockRestore(); + process.exitCode = exitCodeBefore; + } + // eslint-disable-next-line no-control-regex + return { code, stdout: chunks.join('').replace(/\u001b\[[0-9;]*m/g, '') }; +} + +const MANIFEST = `{ id: 'com.example.support-desk', namespace: 'support_desk', version: '0.1.0', type: 'app', name: 'Support Desk' }`; + +const OBJECT_MODULE = `import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Ticket = ObjectSchema.create({ + name: 'support_desk_ticket', + label: 'Ticket', + sharingModel: 'private', + fields: { + subject: Field.text({ label: 'Subject', required: true }), + status: Field.text({ label: 'Status' }), + priority: Field.text({ label: 'Priority' }), + }, +}); +`; + +const viewModule = (columns: string) => `import { defineView } from '@objectstack/spec/ui'; + +const data = { provider: 'object' as const, object: 'support_desk_ticket' }; + +export const TicketViews = defineView({ + list: { label: 'All Tickets', type: 'grid', data, columns: ${columns} }, +}); +`; + +const CONFIG = `import { defineStack } from '@objectstack/spec'; +import { Ticket } from './src/objects/ticket.object'; +import { TicketViews } from './src/views'; + +export default defineStack({ manifest: ${MANIFEST}, objects: [Ticket], views: [TicketViews] }); +`; + +let root = ''; + +/** A project directory: `objectstack.config.ts` plus `files`, with `@objectstack/spec` linked. */ +function project(label: string, files: Record): string { + const dir = join(root, label); + for (const [name, text] of Object.entries(files)) { + mkdirSync(join(dir, name, '..'), { recursive: true }); + writeFileSync(join(dir, name), text); + } + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: label, version: '0.0.0', private: true, type: 'module' })); + linkSpec(dir); + return join(dir, 'objectstack.config.ts'); +} + +const viewProject = (label: string, columns: string) => + project(label, { + 'objectstack.config.ts': CONFIG, + 'src/objects/ticket.object.ts': OBJECT_MODULE, + 'src/views/ticket.view.ts': viewModule(columns), + 'src/views/index.ts': `export { TicketViews } from './ticket.view';\n`, + }); + +/** What bundle-require itself throws for `config`: the message a non-Zod load error must keep. */ +async function bundleLoadMessage(config: string): Promise { + try { + await bundleRequire({ filepath: config, external: BUNDLE_REQUIRE_EXTERNALS }); + } catch (error) { + return (error as Error).message; + } + throw new Error(`expected ${config} to fail to load`); +} + +async function loadMessage(config: string): Promise { + try { + await loadConfig(config); + } catch (error) { + return (error as Error).message; + } + throw new Error(`expected ${config} to fail to load`); +} + +let mixed = ''; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-config-load-zod-refusal-')); + mixed = viewProject('mixed', `[{ field: 'subject', link: true }, 'status', 'priority']`); +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +const LOCATED_HEAD = /src\/views\/ticket\.view\.ts:5:28 · defineView validation failed \(1 issue\):/; + +// The first in-process command run loads oclif and the spec (~5 s on a shared box). +describe('a define* helper ZodError at config load is one located refusal', { timeout: 60_000 }, () => { + it('os validate --json: the error names the view module, line, helper and path; exit 1', async () => { + const r = await run(Validate, [mixed, '--json']); + expect(r.code).toBe(1); + const payload = JSON.parse(r.stdout) as { valid: boolean; error: string }; + expect(payload.valid).toBe(false); + expect(payload.error).toMatch(LOCATED_HEAD); + expect(payload.error).toContain('✗ list.columns: Invalid input'); + expect(payload.error).toContain('✗ list.columns.0:'); + expect(payload.error.trimStart().startsWith('[')).toBe(false); + }); + + it('os validate text face: no raw JSON array reaches the terminal', async () => { + const r = await run(Validate, [mixed]); + expect(r.code).toBe(1); + expect(r.stdout).toMatch(LOCATED_HEAD); + expect(r.stdout).not.toMatch(/✗ \[\s*$/m); + expect(r.stdout).not.toContain('"code": "invalid_union"'); + }); + + it('os build --json carries the same refusal as os validate, exit code unchanged', async () => { + const validate = JSON.parse((await run(Validate, [mixed, '--json'])).stdout) as { error: string }; + const out = join(root, 'mixed', 'dist', 'objectstack.json'); + const r = await run(Compile, [mixed, '--output', out, '--json']); + expect(r.code).not.toBe(0); + const payload = JSON.parse(r.stdout) as { error: string }; + expect(payload.error).toBe(validate.error); + }); + + it('a call that is not a define* helper is located without naming a helper', async () => { + const config = project('object-create', { + 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +const T = ObjectSchema.create({ name: 'BadName', label: 'T', fields: { a: Field.text({ label: 'A' }) } }); +export default defineStack({ manifest: ${MANIFEST}, objects: [T] }); +`, + }); + const message = await loadMessage(config); + expect(message).toMatch(/objectstack\.config\.ts:3:\d+ · Validation failed \(1 issue\):/); + expect(message).toContain('✗ name:'); + }); + + it('a stack the bundle frame is missing from names the config entry, never a guessed module', () => { + const parsed = z.object({ name: z.string() }).safeParse({ name: 1 }); + if (parsed.success) throw new Error('fixture parse unexpectedly succeeded'); + const error = parsed.error as unknown as Error & { issues: unknown[] }; + error.stack = 'ZodError: []\n at defineView (file:///elsewhere/spec/dist/index.mjs:1:1)'; + const config = join(root, 'mixed', 'objectstack.config.ts'); + const refusal = configLoadRefusal(error, config, undefined, join(root, 'mixed')); + expect(refusal.message).toMatch( + /^objectstack\.config\.ts \(loading it threw; the stack does not say which module\) · Validation failed \(1 issue\):/, + ); + expect(refusal.cause).toBe(error); + }); +}); + +describe('controls: everything else a load throws is unchanged', { timeout: 60_000 }, () => { + it('a config that loads cleanly still validates', async () => { + const clean = viewProject('clean', `[{ field: 'subject', link: true }, { field: 'status' }, { field: 'priority' }]`); + const r = await run(Validate, [clean, '--json']); + expect(r.code).toBe(0); + expect((JSON.parse(r.stdout) as { valid: boolean }).valid).toBe(true); + }); + + it('a module that does not resolve keeps the bundle load message', async () => { + const config = project('missing-import', { + 'objectstack.config.ts': `import { nothing } from './src/not-there';\nexport default nothing;\n`, + }); + expect(await loadMessage(config)).toBe(await bundleLoadMessage(config)); + }); + + it('a syntax error keeps the bundle load message', async () => { + const config = project('syntax', { 'objectstack.config.ts': `export default {\n` }); + expect(await loadMessage(config)).toBe(await bundleLoadMessage(config)); + }); + + it("a helper's own prose refusal keeps its words", async () => { + const config = project('no-views', { + 'objectstack.config.ts': `import { defineView } from '@objectstack/spec/ui';\nexport default defineView({});\n`, + }); + expect(await loadMessage(config)).toBe(await bundleLoadMessage(config)); + expect(await loadMessage(config)).toMatch(/^defineView: the container defines no views/); + }); +}); From 5af532cccc59562cd116e709f2af0d7276085165 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:40:57 +0000 Subject: [PATCH 2/2] test(cli): pin the located head on the os build envelope too Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5mch1VryTyQsMG5U86 --- packages/cli/test/config-load-zod-refusal.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/test/config-load-zod-refusal.test.ts b/packages/cli/test/config-load-zod-refusal.test.ts index cd079b9eca2..02805234694 100644 --- a/packages/cli/test/config-load-zod-refusal.test.ts +++ b/packages/cli/test/config-load-zod-refusal.test.ts @@ -173,6 +173,7 @@ describe('a define* helper ZodError at config load is one located refusal', { ti const r = await run(Compile, [mixed, '--output', out, '--json']); expect(r.code).not.toBe(0); const payload = JSON.parse(r.stdout) as { error: string }; + expect(payload.error).toMatch(LOCATED_HEAD); expect(payload.error).toBe(validate.error); });