From 2af6d0286a6bb70a4d48dda4418ae0c846d1e613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 08:06:29 +0300 Subject: [PATCH 1/6] docs: track a baseline commit for the API reference, document the practice Adds a docs-baseline comment block to docs/api.md recording the git commit/package version the docs were last verified against, and a CLAUDE.md rule for keeping it current: diff src/ from that commit before trusting/updating the docs, update only what actually changed, then bump the baseline to the new HEAD. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 16 ++++++++++++++++ docs/api.md | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 417efeb..a378651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,3 +7,19 @@ Rules: - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). + +## API docs baseline (docs/api.md, docs/api/*.md) + +`docs/api.md` starts with an HTML comment block (`docs-baseline`) recording the git commit, +package version, and date the API docs were last verified against source - see that block for +the exact format and the `git diff ..HEAD -- src/` command it documents. + +Rules: +- Whenever you write or update these API docs, record (or update) that baseline block with the + commit you verified against - so a later session can diff from a known point instead of + re-reading everything from scratch. +- Before trusting/updating the docs, diff `src/` (and `test/**/*.spec.ts` for examples) between + the recorded commit and `HEAD` to see what actually changed, then update only the affected + doc section(s) - don't regenerate everything unless the diff is broad enough to warrant it. +- After updating, bump `git-commit`/`package-version`/`date` in the baseline block to the new + `HEAD` (only once the docs are verified accurate as of that commit). diff --git a/docs/api.md b/docs/api.md index fcce006..3eed8f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,5 +1,20 @@ # API Reference + + valgen validators all share the same small set of conventions. This page covers those shared conventions once; the per-category pages document every individual validator function. From e5870e12c9aa832a186fe9da133f702e0a2ed9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Thu, 10 Sep 2026 10:36:14 +0300 Subject: [PATCH 2/6] fix: oneOf was crushing the real error when every candidate failed Two related bugs in the same code path: 1. oneOf mocks context.fail while trying each candidate (so a candidate failing is normal control flow, not an accumulated error) but the mock discarded the failure reason entirely. When every candidate failed - including when one threw a genuinely unexpected exception (a bug, not a normal validation failure) - the only thing that surfaced was oneOf's own generic "Value didn't match one of required rules", with zero trace of what actually went wrong. The mock now remembers the last candidate's failure message and appends it as "(last error: )", plus a structured `lastError` field on the issue - without changing the "try the next candidate" control flow the existing tests rely on. 2. Related and more serious: when a candidate threw directly (bypassing context.fail, e.g. a plain function rather than one built with validator()), `passed` was never reset to false in the catch block. If that was the last candidate tried, oneOf would exit its loop with `passed` still true from loop-top initialization and silently return an unvalidated value instead of failing - a false positive, not just a lost message. Both catch blocks now correctly set `passed = false`. Co-Authored-By: Claude Sonnet 5 --- docs/api/utility-rules.md | 6 ++--- src/rules/utility-rules/one-of.ts | 42 ++++++++++++++++++++++++++----- test/utility-rules/one-of.spec.ts | 12 +++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/api/utility-rules.md b/docs/api/utility-rules.md index 9f71c11..caf6ef4 100644 --- a/docs/api/utility-rules.md +++ b/docs/api/utility-rules.md @@ -181,7 +181,7 @@ oneOf(rules: (Validator | [Validator, Record])[], options?: o - For a plain entry, `oneOf` calls it directly; if it throws or fails, it moves on to the next entry (short-circuits on the **first success**, not the first failure). - For a `[validator, discriminator]` tuple, `input` must be an object. `oneOf` first runs each rule in `discriminator` against the matching property of `input` (e.g. `discriminator.kind(input.kind)`); only if **every** discriminator key passes does it go on to run the tuple's main `validator` against the whole `input`. If any discriminator key fails (or `input` isn't an object), that entry is skipped entirely — the main validator never runs — and `oneOf` moves to the next candidate. This lets you dispatch between differently-shaped objects using a cheap "tag" check (e.g. a `kind` field) instead of trying and catching a full shape validation for each candidate. - An unexpected exception thrown by a discriminator or a rule (as opposed to a normal validation failure) is caught and treated the same as a failure — `oneOf` just moves on to the next candidate. -- If no entry passes, it fails with `Value didn't match one of required rules`. +- If no entry passes, it fails with `Value didn't match one of required rules` - and, if any candidate recorded a reason (a normal validation message, or an unexpected exception's message), that's appended as `(last error: )` and also placed on the issue's `lastError` field, so the underlying cause isn't silently lost when every candidate fails. **Example** ```ts @@ -191,7 +191,7 @@ import { isNull, isNumber, isObject, isString, vg } from 'valgen'; const simple = vg.oneOf([isNull, isNumber]); simple(6); // => 6 simple(null); // => null -simple('x'); // throws: "Value didn't match one of required rules" +simple('x'); // throws: "Value didn't match one of required rules (last error: Value must be a number)" // discriminated form: pick the object shape based on `kind` const pet = vg.oneOf([ @@ -204,7 +204,7 @@ pet({ kind: 'cat', name: 'Molly' }); // => { kind: 'cat', name: 'Molly' } pet({ kind: 'dog', name: 'Daisy' }); // => { kind: 'dog', name: 'Daisy' } pet('Daisy'); // => 'Daisy' pet(5); // => 5 -pet({ kind: 'bird', name: 'Bluey' }); // throws: "Value didn't match one of required rules" +pet({ kind: 'bird', name: 'Bluey' }); // throws: "Value didn't match one of required rules (last error: Value must be equal to \"5\")" ``` ## optional diff --git a/src/rules/utility-rules/one-of.ts b/src/rules/utility-rules/one-of.ts index 16678da..9ebb2d6 100644 --- a/src/rules/utility-rules/one-of.ts +++ b/src/rules/utility-rules/one-of.ts @@ -26,8 +26,20 @@ export function oneOf( let discriminator: DiscriminatorRecord | undefined; let v: any; let passed = false; - // Mock fail method to prevent errors - context.fail = () => (passed = false); + // Every candidate failing is normal control flow here (that's how + // "try the next one" works), so a candidate's own context.fail must + // not throw or accumulate into the real error list. But swallowing it + // completely would hide the *reason* every candidate failed - including + // a genuine bug in a candidate rule, which would otherwise look + // identical to "the input just didn't match". So the mock still + // records the last failure's message; if nothing ends up passing, it's + // surfaced alongside the generic message instead of being discarded. + let lastFailMessage: string | undefined; + context.fail = (_rule: Validator, message: string | Error) => { + passed = false; + lastFailMessage = + message instanceof Error ? message.message : String(message); + }; for (i = 0; i < l; i++) { passed = true; if (Array.isArray(rules[i])) { @@ -51,7 +63,16 @@ export function oneOf( if (!passed) break; } if (!passed) continue; - } catch { + } catch (e: any) { + // A discriminator/rule that throws directly (bypassing + // context.fail entirely, e.g. a plain function rather than one + // built with validator()) must still count as "this candidate + // failed" - otherwise `passed` is left at its top-of-loop `true` + // and, if this is the last candidate, oneOf would silently + // return an unvalidated value instead of failing. + passed = false; + lastFailMessage = + e?.message != null ? String(e.message) : String(e); continue; } } else c = rules[i] as Validator; @@ -59,14 +80,23 @@ export function oneOf( try { v = c(input, undefined, context); if (passed) break; - } catch { - // + } catch (e: any) { + passed = false; + lastFailMessage = + e?.message != null ? String(e.message) : String(e); } } // Restore fail method delete (context as any).fail; if (passed) return v; - context.fail(_this, `Value didn't match one of required rules`, input); + context.fail( + _this, + lastFailMessage + ? `Value didn't match one of required rules (last error: ${lastFailMessage})` + : `Value didn't match one of required rules`, + input, + { lastError: lastFailMessage }, + ); }, options, ); diff --git a/test/utility-rules/one-of.spec.ts b/test/utility-rules/one-of.spec.ts index f6dc5ef..4f6a547 100644 --- a/test/utility-rules/one-of.spec.ts +++ b/test/utility-rules/one-of.spec.ts @@ -69,4 +69,16 @@ describe('oneOf', () => { "Value didn't match one of required rules", ); }); + + it('should surface the last candidate error instead of only the generic message', () => { + const buggy = (() => { + throw new TypeError('unexpected bug'); + }) as any; + const c = vg.oneOf([buggy]); + expect(() => c('anything')).toThrow( + "Value didn't match one of required rules (last error: unexpected bug)", + ); + const result = c.silent('anything'); + expect(result.errors?.[0].lastError).toBe('unexpected bug'); + }); }); From 398e85ab62ea789b152715b3d8cdf37779b93caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Thu, 10 Sep 2026 10:36:31 +0300 Subject: [PATCH 3/6] docs: bump API docs baseline to the oneOf fix commit Co-Authored-By: Claude Sonnet 5 --- docs/api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index 3eed8f5..a815630 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,11 +6,11 @@ docs-baseline: signature/option/example checked against source and test/**/*.spec.ts, or run directly against the library) as of the commit and package version below. - git-commit: 74dd52916e7e8dc30bfa950b4bd34e5d43ee009e - package-version: 6.2.2 - date: 2026-09-08 + git-commit: e5870e12c9aa832a186fe9da133f702e0a2ed9c8 + package-version: 7.0.0 + date: 2026-09-10 - To refresh after later changes: `git diff 74dd52916e7e8dc30bfa950b4bd34e5d43ee009e..HEAD -- src/` + To refresh after later changes: `git diff e5870e12c9aa832a186fe9da133f702e0a2ed9c8..HEAD -- src/` (or a narrower path for one category) to see what moved since this baseline, update the affected doc section(s), then bump git-commit/package-version/date above to the new HEAD. --> From f6c54906b3fc94b37401ac7b94788ff58ccea51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Thu, 10 Sep 2026 11:01:21 +0300 Subject: [PATCH 4/6] fix: oneOf reports the actual candidate error directly, not a generic one Following up on the previous fix: appending "(last error: ...)" to oneOf's own generic message was still burying the actual, actionable error behind boilerplate text. oneOf can only report one message anyway, so when every candidate fails, it now reports that last candidate's own message directly - the generic "Value didn't match one of required rules" is only used when there's truly nothing to report (e.g. an empty rules array). Tests and docs updated to match. Co-Authored-By: Claude Sonnet 5 --- docs/api/utility-rules.md | 6 +++--- src/rules/utility-rules/one-of.ts | 10 ++++------ test/utility-rules/one-of.spec.ts | 31 +++++++++++++++---------------- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/docs/api/utility-rules.md b/docs/api/utility-rules.md index caf6ef4..3f2fbcd 100644 --- a/docs/api/utility-rules.md +++ b/docs/api/utility-rules.md @@ -181,7 +181,7 @@ oneOf(rules: (Validator | [Validator, Record])[], options?: o - For a plain entry, `oneOf` calls it directly; if it throws or fails, it moves on to the next entry (short-circuits on the **first success**, not the first failure). - For a `[validator, discriminator]` tuple, `input` must be an object. `oneOf` first runs each rule in `discriminator` against the matching property of `input` (e.g. `discriminator.kind(input.kind)`); only if **every** discriminator key passes does it go on to run the tuple's main `validator` against the whole `input`. If any discriminator key fails (or `input` isn't an object), that entry is skipped entirely — the main validator never runs — and `oneOf` moves to the next candidate. This lets you dispatch between differently-shaped objects using a cheap "tag" check (e.g. a `kind` field) instead of trying and catching a full shape validation for each candidate. - An unexpected exception thrown by a discriminator or a rule (as opposed to a normal validation failure) is caught and treated the same as a failure — `oneOf` just moves on to the next candidate. -- If no entry passes, it fails with `Value didn't match one of required rules` - and, if any candidate recorded a reason (a normal validation message, or an unexpected exception's message), that's appended as `(last error: )` and also placed on the issue's `lastError` field, so the underlying cause isn't silently lost when every candidate fails. +- If no entry passes, `oneOf` reports the *last* candidate's own failure message directly (a normal validation message, or an unexpected exception's message) rather than a generic one - since it can only report a single message anyway, the actual reason is more useful than a vague "didn't match". The generic `Value didn't match one of required rules` message is only used as a fallback when nothing was actually tried (e.g. an empty `rules` array). **Example** ```ts @@ -191,7 +191,7 @@ import { isNull, isNumber, isObject, isString, vg } from 'valgen'; const simple = vg.oneOf([isNull, isNumber]); simple(6); // => 6 simple(null); // => null -simple('x'); // throws: "Value didn't match one of required rules (last error: Value must be a number)" +simple('x'); // throws: "Value must be a number" (the last candidate's own error, reported directly) // discriminated form: pick the object shape based on `kind` const pet = vg.oneOf([ @@ -204,7 +204,7 @@ pet({ kind: 'cat', name: 'Molly' }); // => { kind: 'cat', name: 'Molly' } pet({ kind: 'dog', name: 'Daisy' }); // => { kind: 'dog', name: 'Daisy' } pet('Daisy'); // => 'Daisy' pet(5); // => 5 -pet({ kind: 'bird', name: 'Bluey' }); // throws: "Value didn't match one of required rules (last error: Value must be equal to \"5\")" +pet({ kind: 'bird', name: 'Bluey' }); // throws: "Value must be equal to \"5\"" (the last candidate tried) ``` ## optional diff --git a/src/rules/utility-rules/one-of.ts b/src/rules/utility-rules/one-of.ts index 9ebb2d6..ba70bbc 100644 --- a/src/rules/utility-rules/one-of.ts +++ b/src/rules/utility-rules/one-of.ts @@ -32,8 +32,9 @@ export function oneOf( // completely would hide the *reason* every candidate failed - including // a genuine bug in a candidate rule, which would otherwise look // identical to "the input just didn't match". So the mock still - // records the last failure's message; if nothing ends up passing, it's - // surfaced alongside the generic message instead of being discarded. + // records the last failure's message, and it's reported directly as + // the final error (oneOf can only report one message anyway) instead + // of being discarded in favor of a generic one. let lastFailMessage: string | undefined; context.fail = (_rule: Validator, message: string | Error) => { passed = false; @@ -91,11 +92,8 @@ export function oneOf( if (passed) return v; context.fail( _this, - lastFailMessage - ? `Value didn't match one of required rules (last error: ${lastFailMessage})` - : `Value didn't match one of required rules`, + lastFailMessage || `Value didn't match one of required rules`, input, - { lastError: lastFailMessage }, ); }, options, diff --git a/test/utility-rules/one-of.spec.ts b/test/utility-rules/one-of.spec.ts index 4f6a547..312e4bf 100644 --- a/test/utility-rules/one-of.spec.ts +++ b/test/utility-rules/one-of.spec.ts @@ -6,9 +6,8 @@ describe('oneOf', () => { const c = vg.oneOf([isNull, isNumber]); expect(c(6)).toStrictEqual(6); expect(c(null)).toStrictEqual(null); - expect(() => c('x' as any)).toThrow( - "Value didn't match one of required rules", - ); + // Reports the last candidate's own error directly, not a generic message. + expect(() => c('x' as any)).toThrow('Value must be a number'); }); it('should return one of valid object using discriminator', () => { @@ -39,13 +38,13 @@ describe('oneOf', () => { expect(c('Daisy')).toStrictEqual('Daisy'); expect(c(5)).toStrictEqual(5); expect(c(null)).toStrictEqual(null); - expect(() => c(1)).toThrow("Value didn't match one of required rules"); + expect(() => c(1)).toThrow('Value must be equal to "5"'); expect(() => c({ kind: 'bird', name: 'Bluey', }), - ).toThrow("Value didn't match one of required rules"); + ).toThrow('Value must be equal to "5"'); }); it('should move on to the next rule if a discriminator throws unexpectedly', () => { @@ -54,9 +53,8 @@ describe('oneOf', () => { }) as any; const c = vg.oneOf([[isObject, { kind: throwing }], isNumber]); expect(c(5)).toStrictEqual(5); - expect(() => c({ kind: 'x' } as any)).toThrow( - "Value didn't match one of required rules", - ); + // isNumber (the next candidate) is what ultimately fails and reports. + expect(() => c({ kind: 'x' } as any)).toThrow('Value must be a number'); }); it('should move on to the next rule if a rule throws unexpectedly', () => { @@ -65,20 +63,21 @@ describe('oneOf', () => { }) as any; const c = vg.oneOf([throwing, isNumber]); expect(c(5)).toStrictEqual(5); - expect(() => c('x' as any)).toThrow( - "Value didn't match one of required rules", - ); + expect(() => c('x' as any)).toThrow('Value must be a number'); }); - it('should surface the last candidate error instead of only the generic message', () => { + it('should report the actual candidate error directly instead of a generic message', () => { const buggy = (() => { throw new TypeError('unexpected bug'); }) as any; const c = vg.oneOf([buggy]); - expect(() => c('anything')).toThrow( - "Value didn't match one of required rules (last error: unexpected bug)", + expect(() => c('anything')).toThrow('unexpected bug'); + }); + + it('should still fall back to a generic message when there is nothing to report', () => { + const c = vg.oneOf([]); + expect(() => c('anything' as any)).toThrow( + "Value didn't match one of required rules", ); - const result = c.silent('anything'); - expect(result.errors?.[0].lastError).toBe('unexpected bug'); }); }); From d504378d8e66e0fd0621941eeb152a77f8ed6187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Thu, 10 Sep 2026 11:01:34 +0300 Subject: [PATCH 5/6] docs: bump API docs baseline to the oneOf direct-error-message fix Co-Authored-By: Claude Sonnet 5 --- docs/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index a815630..db5a560 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,11 +6,11 @@ docs-baseline: signature/option/example checked against source and test/**/*.spec.ts, or run directly against the library) as of the commit and package version below. - git-commit: e5870e12c9aa832a186fe9da133f702e0a2ed9c8 + git-commit: f6c54906b3fc94b37401ac7b94788ff58ccea51b package-version: 7.0.0 date: 2026-09-10 - To refresh after later changes: `git diff e5870e12c9aa832a186fe9da133f702e0a2ed9c8..HEAD -- src/` + To refresh after later changes: `git diff f6c54906b3fc94b37401ac7b94788ff58ccea51b..HEAD -- src/` (or a narrower path for one category) to see what moved since this baseline, update the affected doc section(s), then bump git-commit/package-version/date above to the new HEAD. --> From ea8ed0eb5395276cf38d7e6cf5efba9562744857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Thu, 10 Sep 2026 11:16:10 +0300 Subject: [PATCH 6/6] 7.0.1 --- CHANGELOG.md | 15 ++++++++++++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55167c5..d31bc8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ ## Changelog -### [v7.0.0](https://github.com/panates/valgen/compare/v6.2.2...v7.0.0) - +### [v7.0.1](https://github.com/panates/valgen/compare/v7.0.0...v7.0.1) - + +#### 🪲 Fixes + +- fix: oneOf was crushing the real error when every candidate failed @Eray Hanoğlu +- fix: oneOf reports the actual candidate error directly, not a generic one @Eray Hanoğlu + +#### 📖 Documentation Changes + +- docs: track a baseline commit for the API reference, document the practice @Eray Hanoğlu +- docs: bump API docs baseline to the oneOf fix commit @Eray Hanoğlu +- docs: bump API docs baseline to the oneOf direct-error-message fix @Eray Hanoğlu + +## [v7.0.0](https://github.com/panates/valgen/compare/v6.2.2...v7.0.0) - 8 September 2026 #### 🚀 New Features diff --git a/package-lock.json b/package-lock.json index 82b90b9..dba6665 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "valgen", - "version": "7.0.0", + "version": "7.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "valgen", - "version": "7.0.0", + "version": "7.0.1", "license": "MIT", "dependencies": { "@browsery/validator": "^13.15.35", diff --git a/package.json b/package.json index a2e61d0..2cd39a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "valgen", "description": "Fast runtime type validator, converter and io (encoding/decoding) library", - "version": "7.0.0", + "version": "7.0.1", "author": "Panates", "license": "MIT", "private": true,