Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
# OpenSpec Change: validation-critical-bugfix
- **Change ID**: validation-critical-bugfix
- **Package**: `@suaveplan/validation`
- **Status**: PROPOSED
- **Date**: 2026-04-27
## Summary
Fix three correctness gaps in `@suaveplan/validation` and bring the spec into
conformance:
1. **Critical security footgun** — `parse()` / `safeParse()` previously
silently skipped async refinements. A schema such as
`string().refine(async (token) => await checkRevoked(token))` consumed via
`.parse()` would return `valid` for revoked tokens. Synchronous parse paths
now refuse to run when any refinement is async, surfacing
`VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` instead.
2. **Ghost API (VL-10)** — `BaseSchema` now implements `.withHooks(hooks)`
with `beforeValidate` / `afterValidate` / `onError` invocation at every
terminal branch of `parse` and `parseAsync`. Hooks are awaited on async
paths and their return value is discarded on sync paths.
3. **Ghost OTel emission (VL-11)** — `recordParseSuccess` /
`recordParseFailure` and the duration histogram are now emitted from
`BaseSchema.parse` / `BaseSchema.parseAsync` on every terminal branch,
tagged with `schema.type = this.constructor.name`.
The change also closes one VL-09 gap (`coerceString` now returns `null` for
non-coercible `symbol` inputs so the wrapper can produce a `ParseFailure`
rather than yielding the misleading `"Symbol(x)"` token), folds the existing
`validation-spec-conformance` work into a single shipping change, and adds
`**Verified by:**` lines to every requirement in
`openspec/specs/validation/spec.md`.
## Current state
`@suaveplan/validation` ships 12 schema types, an immutable builder pattern,
and 100% line coverage at baseline. Three documented MUST-clauses had drifted
from the implementation:
- **Async refinement footgun.** `BaseSchema.parse` called
`_applyRefinementsSync` which contained `if (r.async) continue`. The async
predicate's promise was discarded and the value was reported `valid`
regardless of the predicate's eventual result. The original suite even
codified this as expected behaviour in `schema.test.ts::"sync parse skips
async refinements without error"` — flipping that test is part of this
change.
- **VL-10 hooks unwired.** `ValidationHookMap` and `ValidationHookData` were
exported as types, but no `BaseSchema.withHooks` method existed and no
parse path invoked any hook.
- **VL-11 OTel unwired.** `recordParseSuccess` / `recordParseFailure` lived
in `src/otel/validation-otel.ts` but no `src/` call site ever invoked
them; only the unit test for the otel module exercised the recorders.
- **VL-09 coerceString.** Returned a string for every input including
`symbol` (where `String()` throws), forcing the wrapper into a
`_inner._parse` round-trip that could never succeed and yielding misleading
`"Symbol(x)"` text. The spec mandates failed coercion produce
`ParseFailure`.
- **Spec annotations.** All 12 requirements lacked `**Verified by:**` lines
required by `bun scripts/openspec-conformance.ts`.
## What changes
1. **schema.ts.**
- Add `_assertNoAsyncRefinementsForSyncParse` that throws a
`ValidationError` with the new code
`VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` when invoked from
`parse()`. `parseAsync()` is unchanged.
- Add `withHooks(hooks)` builder that returns an immutable clone with
hooks merged onto pre-existing hooks (per-key replace semantics).
- Invoke hooks in `parse` / `parseAsync` at `beforeValidate` /
`afterValidate` / `onError` boundaries.
- Wire `recordParseSuccess` / `recordParseFailure` (with duration via
`performance.now()`) at every terminal branch of `parse` / `parseAsync`.
- Re-implement `safeParse` as a `try { return parse(input) } catch` shim
so OTel + hooks fire on the safe path too.
- Add a `_phantomInput: TInput` declared field so `InferInput<S>` resolves
to the schema's actual input type instead of degrading to `unknown`.
2. **errors.ts / common.ts.** Register the new
`VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` code in both the runtime
`defineCodes` registry and the `ValidationCode` string-union type.
3. **utils/coerce.ts.** `coerceString` now returns `string | null`; returns
`null` for `symbol` inputs.
4. **Tests.** Add scenarios in `schema.test.ts` (sync-throws,
safeParse-fails-cleanly) and one in `coerce.test.ts` (symbol → null).
Existing async-refinement-skips test is replaced.
5. **Spec.** Add `**Verified by:**` lines to every VL-NN requirement and add
one new requirement (VL-13) for the async-refinement loud-fail behaviour.
## Why now
The three drift items above are the highest-severity gaps in the package: the
async-refinement footgun is a security-grade silent-failure bug in the core
parse path, and the unwired VL-10 / VL-11 mean any consumer reading the spec
gets a fictional picture of the API. Bundling them into a single shipping
change is cheaper than three sequential rounds of the 5-gate.
## Impact
- Public API gains `BaseSchema.withHooks(hooks)` (additive) and the
`VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` error code (additive).
`coerceString` return type narrows from `string` to `string | null`
(callers must now handle `null`; the only in-repo caller is
`coerce()`'s wrapper, which already guards on the symbol branch).
- `parse()` / `safeParse()` start surfacing failures for schemas that
previously silently accepted invalid values via async refinements. This is
the intended correctness fix; downstream code that relied on the bug must
switch to `parseAsync` / `safeParseAsync`.
- One new spec requirement (VL-13). All eleven existing requirements
unchanged in wording; only `**Verified by:**` lines are added.
- Coverage stays at 100 / 100 / 100 / 100.
## Deferred
The following Zod-API surface items are out of scope and tracked here so
follow-up work can pick them up:
- `discriminatedUnion(key, schemas)` — typed-tagged-union short-circuit.
- `lazy(() => schema)` / recursive schemas via thunks.
- `brand<TBrand>()` — nominal types.
- `default(value)` — fall back to a value when input is `undefined`.
- `catch(value)` — fall back to a value on parse failure.
- `superRefine((value, ctx) => ...)` — multi-issue refinement with explicit
`ctx.addIssue`.
These are additive features; the current schema engine is internally
expressive enough to host them once a follow-up change formalises them.

View File

@@ -0,0 +1,129 @@
# 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.)

View File

@@ -0,0 +1,60 @@
# Tasks: validation-critical-bugfix
## 1. Critical bug fixes
- [x] 1.1 `BaseSchema.parse` and `BaseSchema.safeParse` MUST loud-fail with
`VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` when any refinement
is async (no more silent skip).
- [x] 1.2 Register the new code in `VALIDATION_CODES` and the
`ValidationCode` string-union type.
- [x] 1.3 `coerceString` returns `null` for non-coercible `symbol` inputs.
## 2. Ghost-API wiring
- [x] 2.1 Add `BaseSchema.withHooks(hooks)` returning an immutable clone with
merged hooks. Carry `_hooks` through `_clone()`.
- [x] 2.2 Invoke `beforeValidate` / `afterValidate` / `onError` from `parse`
(sync, return value discarded) and `parseAsync` (awaited) at every
terminal branch. `safeParse` re-implemented as a try-catch shim so
hooks + OTel fire on the safe path too.
- [x] 2.3 Wire `recordParseSuccess` / `recordParseFailure` and duration
histogram via `performance.now()` at every terminal branch of `parse`
and `parseAsync`, tagged with `schema.type = this.constructor.name`.
## 3. Type surface
- [x] 3.1 Add `declare protected readonly _phantomInput: TInput` on
`BaseSchema` so `InferInput<S>` resolves to the schema's input type
instead of degrading to `unknown`.
## 4. Tests
- [x] 4.1 Replace `schema.test.ts::"sync parse skips async refinements
without error"` with two scenarios: sync `parse` throws
`ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` and `safeParse` returns a
failure with the same code.
- [x] 4.2 Add `coerce.test.ts::"returns null for symbol input"`.
- [x] 4.3 Existing `schema.spec-conformance.test.ts` (VL-01, VL-02, VL-10,
VL-11) now passes against the wired implementation.
## 5. Spec annotations
- [x] 5.1 Add `**Verified by:**` line under every VL-NN requirement in
`openspec/specs/validation/spec.md`.
- [x] 5.2 Add new requirement VL-13 (async-refinement loud-fail).
## 6. Verify
- [x] 6.1 `bunx turbo lint typecheck test --filter=@suaveplan/validation` green.
- [x] 6.2 `bunx turbo test:coverage --filter=@suaveplan/validation`
100/100/100/100.
- [x] 6.3 `bun scripts/openspec-conformance.ts @suaveplan/validation` exits 0.
## 7. Fold + archive
- [x] 7.1 Fold delta into `openspec/specs/validation/spec.md`.
- [x] 7.2 Move `openspec/changes/validation-critical-bugfix/` to
`openspec/archive/2026-04-27-validation-critical-bugfix/`.
- [x] 7.3 Move the now-superseded `openspec/changes/validation-spec-conformance/`
to `openspec/archive/2026-04-27-validation-spec-conformance/` (its
scope is fully subsumed by this change).