diff --git a/.changeset/slick-peaches-wear.md b/.changeset/slick-peaches-wear.md new file mode 100644 index 000000000..7ecf95bb6 --- /dev/null +++ b/.changeset/slick-peaches-wear.md @@ -0,0 +1,12 @@ +--- +"@emdash-cms/plugin-cli": patch +--- + +Refactors the build pipeline's runtime validation of the probed plugin's +default export to use a Zod schema. Error messages keep the same format +(`hook "X" must be a function or { handler, ... }`, `hook "X" has +invalid FIELD VALUE (...)`). Exotic-object entries (Date, RegExp, +Promise, class instances) now produce the wrong-shape error instead of +falling through to a misleading "missing handler" error. BigInt / +cyclic-object / function / symbol field values are rendered safely in +error messages instead of crashing with a TypeError. diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts index 30d5aab5a..be96577af 100644 --- a/packages/plugin-cli/src/build/pipeline.ts +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -50,6 +50,12 @@ import { VersionMismatchError, type NormalisedManifest, } from "../manifest/translate.js"; +import { + ProbedDefaultSchema, + type ProbedDefault, + type ProbedHookEntry, + type ProbedRouteEntry, +} from "./probe-schema.js"; const PLUGIN_ENTRY_PATH = "src/plugin.ts"; const PACKAGE_JSON_PATH = "package.json"; @@ -295,104 +301,186 @@ export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise; + const pluginModule: unknown = await import(probeOutputPath); + if (!isObjectRecord(pluginModule)) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry} did not produce a module object on probe (got ${describeShape(pluginModule)}).`, + ); + } if (pluginModule.default === undefined) { throw new BuildPipelineError( "INVALID_PLUGIN_FORMAT", `${entries.pluginEntry} has no \`default\` export. Sandboxed plugins must \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin". A named-only export (e.g. \`export const plugin = ...\`) produces an empty bundle.`, ); } - const definition = pluginModule.default as Record; - if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + const definition = pluginModule.default; + if (!isObjectRecord(definition)) { throw new BuildPipelineError( "INVALID_PLUGIN_FORMAT", `${entries.pluginEntry} must default-export an object with \`hooks\` and/or \`routes\` (sandboxed plugin shape: \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin"). Got ${describeShape(definition)}.`, ); } - const hooks = definition.hooks as Record | undefined; - const routes = definition.routes as Record | undefined; - - if (hooks) { - for (const hookName of Object.keys(hooks)) { - const hookEntry = hooks[hookName]; - const handler = extractHookHandler(hookEntry); - if (!handler) { - throw new BuildPipelineError( - "INVALID_PLUGIN_FORMAT", - `${entries.pluginEntry}: hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, - ); - } - const config: Record = - typeof hookEntry === "object" && hookEntry !== null - ? (hookEntry as Record) - : {}; - // Re-validate hook config values at build time. The strict - // `SandboxedPlugin` type rejects these at compile time; - // this catches authors who bypass typecheck (untyped JS, - // dynamic config). - if ( - config.errorPolicy !== undefined && - config.errorPolicy !== "continue" && - config.errorPolicy !== "abort" - ) { - throw new BuildPipelineError( - "INVALID_PLUGIN_FORMAT", - `${entries.pluginEntry}: hook "${hookName}" has invalid errorPolicy ${JSON.stringify(config.errorPolicy)} (must be "continue" or "abort").`, - ); - } - if ( - config.priority !== undefined && - (typeof config.priority !== "number" || !Number.isFinite(config.priority)) - ) { - throw new BuildPipelineError( - "INVALID_PLUGIN_FORMAT", - `${entries.pluginEntry}: hook "${hookName}" has invalid priority ${JSON.stringify(config.priority)} (must be a finite number).`, - ); - } - if ( - config.timeout !== undefined && - (typeof config.timeout !== "number" || - !Number.isFinite(config.timeout) || - config.timeout < 0) - ) { - throw new BuildPipelineError( - "INVALID_PLUGIN_FORMAT", - `${entries.pluginEntry}: hook "${hookName}" has invalid timeout ${JSON.stringify(config.timeout)} (must be a non-negative finite number).`, - ); - } - resolvedPlugin.hooks[hookName] = { - handler, - priority: config.priority ?? 100, - timeout: config.timeout ?? 5000, - dependencies: (config.dependencies as string[] | undefined) ?? [], - errorPolicy: (config.errorPolicy as string | undefined) ?? "abort", - exclusive: (config.exclusive as boolean | undefined) ?? false, - pluginId: resolvedPlugin.id, - }; + const parsed = parseProbedDefault(entries.pluginEntry, definition); + + if (parsed.hooks) { + for (const [hookName, hookEntry] of Object.entries(parsed.hooks)) { + resolvedPlugin.hooks[hookName] = assembleHook(hookEntry, resolvedPlugin.id); } } - if (routes) { - for (const [name, route] of Object.entries(routes)) { - const handler = extractRouteHandler(route); - if (!handler) { - throw new BuildPipelineError( - "INVALID_PLUGIN_FORMAT", - `${entries.pluginEntry}: route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, - ); - } - const routeObj: Record = - typeof route === "object" && route !== null ? (route as Record) : {}; - resolvedPlugin.routes[name] = { - handler, - public: routeObj.public as boolean | undefined, - }; + if (parsed.routes) { + for (const [routeName, routeEntry] of Object.entries(parsed.routes)) { + resolvedPlugin.routes[routeName] = assembleRoute(routeEntry); } } return resolvedPlugin; } +/** + * Validate the probed default export against `ProbedDefaultSchema` and + * translate any Zod issue into a `BuildPipelineError`. The first issue + * wins so authors see one focused message rather than an issue tree. + * + * Path-keyed dispatch: an issue at `["hooks", "X"]` produces the + * "must be a function or { handler, ... }" message; an issue at + * `["hooks", "X", "field", ...]` produces the "has invalid FIELD VALUE" + * message. Anything else falls back to a generic path-prefixed message. + * + * Exported so tests can lock in the error-message contract without + * spinning up a real probe build. + */ +export function parseProbedDefault(pluginEntry: string, definition: unknown): ProbedDefault { + let result: ReturnType; + try { + result = ProbedDefaultSchema.safeParse(definition); + } catch (error) { + // Defensive: Zod 4 has been observed to throw `TypeError` when an + // entry is an exotic shape it doesn't expect. The schema's + // `normaliseEntry` preprocess catches the cases we know about, + // but wrap `safeParse` so any future surprise still surfaces as a + // `BuildPipelineError` rather than a raw stack trace. + const message = error instanceof Error ? error.message : String(error); + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${pluginEntry}: probed default export could not be validated (${message}). Check for entries with unusual shapes (Promises, class instances, etc.).`, + ); + } + if (result.success) return result.data; + + const issue = result.error.issues[0]; + if (!issue) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${pluginEntry}: probed default export failed validation.`, + ); + } + + const [collection, entryName, ...rest] = issue.path; + if ((collection === "hooks" || collection === "routes") && typeof entryName === "string") { + const kind = collection === "hooks" ? "hook" : "route"; + const entry = getProperty(getProperty(definition, collection), entryName); + if (rest.length === 0) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${pluginEntry}: ${kind} "${entryName}" must be a function or { handler: function, ... }. Got ${describeShape(entry)}.`, + ); + } + // Per-field issue (errorPolicy, priority, timeout, dependencies, + // public, …). The displayed value is the field as a whole, not + // the deeper element the path points at; the Zod message + // describes the actual fault. + const field = formatFieldPath(rest); + const fieldValue = typeof rest[0] === "string" ? getProperty(entry, rest[0]) : undefined; + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${pluginEntry}: ${kind} "${entryName}" has invalid ${field} ${safeStringify(fieldValue)} (${issue.message}).`, + ); + } + + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${pluginEntry}: ${issue.message} (at ${issue.path.join(".") || ""}).`, + ); +} + +/** + * Render an issue path inside a hook/route entry as `field`, + * `field[index]`, or `field.sub`. Used for human-readable error + * messages only; never fed back into property lookups. + */ +function formatFieldPath(path: readonly PropertyKey[]): string { + let out = ""; + for (const segment of path) { + if (typeof segment === "number") { + out += `[${segment}]`; + } else if (out === "") { + out += String(segment); + } else { + out += `.${String(segment)}`; + } + } + return out || ""; +} + +/** + * Read a property off an `unknown` value, returning `undefined` for any + * non-object input. Used only to recover the original user-supplied + * value back off the definition for error-message formatting, never to + * drive control flow. Exotic objects (Array, Map, Date, class + * instances) return whatever the runtime gives them — harmless for + * an error-message helper. + */ +function getProperty(value: unknown, key: string): unknown { + if (value === null || typeof value !== "object") return undefined; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- error-message helper; widening to Record for plain-object lookup is intentional, exotic objects return harmless results + return (value as Record)[key]; +} + +/** + * `JSON.stringify` that survives values it can't serialise (`BigInt`, + * cyclic structures, `undefined`, functions, symbols) by falling back + * to `describeShape`. Used only to embed user-supplied values in + * `BuildPipelineError` messages. + */ +function safeStringify(value: unknown): string { + try { + const json = JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? `${v.toString()}n` : v, + ); + // `JSON.stringify(undefined)` is `undefined` (not a string). Fall + // through to the shape description in that case. + if (json === undefined) return describeShape(value); + return json; + } catch { + return describeShape(value); + } +} + +function assembleHook(entry: ProbedHookEntry, pluginId: string): ResolvedPlugin["hooks"][string] { + // `preprocess` in `probe-schema.ts` normalises the bare-function form + // into `{ handler }` before validation, so every entry that reaches + // here is in the config form. + return { + handler: entry.handler, + priority: entry.priority ?? 100, + timeout: entry.timeout ?? 5000, + dependencies: entry.dependencies ?? [], + errorPolicy: entry.errorPolicy ?? "abort", + exclusive: entry.exclusive ?? false, + pluginId, + }; +} + +function assembleRoute(entry: ProbedRouteEntry): ResolvedPlugin["routes"][string] { + return { handler: entry.handler, public: entry.public }; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + // ────────────────────────────────────────────────────────────────────────── // Phase 3: runtime build // ────────────────────────────────────────────────────────────────────────── @@ -470,24 +558,6 @@ export async function buildRuntime(ctx: BuildRuntimeContext): Promise {...}`) + * is normalised via `z.preprocess` into `{ handler: fn }` so the + * schema only has to validate one shape per entry. + */ + +import { z } from "zod"; + +/** A function reference; the probe doesn't introspect signatures. */ +const FunctionSchema = z.custom<(...args: unknown[]) => unknown>( + (value) => typeof value === "function", + { message: "must be a function" }, +); + +/** + * Map a probed hook/route entry into the canonical config form + * (`{ handler }`) for the schema. + * + * - Functions become `{ handler: fn }`. + * - Plain objects (prototype is `Object.prototype` or `null`) pass + * through; the schema validates their fields. + * - Anything else (built-ins like `Date`, `RegExp`, `Promise`, `Map`, + * author-defined class instances, primitives) is reduced to `null` + * so the schema produces a single "expected object" issue at the + * entry root. Without this, the schema would reach into the wrong- + * shaped object for `handler` and report a misleading "missing + * handler" issue. + */ +function normaliseEntry(value: unknown): unknown { + if (typeof value === "function") return { handler: value }; + if (value === null || typeof value !== "object") return value; + const proto: unknown = Object.getPrototypeOf(value); + if (proto === Object.prototype || proto === null) return value; + return null; +} + +/** + * Finite-number check that produces a single clean message regardless + * of how the input is invalid (wrong type, `NaN`, `Infinity`). Zod 4's + * `z.number()` rejects `NaN`/`Infinity` with its own per-case messages + * (`"expected number, received NaN"`) which read awkwardly to plugin + * authors; this custom check folds all three failure modes into the + * single "must be a finite number" line. + */ +const FiniteNumberSchema = z.custom((v) => typeof v === "number" && Number.isFinite(v), { + message: "must be a finite number", +}); + +const NonNegativeFiniteNumberSchema = z.custom( + (v) => typeof v === "number" && Number.isFinite(v) && v >= 0, + { message: "must be a non-negative finite number" }, +); + +const HookEntryConfigSchema = z.looseObject({ + handler: FunctionSchema, + priority: FiniteNumberSchema.optional(), + timeout: NonNegativeFiniteNumberSchema.optional(), + dependencies: z.array(z.string()).optional(), + errorPolicy: z + .enum(["continue", "abort"], { + message: `must be "continue" or "abort"`, + }) + .optional(), + exclusive: z.boolean().optional(), +}); + +export const HookEntrySchema = z.preprocess(normaliseEntry, HookEntryConfigSchema); + +const RouteEntryConfigSchema = z.looseObject({ + handler: FunctionSchema, + public: z.boolean().optional(), +}); + +export const RouteEntrySchema = z.preprocess(normaliseEntry, RouteEntryConfigSchema); + +/** + * Coerce a non-record `hooks` / `routes` collection to an empty + * object so it harvests no entries without failing the build. Plain + * records pass through; `undefined` is preserved so the wrapped + * `.optional()` accepts a missing collection. + * + * Returns `{}` rather than `undefined` so the wrapped `z.record` / + * `.optional()` chain composes correctly under Zod 4 (an optional + * record receiving `undefined` through `preprocess` errors with + * "expected nonoptional, received undefined"). + */ +function coerceOptionalRecord(value: unknown): unknown { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + return value; +} + +/** + * The probed module's default export shape. `hooks` and `routes` are + * both optional — a plugin that declares only one of them is valid. + * The top level is `looseObject` so additional keys on the default + * export (e.g. metadata fields the runtime doesn't use) don't fail the + * build; entries inside `hooks` / `routes` are validated strictly. + */ +export const ProbedDefaultSchema = z.looseObject({ + hooks: z.preprocess(coerceOptionalRecord, z.record(z.string(), HookEntrySchema)).optional(), + routes: z.preprocess(coerceOptionalRecord, z.record(z.string(), RouteEntrySchema)).optional(), +}); + +export type ProbedDefault = z.infer; +export type ProbedHookEntry = z.infer; +export type ProbedRouteEntry = z.infer; diff --git a/packages/plugin-cli/tests/probe-schema.test.ts b/packages/plugin-cli/tests/probe-schema.test.ts new file mode 100644 index 000000000..a9f6903f8 --- /dev/null +++ b/packages/plugin-cli/tests/probe-schema.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for `parseProbedDefault`, the runtime validation the build + * pipeline runs against the imported default export of a probed plugin. + * + * These tests lock in the user-facing error-message contract: plugin + * authors and any aggregator that scrapes stderr depend on these + * exact strings. Behaviour changes here should be deliberate. + */ + +import { describe, expect, it } from "vitest"; + +import { BuildPipelineError, parseProbedDefault } from "../src/build/pipeline.js"; + +const PLUGIN_ENTRY = "src/plugin.ts"; + +function expectFailure(input: Record): BuildPipelineError { + try { + parseProbedDefault(PLUGIN_ENTRY, input); + } catch (error) { + if (error instanceof BuildPipelineError) return error; + throw error; + } + throw new Error("Expected parseProbedDefault to throw, but it succeeded"); +} + +describe("parseProbedDefault", () => { + describe("valid shapes", () => { + it("accepts the empty default export", () => { + const result = parseProbedDefault(PLUGIN_ENTRY, {}); + expect(result.hooks).toBeUndefined(); + expect(result.routes).toBeUndefined(); + }); + + it("normalises bare-function hooks to the config form", () => { + const handler = (): void => {}; + const result = parseProbedDefault(PLUGIN_ENTRY, { + hooks: { "content:beforeSave": handler }, + }); + expect(result.hooks?.["content:beforeSave"]).toEqual({ handler }); + }); + + it("preserves config-form hook fields", () => { + const handler = (): void => {}; + const result = parseProbedDefault(PLUGIN_ENTRY, { + hooks: { + "content:beforeSave": { + handler, + priority: 50, + timeout: 1000, + dependencies: ["other-plugin"], + errorPolicy: "continue", + exclusive: true, + }, + }, + }); + expect(result.hooks?.["content:beforeSave"]).toEqual({ + handler, + priority: 50, + timeout: 1000, + dependencies: ["other-plugin"], + errorPolicy: "continue", + exclusive: true, + }); + }); + + it("normalises bare-function routes to the config form", () => { + const handler = (): void => {}; + const result = parseProbedDefault(PLUGIN_ENTRY, { + routes: { ping: handler }, + }); + expect(result.routes?.ping).toEqual({ handler }); + }); + + it("preserves config-form route fields including public", () => { + const handler = (): void => {}; + const result = parseProbedDefault(PLUGIN_ENTRY, { + routes: { ping: { handler, public: true } }, + }); + expect(result.routes?.ping).toEqual({ handler, public: true }); + }); + + it("passes through unknown extra keys on the default export", () => { + const handler = (): void => {}; + const result = parseProbedDefault(PLUGIN_ENTRY, { + hooks: { h: { handler, experimentalKnob: "future" } }, + somethingElse: 42, + }); + expect(result.hooks?.h).toMatchObject({ handler, experimentalKnob: "future" }); + }); + }); + + describe("hook validation errors", () => { + it("rejects a non-function/non-object hook entry", () => { + const error = expectFailure({ hooks: { h: "not a function" } }); + expect(error.code).toBe("INVALID_PLUGIN_FORMAT"); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got string.`, + ); + }); + + it("rejects a Date hook entry as the wrong shape", () => { + // Only plain objects (`Object.prototype` or null prototype) + // reach the per-field validation. Anything with a more + // specific prototype is treated as a wrong-shaped entry. + const error = expectFailure({ hooks: { h: new Date() } }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got object.`, + ); + }); + + it("rejects a RegExp hook entry as the wrong shape", () => { + const error = expectFailure({ hooks: { h: /x/ } }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got object.`, + ); + }); + + it("rejects a Promise hook entry as the wrong shape", () => { + // Forgot-to-await mistake: `hooks: { h: makeHandler() }` where + // `makeHandler` is async. Caught at the entry-shape level so + // the author sees the standard wrong-shape message. + const error = expectFailure({ hooks: { h: Promise.resolve(() => {}) } }); + expect(error.code).toBe("INVALID_PLUGIN_FORMAT"); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got object.`, + ); + }); + + it("rejects a hook entry missing its handler", () => { + const error = expectFailure({ hooks: { h: { errorPolicy: "abort" } } }); + expect(error.code).toBe("INVALID_PLUGIN_FORMAT"); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid handler undefined (must be a function).`, + ); + }); + + it("rejects an invalid errorPolicy", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, errorPolicy: "bogus" } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid errorPolicy "bogus" (must be "continue" or "abort").`, + ); + }); + + it("rejects a non-number priority", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: "high" } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid priority "high" (must be a finite number).`, + ); + }); + + it("rejects an Infinity priority", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: Infinity } }, + }); + // `JSON.stringify(Infinity)` is the string "null" per the JSON + // spec; safeStringify passes that through unchanged. + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid priority null (must be a finite number).`, + ); + }); + + it("rejects a NaN priority", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: Number.NaN } }, + }); + expect(error.message).toContain(`hook "h" has invalid priority`); + expect(error.message).toContain(`must be a finite number`); + }); + + it("rejects a negative timeout", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, timeout: -100 } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid timeout -100 (must be a non-negative finite number).`, + ); + }); + + it("rejects a non-array dependencies", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, dependencies: "single" } }, + }); + expect(error.message).toContain(`hook "h" has invalid dependencies "single"`); + }); + + it("rejects a non-boolean exclusive", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, exclusive: "yes" } }, + }); + expect(error.message).toContain(`hook "h" has invalid exclusive "yes"`); + }); + + it("renders a BigInt field value as n", () => { + // `JSON.stringify` throws on BigInt; safeStringify marshals + // it through a JSON replacer that emits the `10n` notation. + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: 10n } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid priority "10n" (must be a finite number).`, + ); + }); + + it("renders a function field value as 'function'", () => { + // `JSON.stringify(fn)` returns the JS value `undefined`; + // safeStringify falls back to `describeShape`. + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: (): void => {} } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid priority function (must be a finite number).`, + ); + }); + + it("renders a symbol field value as 'symbol'", () => { + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: Symbol("x") } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid priority symbol (must be a finite number).`, + ); + }); + + it("renders a cyclic-object field value as 'object'", () => { + // `JSON.stringify` throws on cyclic structures; safeStringify + // catches and falls back to `describeShape`. + const cycle: Record = {}; + cycle.self = cycle; + const error = expectFailure({ + hooks: { h: { handler: (): void => {}, priority: cycle } }, + }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: hook "h" has invalid priority object (must be a finite number).`, + ); + }); + + it("displays the whole array when a single element of dependencies is bad", () => { + // The issue path is `["hooks","h","dependencies",1]`; the + // displayed value is the field as a whole rather than the + // offending element. The Zod message names the actual fault. + const error = expectFailure({ + hooks: { + h: { handler: (): void => {}, dependencies: ["a", 42, "c"] }, + }, + }); + expect(error.message).toContain(`hook "h" has invalid dependencies[1] ["a",42,"c"]`); + expect(error.message).toContain("expected string"); + }); + + it("surfaces exactly one issue when multiple fields are bad", () => { + const error = expectFailure({ + hooks: { + h: { handler: (): void => {}, priority: "high", errorPolicy: "bogus" }, + }, + }); + // Don't lock in *which* field surfaces first (Zod issue order + // is an implementation detail); enforce that only one does. + const matches = error.message.match(/has invalid /g); + expect(matches).toHaveLength(1); + expect(error.message).toMatch(/hook "h" has invalid (priority|errorPolicy)/); + }); + }); + + describe("route validation errors", () => { + it("rejects a non-function/non-object route entry", () => { + const error = expectFailure({ routes: { ping: 42 } }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: route "ping" must be a function or { handler: function, ... }. Got number.`, + ); + }); + + it("rejects a route entry missing its handler", () => { + const error = expectFailure({ routes: { ping: { public: true } } }); + expect(error.message).toBe( + `${PLUGIN_ENTRY}: route "ping" has invalid handler undefined (must be a function).`, + ); + }); + + it("rejects a non-boolean public flag", () => { + const error = expectFailure({ + routes: { ping: { handler: (): void => {}, public: "yes" } }, + }); + expect(error.message).toContain(`route "ping" has invalid public "yes"`); + }); + }); + + describe("non-record collection coercion", () => { + // A malformed `hooks` / `routes` outer collection harvests no + // entries but doesn't fail the build. Entries themselves are + // still strictly validated. + it("treats an array hooks field as empty", () => { + const result = parseProbedDefault(PLUGIN_ENTRY, { hooks: [] }); + expect(result.hooks).toEqual({}); + }); + + it("treats a string hooks field as empty", () => { + const result = parseProbedDefault(PLUGIN_ENTRY, { hooks: "nope" }); + expect(result.hooks).toEqual({}); + }); + + it("treats a null hooks field as empty", () => { + const result = parseProbedDefault(PLUGIN_ENTRY, { hooks: null }); + expect(result.hooks).toEqual({}); + }); + + it("treats a string routes field as empty", () => { + const result = parseProbedDefault(PLUGIN_ENTRY, { routes: "all of them" }); + expect(result.routes).toEqual({}); + }); + + it("treats a number routes field as empty", () => { + const result = parseProbedDefault(PLUGIN_ENTRY, { routes: 42 }); + expect(result.routes).toEqual({}); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ff602efb..327bae40a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7954,6 +7954,10 @@ packages: peerDependencies: kysely: '*' + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + kysely@0.29.2: resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} engines: {node: '>=22.0.0'} @@ -17567,6 +17571,8 @@ snapshots: dependencies: kysely: 0.29.2 + kysely@0.27.6: {} + kysely@0.29.2: {} layerr@3.0.0: {}