Files
2026-05-27 23:29:53 +01:00

9.5 KiB

Capability Spec Delta: validation (critical-bugfix)

Package: @suaveplan/validation Status: PROPOSED

This delta:

  1. Adds VL-13 — Synchronous parse paths MUST refuse to run when any refinement is async, surfacing VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC rather than silently reporting the value valid.
  2. Modifies VL-08 wording slightly to reflect the loud-fail contract in VL-13 (async refinements remain awaited only on parseAsync / safeParseAsync, but parse / safeParse no longer accept schemas carrying them).
  3. Modifies VL-09 to make the coerceString failure-mode explicit (returns null for non-coercible symbol inputs).
  4. Modifies VL-10 to document .withHooks(hooks) as a method on BaseSchema returning an immutable clone, and that hooks fire from every terminal branch of parse / parseAsync (and the safe variants by inheritance).
  5. Modifies VL-11 to document that emission happens inside BaseSchema.parse / BaseSchema.parseAsync (not as an opt-in by callers), tagged with schema.type = constructor.name and using performance.now() for the duration histogram.
  6. Adds **Verified by:** lines to every requirement (VL-01 through VL-13).

ADDED Requirements

VL-13 — Async-refinement loud-fail in synchronous parse

BaseSchema.parse and BaseSchema.safeParse MUST refuse to run when any refinement attached to the schema is async. parse MUST throw a ValidationError whose first issue carries the code VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC. safeParse MUST return ParseFailure with the same code. This eliminates the prior silent-skip behaviour where async predicates' promises were discarded and the value was reported valid regardless of the predicate's eventual result, which is a security-grade footgun for revocation / uniqueness / rate-limit checks.

parseAsync / safeParseAsync are unchanged — they continue to await async refinements normally.

Verified by: src/core/schema/schema.test.ts::"sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present" AND src/core/schema/schema.test.ts::"safeParse returns ASYNC_REFINEMENT_REQUIRES_PARSEASYNC failure when async refinement is present" AND src/core/schema/schema.test.ts::"async refinement passes" AND src/core/schema/schema.test.ts::"async refinement fails".

MODIFIED Requirements

VL-08 — Async refinements (modified)

.refine(fn) MUST accept both synchronous and async predicate functions. Async refinements are awaited via .parseAsync() / .safeParseAsync(). The synchronous parse paths (.parse() / .safeParse()) MUST NOT silently skip async refinements: see VL-13.

Verified by: src/core/schema/schema.test.ts::"async refinement passes" AND src/core/schema/schema.test.ts::"async refinement fails" AND src/core/schema/schema.test.ts::"sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present".

VL-09 — Coerce utilities (modified)

coerce(schema) MUST coerce string inputs to the target type before parsing. coerceString, coerceNumber, coerceBoolean, coerceDate are pure functions; a failed coercion returns null (which the wrapper translates into ParseFailure) rather than throwing. coerceString returns null for non-coercible inputs (symbol); other inputs are coerced via String().

Verified by: src/utils/coerce.test.ts (full suite — coerceString / coerceNumber / coerceBoolean / coerceDate + coerce wrapper) AND src/utils/coerce.test.ts::"returns null for symbol input (non-coercible per VL-09)".

VL-10 — Hooks (modified)

Schemas MUST support optional lifecycle hooks { beforeValidate, afterValidate, onError } settable via a .withHooks(hooks) method on BaseSchema that returns an immutable clone with the supplied hooks merged onto any pre-existing hooks (per-key replace semantics). Hooks are called from every terminal branch of parse / safeParse / parseAsync / safeParseAsync: beforeValidate is called before _parse, afterValidate on successful resolution, onError immediately before ValidationError is thrown / surfaced. In the synchronous path the hook return value (including any returned promise) is discarded; in the async path returned promises are awaited.

Verified by: src/core/schema/schema.spec-conformance.test.ts::"calls beforeValidate and afterValidate on successful sync parse" AND src/core/schema/schema.spec-conformance.test.ts::"calls onError on sync parse failure" AND src/core/schema/schema.spec-conformance.test.ts::"calls onError when sync refinement fails" AND src/core/schema/schema.spec-conformance.test.ts::"awaits async beforeValidate/afterValidate in parseAsync" AND src/core/schema/schema.spec-conformance.test.ts::"awaits async onError on async parse failure (type mismatch)" AND src/core/schema/schema.spec-conformance.test.ts::"awaits async onError when async refinement fails" AND src/core/schema/schema.spec-conformance.test.ts::"merges hooks across multiple withHooks calls (clone path)".

VL-11 — OTel instrumentation (modified)

validation.parse.success and validation.parse.failure counters and validation.parse.duration_ms histogram MUST be emitted via @opentelemetry/api from every terminal branch of BaseSchema.parse and BaseSchema.parseAsync (not opt-in). Each metric carries a schema.type attribute equal to this.constructor.name (e.g. "StringSchema", "ObjectSchema"). Duration is measured via performance.now(). All emission is a no-op when no MeterProvider is registered.

Verified by: src/core/schema/schema.spec-conformance.test.ts::"emits validation.parse.success on successful parse" AND src/core/schema/schema.spec-conformance.test.ts::"emits validation.parse.failure on failed parse" AND src/core/schema/schema.spec-conformance.test.ts::"emits failure on sync refinement failure" AND src/core/schema/schema.spec-conformance.test.ts::"emits success on async parse + emits duration histogram" AND src/core/schema/schema.spec-conformance.test.ts::"emits failure on async parse failure (type mismatch)" AND src/core/schema/schema.spec-conformance.test.ts::"emits failure on async refinement failure" AND src/otel/validation-otel.test.ts (no-op + lazy init).

Verified-by mapping (full requirement set)

Requirement Verified by
VL-01 — Self-contained schema engine tests/dep-graph.test.ts AND src/core/schema/schema.spec-conformance.test.ts::"string().parse returns string and accepts unknown input" AND src/core/schema/schema.spec-conformance.test.ts::"refine, transform, optional, nullable are present"
VL-02 — Never-throw contract src/core/schema/schema.test.ts::"throws ValidationError for invalid input" AND src/core/schema/schema.test.ts::"returns failure for invalid input" AND src/core/schema/schema.spec-conformance.test.ts::"safeParse never throws on bizarre input" AND src/core/schema/schema.spec-conformance.test.ts::"safeParseAsync never throws on bizarre input"
VL-03 — ValidationError shape src/errors/errors.test.ts (full suite — extends SuaveplanError, flatten / format, code registry)
VL-04 — String schema constraints src/schemas/string/string.test.ts (full suite — min / max / length / regex / email / url / uuid / startsWith / endsWith / includes + trim / toLowerCase / toUpperCase)
VL-05 — Number schema constraints src/schemas/number/number.test.ts (full suite — min / max / int / positive / negative / multipleOf / finite)
VL-06 — Object schema modes src/schemas/object/object.test.ts (full suite — strip / strict / passthrough + pick / omit / partial / required / extend / merge)
VL-07 — Union short-circuit src/schemas/union/union.test.ts (full suite — first-match short-circuit + aggregated arm errors)
VL-08 — Async refinements see modified entry above
VL-09 — Coerce utilities see modified entry above
VL-10 — Hooks see modified entry above
VL-11 — OTel instrumentation see modified entry above
VL-12 — sideEffects: false + coverage 100% tests/tree-shake.test.ts::"sideEffects is false" AND coverage gate bunx turbo test:coverage --filter=@suaveplan/validation
VL-13 — Async-refinement loud-fail in synchronous parse see ADDED entry above

Newly added implementation in this change

  • BaseSchema.withHooks(hooks) builder method.
  • Hook invocation at every terminal branch of parse / parseAsync.
  • recordParseSuccess / recordParseFailure + duration histogram emission at every terminal branch of parse / parseAsync.
  • safeParse re-implemented as a try { parse } catch shim so the safe path inherits OTel + hook emission.
  • _assertNoAsyncRefinementsForSyncParse — loud-fail guard for VL-13.
  • coerceString returns null for symbol inputs.
  • New error code VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC.

Newly added tests in this change

  • src/core/schema/schema.test.ts::"sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present"
  • src/core/schema/schema.test.ts::"safeParse returns ASYNC_REFINEMENT_REQUIRES_PARSEASYNC failure when async refinement is present"
  • src/utils/coerce.test.ts::"returns null for symbol input (non-coercible per VL-09)"
  • (All seventeen scenarios in src/core/schema/schema.spec-conformance.test.ts were authored by the prior validation-spec-conformance change but only began passing once this change wired the underlying hooks + OTel implementation.)