diff --git a/.changeset/audit-bugfixes.md b/.changeset/audit-bugfixes.md new file mode 100644 index 0000000..5836aa1 --- /dev/null +++ b/.changeset/audit-bugfixes.md @@ -0,0 +1,24 @@ +--- +"@zipbul/baker": minor +--- + +Fix four correctness bugs found in a package-wide audit. Two of them change observable behavior for +input that previously "worked", so review before upgrading: + +- **`@IsEnum` with numeric enums (behavior change).** TypeScript numeric enums compile to a reverse-mapped + object (`{ 0: 'Inactive', 1: 'Active', Active: 1, Inactive: 0 }`), so the previous `Object.values()` + lookup wrongly accepted the member-*name* strings (e.g. `'Active'`) as valid values. Values are now read + through the non-numeric keys, so only real members pass — correct for string, numeric, and heterogeneous + enums. Input that relied on the member-name strings being accepted will now be rejected. + +- **`momentTransformer` parses in UTC (behavior change).** It now uses `moment.utc(value)` so a zoneless + datetime string resolves to the same instant on every host; previously local-time parsing made the + serialized output depend on the machine timezone. Zoneless inputs that were parsed in local time will now + be parsed as UTC. Matches `luxonTransformer`'s UTC default. + +- **`luxonTransformer` invalid-date passthrough.** An unparseable date string / `Date` now passes through + untouched instead of being laundered into an Invalid `DateTime` (which serialized to `null` / + `"Invalid DateTime"` and corrupted data). Matches `momentTransformer`'s pass-through contract. + +- **Per-call `groups` option validation.** A non-`string[]` `groups` value now throws a clear `BakerError` + at the call boundary instead of silently misbehaving inside the generated executor. diff --git a/.changeset/declared-collection-validation-and-perf.md b/.changeset/declared-collection-validation-and-perf.md new file mode 100644 index 0000000..80a5622 --- /dev/null +++ b/.changeset/declared-collection-validation-and-perf.md @@ -0,0 +1,32 @@ +--- +'@zipbul/baker': minor +--- + +Fix declared-collection element validation (RED tests added first), speed up collection `validate`, and +land an internal layering cleanup. One item changes observable behavior — review before upgrading: + +- **Declared `@Type(() => Set)` / `@Type(() => Map)` now validate their elements (behavior change).** The + declared-collection codegen path hand-rolled its per-element loop separately from the canonical + (`type: null`) path and had three defects: a declared **Map** dropped every per-element `each` rule + entirely; declared Set/Map `each` rules ignored the runtime `groups` filter; and a function `message` on + an `each` rule received the whole collection as `value` instead of the failing element. All four sites + (Set/Map × deserialize/validate) now route through one shared emitter with the same rule-major ordering, + group filtering, per-element `value` binding, and `field[i]` paths as the canonical path. Input that was + silently accepted because a Map's element rules never ran will now be validated. + +- **Collection `validate` is ~4.7× faster on large arrays.** The inline-nested validate path eagerly + allocated a per-element error-path string (`field[i].`) on every element even for valid input; it is now + built only at the (cold) error-push sites. A 1000-element nested-DTO `validate` drops from ~10µs to + ~2.2µs (now on par with TypeBox and ahead of Ajv). `deserialize` and all error paths are byte-identical. + +- **`createRule` is now also exported from the `@zipbul/baker/rules` subpath** (it was already exported from + the package root). + +- **`luxonTransformer` / `momentTransformer` peer-dep error is now precise.** A genuinely-missing peer still + throws the "install it" `BakerError`; a peer that IS installed but throws during evaluation now surfaces + its real error instead of the misleading install hint. + +Internal-only (no API change): the seal stage's TypeDef normalization was extracted out of the `sealOne` +god-function, large static lookup tables and the `string-format` validators were split into cohesive +modules, and several stateless helpers were simplified. Public surface is unchanged except the `createRule` +subpath export above (verified by an export-diff). diff --git a/.changeset/discriminator-array-and-validator-fixes.md b/.changeset/discriminator-array-and-validator-fixes.md new file mode 100644 index 0000000..67547f4 --- /dev/null +++ b/.changeset/discriminator-array-and-validator-fixes.md @@ -0,0 +1,33 @@ +--- +"@zipbul/baker": minor +--- + +Fix five reproduced correctness bugs (each added as a RED test first) and unify the unknown-key failure +model. Several change observable behavior — review before upgrading: + +- **Discriminated arrays now work (was broken).** A field typed `type: () => [Base]` with a `discriminator` + previously read the discriminator off the *array itself* (`undefined`) and rejected every valid input with + `invalidDiscriminator`. `deserialize`/`validate` now dispatch the discriminator switch **per element**, + reporting nested errors at `field[i].path` and the invalid-discriminator error at the `field[i]` element + path. (serialize already handled arrays.) + +- **serialize throws on an unmatched discriminator subtype (behavior change).** When an instance matched no + `instanceof` branch, serialize silently emitted the raw, un-serialized object (leaking undeclared fields). + It now throws a `BakerError`, symmetric with deserialize rejecting an unknown discriminator value. + +- **`each` rule messages receive the failing element (behavior change).** A `message`/`context` function on + an `arrayOf(...)` rule was passed the whole collection as `value` while the path pointed at `field[i]`. + It now receives the failing element, consistent with the element-level path. + +- **`isDateString` / `isISO8601({ strict: true })` leap-year for years 0–99.** Calendar validity used + `new Date(year, …)`, which remaps a 0–99 year argument to 1900–1999 — so `0000-02-29` (a valid leap date + by the 400 rule) was wrongly rejected. Now computed with the proleptic Gregorian rule for all years (and + without allocating a `Date`). + +- **`isHash` / `isTaxId` reject an unknown algorithm/locale at construction (behavior change).** They + previously returned a rule that always failed at runtime; they now throw a `BakerError` when called with + an unsupported key, matching `isMobilePhone`/`isPostalCode`/`isIdentityCard`/`isPassportNumber`. + +- **`isURL` no longer shares its default-protocols array across rules.** With default protocols, every + `isURL()` rule exposed the same module-level `['http','https','ftp']` array on `rule.constraints`; + mutating one rule's constraints would have corrupted every other. Each rule now owns an independent copy. diff --git a/REFACTORING.md b/REFACTORING.md deleted file mode 100644 index de38b45..0000000 --- a/REFACTORING.md +++ /dev/null @@ -1,230 +0,0 @@ -# @zipbul/baker — Refactoring Plan - -Goal: a precise, SRP-compliant module and type structure. Behavior-preserving except for the -multi-app isolation feature in Phase 1 (shipped as the `Baker` class). The test suite (99%+ line -coverage) pins behavior; every phase must keep `tsc` clean and all tests green. Hot codegen -paths run once at `seal()` (never per call), so module splits are runtime-perf-neutral; the -only carve-outs needing a benchmark check are the generated `new Function` bodies, which must -stay byte-identical. - -Conventions enforced throughout: -- Per-directory type organization: `enums.ts` / `types.ts` / `constants.ts` / `interfaces.ts`. -- Directory-scoped barrels (`index.ts`); cross-directory imports go through the barrel. -- Strict, precise named exports — no `export *`, internal-only symbols stay out of barrels. -- `export type` / `import type` for type-only, plain `export` / `import` for runtime values - (enforced by `verbatimModuleSyntax`). -- Move functions verbatim; never "tidy" the documented micro-optimizations during extraction. - ---- - -## Phase 0 — Status (done) - -Literal/union → enum conversion is complete and committed on `refactor/literal-unions-to-enums`: -- `src/enums.ts` (string-valued, leaf): `RequiredType`, `Direction`, `CollectionType`, `CacheKey`, - `RuleOp`, `RulePlanExprKind`, `RulePlanCheckKind`, `ExcludeMode`. -- `src/seal/enums.ts`: `GuardKey`. -- Public exports: `RequiredType`, `ExcludeMode`. -- Test hardening: RulePlan emit assertions pin all six `RuleOp` operator strings. - ---- - -## Phase 1 — Multi-app isolation + multi-instance seal fix (DONE) - -### Problem -The argless `seal()` iterated a module-local `globalRegistry`. When a bundler duplicated baker -(app inlines it; a library ships it `--packages external`), each copy had its own registry, so the -app's `seal()` never sealed the library copy's `@Recipe` DTOs → `" is not sealed"`. The same -module-local design gave no way to isolate multiple apps in one process. - -### Solution — the `Baker` class, class-identity isolation -`new Baker(config?)`: an isolated scope owning its own registry + config. Each app/library seals its -OWN roots, so nothing depends on a shared module-global registry. The global registration API -(`@Recipe` / `seal()` / `configure()` / the never-shipped `createBaker()` factory) was removed -entirely — `Baker` is the only registration surface. - -- `@app.Recipe` collects a class into that baker; `app.seal()` seals the baker's roots (and their - nested DTOs) with the baker's config. `@Field`, rules, and `deserialize/serialize/validate` stay - global — they read the metadata/executor stored on the class via global `Symbol.for`. -- `Recipe` and `seal` are instance arrow-field properties (not prototype methods): `@app.Recipe` is - applied as a detached value (no `this` receiver), so it must be bound; arrow fields also keep - `const { Recipe, seal } = new Baker()` working. -- Isolation boundary = **class identity**. Distinct classes are fully isolated (each sealed with its - baker's config). A shared value-type DTO reached from multiple bakers' roots is **reused** (one - sealed form, first seal wins) — sharing is legitimate, not a dev mistake, so there is no - cross-baker error. `seal()` keeps baker's contract: throw `BakerError` on dev errors, return - `BakerIssueSet` on validation. -- The reported duplication bug dissolves: no shared mutable registry to fragment; the only durable - state is on the class via global `Symbol.for`, so duplicate copies converge. - -### Implementation -- `src/baker.ts` — `class Baker` with private `#registry`/`#options`/`#sealed` and arrow-field - `Recipe`/`seal`. -- `src/seal/seal.ts` — `sealRegistry(registry, options)` (transactional batch seal) + recursive - `sealOne`; the old `seal()`/`sealAllRegistered()`/`sealOneClass()`/`__testing__` and the - vestigial `track?` param were removed. -- `src/configure.ts` — reduced to `normalizeConfig()` + `BakerConfig` + `BAKER_CONFIG_KEYS` - (`configure()`/`getGlobalOptions()`/global option state removed). -- `index.ts` — exports `Baker` (no `createBaker`/`Recipe`/`seal`/`configure`). -- Deleted: `src/registry.ts`, `src/decorators/recipe.ts`, `src/seal/seal-state.ts`. -- `test/e2e/multi-app-isolation.test.ts` — scope seal, distinct-class isolation, config isolation, - shared-class reuse, shared-nested reuse, idempotency, invalid config. - -Rejected en route: a `globalThis`-shared registry (merges apps → violates no-mix); an -owner-tracking `BakerError` throw on shared classes (bricked the second baker on a shared nested -DTO, and miscategorised legitimate sharing as a dev error); and keeping the global API behind -`@internal`/test shims (still leaves a global in the library — a dodge, not removal). ---- - -## Phase 2 — CORE type/responsibility split (root `src/`) - -`src/types.ts` is a 5-domain junk drawer imported by 27 modules. Dissolve it; each type moves to -its owning directory. Root keeps only genuinely cross-cutting members. - -| Current location | Member(s) | New home | -| --- | --- | --- | -| `types.ts` | `RawClassMeta`, `RawPropertyMeta` | **stay** `src/types.ts` (shared metadata storage) | -| `types.ts` | `EmittableRule`, `InternalRule`, `RulePlan`, `RulePlanExpr`, `RulePlanCheck`, `EmitContext` | `src/rules/types.ts` | -| `types.ts` | `RuleDef`, `MessageArgs`, `ExposeDef`, `ExcludeDef`, `TypeDef`, `PropertyFlags`, `ClassCtor`, `TransformDef` | `src/decorators/types.ts` | -| `types.ts` | `Transformer`, `TransformParams`, `TransformFunction` | `src/transformers/types.ts` | -| `types.ts` | `SealedExecutors` | `src/seal/types.ts` | -| `interfaces.ts` | `RuntimeOptions` | `src/functions/interfaces.ts` | -| `interfaces.ts` | `SealOptions` | `src/seal/interfaces.ts` (root `interfaces.ts` deleted) | -| `errors.ts` | `BakerIssue`, `BakerIssueSet` / `BAKER_ERROR` / `BakerError` / guards | `src/errors/` → `types.ts` / `constants.ts` / `baker-error.ts` / `guards.ts` / `index.ts` | -| `configure.ts` | `BakerConfig` / `BAKER_CONFIG_KEYS` / `normalizeConfig()` | `src/config/` → `types.ts` / `constants.ts` / `configure.ts` / `index.ts` | -| `symbols.ts` | `RAW`, `SEALED` + `Symbol.metadata` polyfill | **keep as-is** (load-order side-effect; published `./symbols`) | -| root | `rule-plan.ts`, `rule-metadata.ts`, `create-rule.ts` | move into `src/rules/` (rules domain) | - -Keep at root: `symbols.ts`, `types.ts` (now just `Raw*Meta`), `meta-access.ts`, `collect.ts`, -`baker.ts`, `utils.ts`. - -Cycle notes (both are `import type`, erased → no runtime cycle; comment them): -- `EmitContext.addExecutor(executor: SealedExecutors)` makes `rules/types` reference - `seal/types`. Keep as a one-directional erased type edge. -- `config` references `SealOptions` from `seal/interfaces` (erased). `Baker` (root) imports - `normalizeConfig` from `config` and `sealRegistry` from `seal` as values — both one-directional. - ---- - -## Phase 3 — `rules/` decomposition - -### `string.ts` (2524 lines) → cohesive modules + re-export barrel -`makeStringRule` → `string-shared.ts`. Rules split by concern; each module imports only -`string-shared` + `../rule-plan`, no lateral edges. Regex/data consts and checksum helpers move -**with their single consuming rule** (preserves `ctx.addRegex/addRef` dedup identity). - -| Module | Rules | -| --- | --- | -| `string-basic.ts` | minLength, maxLength, length, contains, notContains, matches, isLowercase, isUppercase, isAscii, isAlpha, isAlphanumeric, isHttpToken, isBooleanString, isNumberString, isDecimal, isFullWidth, isHalfWidth, isVariableWidth, isMultibyte, isSurrogatePair | -| `string-encoding.ts` | isHexadecimal, isOctal, isBase32, isBase58, isBase64, isHexColor, isRgbColor, isHSL | -| `string-format.ts` | isEmail, isURL, isIP, isMACAddress, isJWT, isLatLong, isDataURI, isFQDN, isPort, isMimeType, isMagnetURI, isJSON, isEthereumAddress, isBtcAddress | -| `string-datetime.ts` | isISO8601, isDateString, isRFC3339, isMilitaryTime | -| `string-identifier.ts` | isUUID, isULID, isCUID2, isMongoId, isFirebasePushId, isSemVer, isHash, isISO31661Alpha2/3, isISO4217CurrencyCode, isLocale, isOrigin, isCorsOrigin, isLatitude, isLongitude, isPhoneNumber, isStrongPassword, isByteLength, isTaxId | -| `string-finance.ts` | isCreditCard, isIBAN, isISBN, isISIN, isISSN, isISRC, isEAN, isBIC, isCurrency | - -`string.ts` becomes a **pure re-export barrel** in the original export order, so `rules/index.ts` -(`from './string'`) and the deep imports of `minLength` (`typechecker.spec`, -`deserialize-builder.spec`) stay byte-stable. - -### Rule machinery -Split `rule-plan.ts` (two responsibilities glued): `rule-factory.ts` (`makeRule`, -`makePlannedRule`) + `rule-plan.ts` (plan AST builders + `emitRulePlan` codegen). Keep a -re-export shim in `rule-plan.ts` so sibling import lines are unchanged. `rule-metadata.ts` -(branding) and `create-rule.ts` (public API) are already cohesive — relocate into `rules/` -without internal change. - -`rules/index.ts` stays the single published `./rules` barrel (no per-category split). - ---- - -## Phase 4 — `seal/` decomposition - -`deserialize-builder.ts` (1979 lines) mixes ~9 responsibilities. Extract into single-purpose -modules; the driver keeps only function assembly. - -Shared (leaf): `seal/enums.ts` (GuardKey), `seal/interfaces.ts` (FieldCodeContext, GuardParams, -TypeGateConfig, CategorizedRules, ResolvedTypeGate, SealOptions), `seal/types.ts` -(SealedExecutors), `gen-names.ts` (shared GEN names), existing `codegen-utils.ts`. - -| Module | Owns | -| --- | --- | -| `error-codegen.ts` | nestedErrPush, nestedErrReturn | -| `conversion-codegen.ts` | generateConversionCode + PRIMITIVE_TYPE_HINTS / ASSERTER_TO_GATE / GATE_ONLY_ASSERTERS | -| `expose-resolver.ts` | extract/output key + expose groups + field-skip, **unified for both directions** (dedups deserialize vs serialize copies) | -| `guard-strategies.ts` | resolveGuardKey, GUARD_STRATEGIES | -| `rule-analysis.ts` | categorizeRules, resolveTypeGate | -| `issue-extras.ts` | buildIssueExtras, computeRuleExtras, computeFieldExtras, makeRuleEmitCtx | -| `emit-context.ts` | makeEmitCtx | -| `rule-emitter.ts` | buildRulesCode + emitRuleList/emitTyped/emitGeneral/emitEach/wrapGroupsGuard/sameGroups | -| `nested-codegen.ts` | generateCollectionCode, generateNestedCode (deserialize) | -| `nested-codegen-validate.ts` | generateCollectionCodeValidateOnly, generateNestedCodeValidateOnly, emitInlineNestedBlock | -| `field-codegen.ts` | generateFieldCode, generateValidationCode | -| `deserialize-builder.ts` | (slim) buildDeserializeCode, buildValidateCode | -| `transform-codegen.ts` | buildSerializeTransformExpr, buildPostNestedTransformCode | -| `serialize-field-codegen.ts` | generateSerializeFieldCode + extracted collection/nested/discriminator/output | -| `serialize-builder.ts` | (slim) buildSerializeCode | -| `typedef-normalizer.ts` | normalizeTypeDefs (from sealOne) | -| `async-analysis.ts` | analyzeAsync, nestedClassesOf | -| `merge-inheritance.ts` | mergeInheritance | -| `seal.ts` | (slim) seal orchestration: sealRegistry/sealOne/ensureSealed | - -**Cycle break:** `field-codegen ↔ nested-codegen-validate` is mutual recursion -(`emitInlineNestedBlock` calls `generateFieldCode`). Inject `generateFieldCode` via a new -`emitField` callback field on `FieldCodeContext` (dependency inversion) so -`nested-codegen-validate` depends only on `seal/interfaces.ts`. - -Do NOT split the already-cohesive `circular-analyzer.ts`, `expose-validator.ts`, -`validate-meta.ts`, `codegen-utils.ts`. - ---- - -## Phase 5 — barrels, exports, `.d.ts` - -- Add internal barrels where useful (`seal/`, `functions/`); keep public barrels - (`decorators/`, `rules/`, `transformers/`) and the single published `/index.ts`. -- Repoint `/index.ts` re-export paths only; keep exported **names and shapes identical** so the - published `.d.ts` is byte-stable. `package.json` `exports` map needs no change (subpaths still - resolve; `symbols.ts` stays put). -- Run `tsc`, full tests, `bun run deps:check` (dpdm, no new cycles), `knip` (no unused exports), - and a codegen benchmark spot-check after the seal split. - ---- - -## Execution order (each step = one commit, `tsc` + full suite green) - -1. ~~Phase 1 — multi-app isolation via the `Baker` class (global API removed)~~ (DONE). -2. Core leaf splits: `errors/`, `config/`, relocate `RuntimeOptions`/`SealOptions`, delete root `interfaces.ts`. -3. Carve domain types: `transformers/types.ts`, `decorators/types.ts`, `rules/types.ts`, `seal/types.ts`; reduce root `types.ts` to `Raw*Meta`; move `rule-plan`/`rule-metadata`/`create-rule` into `rules/`. -4. `string.ts` split (finance → datetime → encoding → format → identifier → basic), then `rule-plan` factory split. -5. `seal/` deserialize decomposition (pure leaves first: error-codegen, conversion-codegen, guard-strategies, interfaces; then rule-analysis, issue-extras, emit-context, rule-emitter; then nested modules; then field-codegen + cycle break; finally slim driver). -6. `seal/` serialize decomposition + `seal.ts` extraction (typedef-normalizer, async-analysis, merge-inheritance). -7. Barrels/exports cleanup + `.d.ts` diff + deps:check/knip + benchmark. - -Each phase is independently revertible; regressions isolate to one domain. - ---- - -## Invariants (must hold every commit) -- `bunx tsc --noEmit` clean; `bun test` fully green (currently 2326 pass). -- Generated `new Function` bodies byte-identical (codegen splits move code verbatim). -- Public surface (`/index.ts` names + 4 subpath barrels + `package.json` exports) unchanged, - except the additive enum exports already shipped and any intentional fix documented here. -- `verbatimModuleSyntax` respected (enums plain-imported, types `import type`). -- No new dependency cycles (`deps:check`), no unused exports (`knip`). - ---- - -## Forward-looking note — schema scope (OpenAPI 3.0) - -A future "derive an OpenAPI 3.0 schema" feature needs per-app isolation: in a multi-app project -each app wants only *its own* DTOs in its schema. The `Baker` instance shipped in Phase 1 is -already the right boundary for this — a baker owns exactly the roots registered via its `@app.Recipe`. - -Recommended design when the feature lands: -- **Primary — derive from a baker:** `app.toOpenAPI()` walks the type graph from the roots that - baker collected. Perfect per-app isolation falls out of the instance; no global enumeration, no - reliance on module-instance identity (the very thing the Phase-1 bug came from). -- **Or from explicit roots:** `toOpenAPI([UserDto, OrderDto])` for callers that want an ad-hoc set. - -Class identity stays the isolation boundary (consistent with sealing): a DTO shared across bakers -appears in each baker's schema as the same definition. Single-app projects already have exactly one -`Baker`, so `app.toOpenAPI()` covers them with no extra concept. diff --git a/bench/array.bench.ts b/bench/array.bench.ts index e9946be..d9f3d07 100644 --- a/bench/array.bench.ts +++ b/bench/array.bench.ts @@ -10,23 +10,25 @@ import { bench, group, run } from 'mitata'; import * as v from 'valibot'; import { z } from 'zod'; -import { Field, Recipe, deserialize, isBakerIssueSet, seal } from '../index'; +import { Baker, Field, isBakerIssueSet } from '../index'; import { isString, isNumber, min, arrayMinSize } from '../src/rules/index'; import { ARRAY_VALID } from './data'; +const baker = new Baker(); + // ── Baker ──────────────────────────────────────────────────────────────────── -@Recipe +@baker.Recipe class BakerItem { @Field(isString) name!: string; @Field(isNumber(), min(0)) value!: number; } -@Recipe +@baker.Recipe class BakerList { @Field(arrayMinSize(1), { type: () => [BakerItem] }) items!: BakerItem[]; } -seal(); -await deserialize(BakerList, ARRAY_VALID); +baker.seal(); +await baker.deserialize(BakerList, ARRAY_VALID); // ── Zod ────────────────────────────────────────────────────────────────────── @@ -103,7 +105,7 @@ let sinkNum = 0; group('array 1000 items — valid input', () => { bench('baker', () => { - const r = deserialize(BakerList, ARRAY_VALID); + const r = baker.deserialize(BakerList, ARRAY_VALID); sinkNum += isBakerIssueSet(r) ? r.errors.length : (r as { items: unknown[] }).items.length; }); bench('zod', () => { diff --git a/bench/cold.bench.ts b/bench/cold.bench.ts index ad4b6e8..7cff2f7 100644 --- a/bench/cold.bench.ts +++ b/bench/cold.bench.ts @@ -9,16 +9,18 @@ import { bench, group, run } from 'mitata'; import * as v from 'valibot'; import { z } from 'zod'; -import { Field, Recipe, deserialize, seal } from '../index'; +import { Baker, Field } from '../index'; import { isString, isEmail, isNumber, isBoolean, min, max, minLength } from '../src/rules/index'; import { unseal } from '../test/integration/helpers/unseal'; +const baker = new Baker(); + const input = { name: 'Alice', email: 'alice@example.com', age: 30, active: true, tag: 'ok' }; // ── Baker ──────────────────────────────────────────────────────────────────── // Baker's seal is one-time per class. To measure cold start, we use unseal helper. -@Recipe +@baker.Recipe class BakerCold { @Field(isString, minLength(2)) name!: string; @Field(isString, isEmail()) email!: string; @@ -27,8 +29,8 @@ class BakerCold { @Field(isString) tag!: string; } // warm once to verify correctness -seal(); -await deserialize(BakerCold, input); +baker.seal(); +await baker.deserialize(BakerCold, input); // ── TypeBox ────────────────────────────────────────────────────────────────── // ── AJV ────────────────────────────────────────────────────────────────────── @@ -51,8 +53,8 @@ await deserialize(BakerCold, input); group('cold start — schema define + compile + first validate', () => { bench('baker (unseal + re-seal + validate)', async () => { unseal(); - seal(); - await deserialize(BakerCold, input); + baker.seal(); + await baker.deserialize(BakerCold, input); }); bench('zod (define + parse)', () => { diff --git a/bench/error.bench.ts b/bench/error.bench.ts index 56cb4ac..db08c5a 100644 --- a/bench/error.bench.ts +++ b/bench/error.bench.ts @@ -10,15 +10,14 @@ import { bench, group, run } from 'mitata'; import * as v from 'valibot'; import { z } from 'zod'; -import { Field, Recipe, deserialize, configure, isBakerIssueSet, seal } from '../index'; +import { Baker, Field, isBakerIssueSet } from '../index'; import { isNumber, min } from '../src/rules/index'; import { ERROR_ALL_FAIL } from './data'; -// ── Baker ──────────────────────────────────────────────────────────────────── - -configure({ stopAtFirstError: false }); +const baker = new Baker({ stopAtFirstError: false }); -@Recipe +// ── Baker ──────────────────────────────────────────────────────────────────── +@baker.Recipe class BakerIssueSet { @Field(isNumber(), min(1)) f0!: number; @Field(isNumber(), min(1)) f1!: number; @@ -32,8 +31,8 @@ class BakerIssueSet { @Field(isNumber(), min(1)) f9!: number; } // warm seal -seal(); -await deserialize(BakerIssueSet, ERROR_ALL_FAIL); +baker.seal(); +await baker.deserialize(BakerIssueSet, ERROR_ALL_FAIL); // ── Zod ────────────────────────────────────────────────────────────────────── @@ -115,7 +114,7 @@ let sinkNum = 0; group('error collection — 10 fields all invalid', () => { bench('baker', () => { - const r = deserialize(BakerIssueSet, ERROR_ALL_FAIL); + const r = baker.deserialize(BakerIssueSet, ERROR_ALL_FAIL); sinkNum += isBakerIssueSet(r) ? (r as unknown as { errors: unknown[] }).errors.length : 1; }); bench('zod', () => { diff --git a/bench/nested.bench.ts b/bench/nested.bench.ts index 6ed57e2..6e8c32b 100644 --- a/bench/nested.bench.ts +++ b/bench/nested.bench.ts @@ -10,32 +10,34 @@ import { bench, group, run } from 'mitata'; import * as v from 'valibot'; import { z } from 'zod'; -import { Field, Recipe, deserialize, isBakerIssueSet, seal } from '../index'; +import { Baker, Field, isBakerIssueSet } from '../index'; import { isString, isNumber, min, minLength } from '../src/rules/index'; import { NESTED_VALID, NESTED_INVALID } from './data'; +const baker = new Baker(); + // ── Baker ──────────────────────────────────────────────────────────────────── -@Recipe +@baker.Recipe class BakerAddress { @Field(isString, minLength(1)) street!: string; @Field(isString, minLength(1)) city!: string; @Field(isString, minLength(1)) zip!: string; } -@Recipe +@baker.Recipe class BakerCustomer { @Field(isString, minLength(1)) name!: string; @Field(isString) email!: string; @Field({ type: () => BakerAddress }) address!: BakerAddress; } -@Recipe +@baker.Recipe class BakerOrder { @Field(isString, minLength(1)) title!: string; @Field({ type: () => BakerCustomer }) customer!: BakerCustomer; @Field(isNumber(), min(0)) priority!: number; } -seal(); -await deserialize(BakerOrder, NESTED_VALID); +baker.seal(); +await baker.deserialize(BakerOrder, NESTED_VALID); // ── Zod ────────────────────────────────────────────────────────────────────── @@ -143,7 +145,7 @@ let sinkNum = 0; group('nested 3-level — valid input', () => { bench('baker', () => { - const r = deserialize(BakerOrder, NESTED_VALID); + const r = baker.deserialize(BakerOrder, NESTED_VALID); sinkNum += isBakerIssueSet(r) ? r.errors.length : 1; }); bench('zod', () => { @@ -176,7 +178,7 @@ group('nested 3-level — valid input', () => { group('nested 3-level — invalid input', () => { bench('baker', () => { - const r = deserialize(BakerOrder, NESTED_INVALID); + const r = baker.deserialize(BakerOrder, NESTED_INVALID); sinkNum += isBakerIssueSet(r) ? r.errors.length : 1; }); bench('zod', () => { diff --git a/bench/proof-inline-emit.bench.ts b/bench/proof-inline-emit.bench.ts index 5f8f9b9..9023402 100644 --- a/bench/proof-inline-emit.bench.ts +++ b/bench/proof-inline-emit.bench.ts @@ -1,6 +1,6 @@ import { bench, group, run } from 'mitata'; -import { Field, Recipe, deserialize, seal } from '../index'; +import { Baker, Field } from '../index'; import { isNumberString, isISBN, @@ -18,126 +18,128 @@ import { } from '../src/rules/index'; import { isNotEmptyObject } from '../src/rules/object'; +const baker = new Baker(); + // ── DTOs ──────────────────────────────────────────────────────────────────── -@Recipe +@baker.Recipe class NumberStringDto { @Field(isNumberString()) value!: string; } -@Recipe +@baker.Recipe class ISBNDto { @Field(isISBN(13)) value!: string; } -@Recipe +@baker.Recipe class ISINDto { @Field(isISIN) value!: string; } -@Recipe +@baker.Recipe class ISO8601StrictDto { @Field(isISO8601({ strict: true })) value!: string; } -@Recipe +@baker.Recipe class ISSNDto { @Field(isISSN()) value!: string; } -@Recipe +@baker.Recipe class FQDNDto { @Field(isFQDN()) value!: string; } -@Recipe +@baker.Recipe class EANDto { @Field(isEAN) value!: string; } -@Recipe +@baker.Recipe class JSONDto { @Field(isJSON) value!: string; } -@Recipe +@baker.Recipe class IBANDto { @Field(isIBAN()) value!: string; } -@Recipe +@baker.Recipe class ByteLengthDto { @Field(isByteLength(1, 100)) value!: string; } -@Recipe +@baker.Recipe class LatitudeDto { @Field(isLatitude) value!: number; } -@Recipe +@baker.Recipe class LongitudeDto { @Field(isLongitude) value!: number; } -@Recipe +@baker.Recipe class StrongPasswordDto { @Field(isStrongPassword()) value!: string; } -@Recipe +@baker.Recipe class NotEmptyObjDto { @Field(isNotEmptyObject({ nullable: true })) value!: object; } // Warm seal -seal(); -deserialize(NumberStringDto, { value: '123' }); -deserialize(ISBNDto, { value: '9780306406157' }); -deserialize(ISINDto, { value: 'US0378331005' }); -deserialize(ISO8601StrictDto, { value: '2024-01-15T10:30:00Z' }); -deserialize(ISSNDto, { value: '0378-5955' }); -deserialize(FQDNDto, { value: 'example.com' }); -deserialize(EANDto, { value: '73513537' }); -deserialize(JSONDto, { value: '{"a":1}' }); -deserialize(IBANDto, { value: 'DE89370400440532013000' }); -deserialize(ByteLengthDto, { value: 'hello' }); -deserialize(LatitudeDto, { value: 45.5 }); -deserialize(LongitudeDto, { value: -122.6 }); -deserialize(StrongPasswordDto, { value: 'Str0ng!Pass' }); -deserialize(NotEmptyObjDto, { value: { a: 1 } }); +baker.seal(); +baker.deserialize(NumberStringDto, { value: '123' }); +baker.deserialize(ISBNDto, { value: '9780306406157' }); +baker.deserialize(ISINDto, { value: 'US0378331005' }); +baker.deserialize(ISO8601StrictDto, { value: '2024-01-15T10:30:00Z' }); +baker.deserialize(ISSNDto, { value: '0378-5955' }); +baker.deserialize(FQDNDto, { value: 'example.com' }); +baker.deserialize(EANDto, { value: '73513537' }); +baker.deserialize(JSONDto, { value: '{"a":1}' }); +baker.deserialize(IBANDto, { value: 'DE89370400440532013000' }); +baker.deserialize(ByteLengthDto, { value: 'hello' }); +baker.deserialize(LatitudeDto, { value: 45.5 }); +baker.deserialize(LongitudeDto, { value: -122.6 }); +baker.deserialize(StrongPasswordDto, { value: 'Str0ng!Pass' }); +baker.deserialize(NotEmptyObjDto, { value: { a: 1 } }); let sink: unknown; group('proof — inline emit validators (previously refs)', () => { bench('isNumberString', () => { - sink = deserialize(NumberStringDto, { value: '123' }); + sink = baker.deserialize(NumberStringDto, { value: '123' }); }); bench('isISBN(13)', () => { - sink = deserialize(ISBNDto, { value: '9780306406157' }); + sink = baker.deserialize(ISBNDto, { value: '9780306406157' }); }); bench('isISIN', () => { - sink = deserialize(ISINDto, { value: 'US0378331005' }); + sink = baker.deserialize(ISINDto, { value: 'US0378331005' }); }); bench('isISO8601(strict)', () => { - sink = deserialize(ISO8601StrictDto, { value: '2024-01-15T10:30:00Z' }); + sink = baker.deserialize(ISO8601StrictDto, { value: '2024-01-15T10:30:00Z' }); }); bench('isISSN', () => { - sink = deserialize(ISSNDto, { value: '0378-5955' }); + sink = baker.deserialize(ISSNDto, { value: '0378-5955' }); }); bench('isFQDN', () => { - sink = deserialize(FQDNDto, { value: 'example.com' }); + sink = baker.deserialize(FQDNDto, { value: 'example.com' }); }); bench('isEAN', () => { - sink = deserialize(EANDto, { value: '73513537' }); + sink = baker.deserialize(EANDto, { value: '73513537' }); }); bench('isJSON', () => { - sink = deserialize(JSONDto, { value: '{"a":1}' }); + sink = baker.deserialize(JSONDto, { value: '{"a":1}' }); }); bench('isIBAN', () => { - sink = deserialize(IBANDto, { value: 'DE89370400440532013000' }); + sink = baker.deserialize(IBANDto, { value: 'DE89370400440532013000' }); }); bench('isByteLength', () => { - sink = deserialize(ByteLengthDto, { value: 'hello' }); + sink = baker.deserialize(ByteLengthDto, { value: 'hello' }); }); bench('isLatitude', () => { - sink = deserialize(LatitudeDto, { value: 45.5 }); + sink = baker.deserialize(LatitudeDto, { value: 45.5 }); }); bench('isLongitude', () => { - sink = deserialize(LongitudeDto, { value: -122.6 }); + sink = baker.deserialize(LongitudeDto, { value: -122.6 }); }); bench('isStrongPassword', () => { - sink = deserialize(StrongPasswordDto, { value: 'Str0ng!Pass' }); + sink = baker.deserialize(StrongPasswordDto, { value: 'Str0ng!Pass' }); }); bench('isNotEmptyObject(nullable)', () => { - sink = deserialize(NotEmptyObjDto, { value: { a: 1 } }); + sink = baker.deserialize(NotEmptyObjDto, { value: { a: 1 } }); }); }); diff --git a/bench/proof-optimization-candidates.bench.ts b/bench/proof-optimization-candidates.bench.ts index b08e750..9e0a458 100644 --- a/bench/proof-optimization-candidates.bench.ts +++ b/bench/proof-optimization-candidates.bench.ts @@ -1,21 +1,23 @@ import { bench, group, run } from 'mitata'; -import { Field, Recipe, deserialize, serialize, seal } from '../index'; +import { Baker, Field } from '../index'; import { isString, minLength } from '../src/rules/index'; -@Recipe +const baker = new Baker(); + +@baker.Recipe class PlainDto { @Field(isString, minLength(1)) value!: string; } -@Recipe +@baker.Recipe class GroupDto { @Field(isString, minLength(1), { groups: ['admin'] }) value!: string; } -@Recipe +@baker.Recipe class OneTransformDto { @Field(isString, { transform: { @@ -26,7 +28,7 @@ class OneTransformDto { value!: string; } -@Recipe +@baker.Recipe class TwoTransformDto { @Field(isString, { transform: [ @@ -37,11 +39,11 @@ class TwoTransformDto { value!: string; } -seal(); -deserialize(PlainDto, { value: 'x' }); -deserialize(GroupDto, { value: 'x' }, { groups: ['admin'] }); -deserialize(OneTransformDto, { value: 'x' }); -deserialize(TwoTransformDto, { value: 'x' }); +baker.seal(); +baker.deserialize(PlainDto, { value: 'x' }); +baker.deserialize(GroupDto, { value: 'x' }, { groups: ['admin'] }); +baker.deserialize(OneTransformDto, { value: 'x' }); +baker.deserialize(TwoTransformDto, { value: 'x' }); const serOne = Object.assign(new OneTransformDto(), { value: 'x' }); const serTwo = Object.assign(new TwoTransformDto(), { value: 'x' }); @@ -50,27 +52,27 @@ let sink: unknown; group('proof — optimization candidates', () => { bench('deserialize plain field', () => { - sink = deserialize(PlainDto, { value: 'x' }); + sink = baker.deserialize(PlainDto, { value: 'x' }); }); bench('deserialize grouped field with groups', () => { - sink = deserialize(GroupDto, { value: 'x' }, { groups: ['admin'] }); + sink = baker.deserialize(GroupDto, { value: 'x' }, { groups: ['admin'] }); }); bench('deserialize one sync transform', () => { - sink = deserialize(OneTransformDto, { value: 'x' }); + sink = baker.deserialize(OneTransformDto, { value: 'x' }); }); bench('deserialize two sync transforms', () => { - sink = deserialize(TwoTransformDto, { value: 'x' }); + sink = baker.deserialize(TwoTransformDto, { value: 'x' }); }); bench('serialize one sync transform', () => { - sink = serialize(serOne); + sink = baker.serialize(serOne); }); bench('serialize two sync transforms', () => { - sink = serialize(serTwo); + sink = baker.serialize(serTwo); }); }); diff --git a/bench/proof-sync-overhead.bench.ts b/bench/proof-sync-overhead.bench.ts index 9f5c7f4..3fc7d33 100644 --- a/bench/proof-sync-overhead.bench.ts +++ b/bench/proof-sync-overhead.bench.ts @@ -1,27 +1,29 @@ import { bench, group, run } from 'mitata'; -import { createRule, deserialize, Field, Recipe, seal } from '../index'; +import { Baker, createRule, Field } from '../index'; import { isString } from '../src/rules/index'; +const baker = new Baker(); + const directRule = (value: unknown) => typeof value === 'string'; const wrappedRule = createRule({ name: 'wrappedString', validate: directRule, }); -@Recipe +@baker.Recipe class BuiltinDto { @Field(isString) value!: string; } -@Recipe +@baker.Recipe class CustomRuleDto { @Field(wrappedRule) value!: string; } -@Recipe +@baker.Recipe class TransformDto { @Field(isString, { transform: { @@ -33,10 +35,10 @@ class TransformDto { } // Warm seal -seal(); -deserialize(BuiltinDto, { value: 'x' }); -deserialize(CustomRuleDto, { value: 'x' }); -deserialize(TransformDto, { value: 'x' }); +baker.seal(); +baker.deserialize(BuiltinDto, { value: 'x' }); +baker.deserialize(CustomRuleDto, { value: 'x' }); +baker.deserialize(TransformDto, { value: 'x' }); let sink: unknown; @@ -50,15 +52,15 @@ group('proof — sync overhead hotspots', () => { }); bench('deserialize builtin rule DTO', () => { - sink = deserialize(BuiltinDto, { value: 'x' }); + sink = baker.deserialize(BuiltinDto, { value: 'x' }); }); bench('deserialize custom rule DTO', () => { - sink = deserialize(CustomRuleDto, { value: 'x' }); + sink = baker.deserialize(CustomRuleDto, { value: 'x' }); }); bench('deserialize sync transform DTO', () => { - sink = deserialize(TransformDto, { value: 'x' }); + sink = baker.deserialize(TransformDto, { value: 'x' }); }); }); diff --git a/bench/simple.bench.ts b/bench/simple.bench.ts index 707f376..44f5f9d 100644 --- a/bench/simple.bench.ts +++ b/bench/simple.bench.ts @@ -11,13 +11,15 @@ import { bench, group, run } from 'mitata'; import * as v from 'valibot'; import { z } from 'zod'; -import { Field, Recipe, deserialize, isBakerIssueSet, seal } from '../index'; +import { Baker, Field, isBakerIssueSet } from '../index'; import { isString, isEmail, isNumber, isBoolean, min, max, minLength } from '../src/rules/index'; import { SIMPLE_VALID, SIMPLE_INVALID } from './data'; +const baker = new Baker(); + // ── Baker ──────────────────────────────────────────────────────────────────── -@Recipe +@baker.Recipe class BakerSimple { @Field(isString, minLength(2)) name!: string; @Field(isString, isEmail()) email!: string; @@ -25,7 +27,7 @@ class BakerSimple { @Field(isBoolean) active!: boolean; @Field(isString) tag!: string; } -seal(); +baker.seal(); // ── Zod ────────────────────────────────────────────────────────────────────── @@ -103,7 +105,7 @@ let sinkNum = 0; group('simple object — valid input', () => { bench('baker', () => { - const r = deserialize(BakerSimple, SIMPLE_VALID) as BakerSimple; + const r = baker.deserialize(BakerSimple, SIMPLE_VALID) as BakerSimple; sinkNum += r.tag.length; }); bench('zod', () => { @@ -136,7 +138,7 @@ group('simple object — valid input', () => { group('simple object — invalid input', () => { bench('baker', () => { - const r = deserialize(BakerSimple, SIMPLE_INVALID); + const r = baker.deserialize(BakerSimple, SIMPLE_INVALID); if (isBakerIssueSet(r)) { sinkNum += r.errors.length; } else { diff --git a/bench/validate-only.bench.ts b/bench/validate-only.bench.ts index d8de9da..2874a88 100644 --- a/bench/validate-only.bench.ts +++ b/bench/validate-only.bench.ts @@ -6,25 +6,27 @@ import Ajv from 'ajv'; // ───────────────────────────────────────────────────────────────────────────── import { bench, group, run } from 'mitata'; -import { Field, Recipe, deserialize, seal, validate } from '../index'; +import { Baker, Field } from '../index'; import { isString, isNumber, min, minLength, arrayMinSize } from '../src/rules/index'; import { NESTED_VALID, NESTED_INVALID } from './data'; +const baker = new Baker(); + // ── Baker ──────────────────────────────────────────────────────────────────── -@Recipe +@baker.Recipe class BkAddr { @Field(isString, minLength(1)) street!: string; @Field(isString, minLength(1)) city!: string; @Field(isString, minLength(1)) zip!: string; } -@Recipe +@baker.Recipe class BkCust { @Field(isString, minLength(1)) name!: string; @Field(isString) email!: string; @Field({ type: () => BkAddr }) address!: BkAddr; } -@Recipe +@baker.Recipe class BkOrder { @Field(isString, minLength(1)) title!: string; @Field({ type: () => BkCust }) customer!: BkCust; @@ -32,21 +34,21 @@ class BkOrder { } // Array benchmark DTO -@Recipe +@baker.Recipe class BkItem { @Field(isString, minLength(1)) name!: string; @Field(isNumber(), min(0)) price!: number; } -@Recipe +@baker.Recipe class BkCart { @Field(arrayMinSize(1), { type: () => [BkItem] }) items!: BkItem[]; } // Warm seal -seal(); -deserialize(BkOrder, NESTED_VALID); +baker.seal(); +baker.deserialize(BkOrder, NESTED_VALID); const cartInput = { items: Array.from({ length: 1000 }, (_, i) => ({ name: `item${i}`, price: i })) }; -deserialize(BkCart, cartInput); +baker.deserialize(BkCart, cartInput); // ── TypeBox (validate-only baseline) ───────────────────────────────────────── @@ -101,10 +103,10 @@ let sink: unknown; group('nested 3-level — validate vs deserialize', () => { bench('baker validate()', () => { - sink = validate(BkOrder, NESTED_VALID); + sink = baker.validate(BkOrder, NESTED_VALID); }); bench('baker deserialize()', () => { - sink = deserialize(BkOrder, NESTED_VALID); + sink = baker.deserialize(BkOrder, NESTED_VALID); }); bench('typebox Check()', () => { sink = tbCheck.Check(NESTED_VALID); @@ -113,10 +115,10 @@ group('nested 3-level — validate vs deserialize', () => { group('array 1000 items — validate vs deserialize', () => { bench('baker validate()', () => { - sink = validate(BkCart, cartInput); + sink = baker.validate(BkCart, cartInput); }); bench('baker deserialize()', () => { - sink = deserialize(BkCart, cartInput); + sink = baker.deserialize(BkCart, cartInput); }); bench('typebox Check()', () => { sink = tbCartCheck.Check(cartInput); @@ -128,10 +130,10 @@ group('array 1000 items — validate vs deserialize', () => { group('nested 3-level — invalid', () => { bench('baker validate()', () => { - sink = validate(BkOrder, NESTED_INVALID); + sink = baker.validate(BkOrder, NESTED_INVALID); }); bench('baker deserialize()', () => { - sink = deserialize(BkOrder, NESTED_INVALID); + sink = baker.deserialize(BkOrder, NESTED_INVALID); }); }); diff --git a/index.ts b/index.ts index 515ec57..75c1201 100644 --- a/index.ts +++ b/index.ts @@ -1,23 +1,25 @@ // Public API — Core -export { createRule } from './src/create-rule'; +export { createRule } from './src/rules'; // Decorators -export { Field, arrayOf } from './src/decorators/index'; -export type { FieldOptions, ArrayOfMarker } from './src/decorators/index'; +export { Field, arrayOf } from './src/decorators'; +export type { FieldOptions, ArrayOfMarker } from './src/decorators'; // Baker — multi-app isolation boundary (`new Baker(config?)`) export { Baker } from './src/baker'; // Enums -export { ExcludeMode, RequiredType } from './src/enums'; +export { ExcludeMode } from './src/decorators'; +export { RequiredType } from './src/rules'; // Errors -export type { BakerIssue, BakerIssueSet } from './src/errors'; -export { isBakerIssueSet, BakerError } from './src/errors'; +export type { BakerIssue, BakerIssueSet } from './src/common'; +export { isBakerIssueSet, BakerError } from './src/common'; // Types -export type { EmittableRule, Transformer, TransformParams } from './src/types'; -export type { BakerConfig } from './src/configure'; +export type { EmittableRule } from './src/rules'; +export type { Transformer, TransformParams } from './src/transformers'; +export type { BakerConfig } from './src/config'; // Interfaces / Options -export type { RuntimeOptions } from './src/interfaces'; +export type { RuntimeOptions } from './src/common'; diff --git a/package.json b/package.json index bc4eab4..e216738 100644 --- a/package.json +++ b/package.json @@ -50,20 +50,20 @@ "import": "./dist/index.js" }, "./decorators": { - "types": "./dist/src/decorators/index.d.ts", - "import": "./dist/src/decorators/index.js" + "types": "./dist/src/decorators/public.d.ts", + "import": "./dist/src/decorators/public.js" }, "./rules": { - "types": "./dist/src/rules/index.d.ts", - "import": "./dist/src/rules/index.js" + "types": "./dist/src/rules/public.d.ts", + "import": "./dist/src/rules/public.js" }, "./symbols": { "types": "./dist/src/symbols.d.ts", "import": "./dist/src/symbols.js" }, "./transformers": { - "types": "./dist/src/transformers/index.d.ts", - "import": "./dist/src/transformers/index.js" + "types": "./dist/src/transformers/public.d.ts", + "import": "./dist/src/transformers/public.js" } }, "publishConfig": { diff --git a/scripts/fix-exports-last.ts b/scripts/fix-exports-last.ts index 13fdd86..7196456 100644 --- a/scripts/fix-exports-last.ts +++ b/scripts/fix-exports-last.ts @@ -10,7 +10,7 @@ const files = [ 'src/rule-plan.ts', 'src/rules/array.ts', 'src/rules/locales.ts', - 'src/functions/validate.ts', + 'src/runtime/validate.ts', 'src/decorators/field.ts', 'src/seal/deserialize-builder.ts', 'src/seal/serialize-builder.ts', diff --git a/src/baker.ts b/src/baker.ts index cf11533..d27bbd8 100644 --- a/src/baker.ts +++ b/src/baker.ts @@ -1,14 +1,22 @@ -import type { BakerConfig } from './configure'; -import type { BakerIssueSet } from './errors'; -import type { RuntimeOptions, SealOptions } from './interfaces'; -import type { SealedExecutors } from './types'; +import type { BakerIssueSet, ClassCtor, RuntimeOptions } from './common'; +import type { BakerConfig } from './config'; +import type { SealOptions, SealedExecutors } from './seal'; -import { normalizeConfig } from './configure'; -import { BakerError } from './errors'; -import { runDeserialize, runDeserializeSync, runDeserializeAsync } from './functions/deserialize'; -import { resolveSerializeClass, runSerialize, runSerializeSync, runSerializeAsync } from './functions/serialize'; -import { runValidate, runValidateSync, runValidateAsync } from './functions/validate'; -import { sealRegistry } from './seal/seal'; +import { BakerError } from './common'; +import { normalizeConfig } from './config'; +import { + runDeserialize, + runDeserializeSync, + runDeserializeAsync, + resolveSerializeClass, + runSerialize, + runSerializeSync, + runSerializeAsync, + runValidate, + runValidateSync, + runValidateAsync, +} from './runtime'; +import { sealRegistry } from './seal'; /** * A baker — an isolated registration + seal + runtime boundary. Each `new Baker()` owns its own @@ -39,7 +47,9 @@ export class Baker { #sealed = false; constructor(config?: BakerConfig) { - this.#options = config === undefined ? Object.freeze({}) : normalizeConfig(config); + // Route the no-config case through the normalizer too (config ?? {}) so "all defaults" has ONE + // canonical SealOptions shape (explicit `false`s), not a structurally-different empty object. + this.#options = normalizeConfig(config === undefined ? {} : config); } /** Class decorator — registers the class as a root of this baker. Use as `@app.Recipe`. */ @@ -80,34 +90,28 @@ export class Baker { } deserialize = ( - Class: new (...args: never[]) => T, + Class: ClassCtor, input: unknown, options?: RuntimeOptions, ): T | BakerIssueSet | Promise => runDeserialize(this.#require(Class), input, options); - deserializeSync = (Class: new (...args: never[]) => T, input: unknown, options?: RuntimeOptions): T | BakerIssueSet => + deserializeSync = (Class: ClassCtor, input: unknown, options?: RuntimeOptions): T | BakerIssueSet => runDeserializeSync(this.#require(Class), Class.name, input, options); - deserializeAsync = ( - Class: new (...args: never[]) => T, - input: unknown, - options?: RuntimeOptions, - ): Promise => runDeserializeAsync(this.#require(Class), input, options); + deserializeAsync = (Class: ClassCtor, input: unknown, options?: RuntimeOptions): Promise => + runDeserializeAsync(this.#require(Class), input, options); validate = ( - Class: new (...args: never[]) => T, + Class: ClassCtor, input: unknown, options?: RuntimeOptions, ): true | BakerIssueSet | Promise => runValidate(this.#require(Class), input, options); - validateSync = (Class: new (...args: never[]) => T, input: unknown, options?: RuntimeOptions): true | BakerIssueSet => + validateSync = (Class: ClassCtor, input: unknown, options?: RuntimeOptions): true | BakerIssueSet => runValidateSync(this.#require(Class), Class.name, input, options); - validateAsync = ( - Class: new (...args: never[]) => T, - input: unknown, - options?: RuntimeOptions, - ): Promise => runValidateAsync(this.#require(Class), input, options); + validateAsync = (Class: ClassCtor, input: unknown, options?: RuntimeOptions): Promise => + runValidateAsync(this.#require(Class), input, options); serialize = (instance: T, options?: RuntimeOptions): Record | Promise> => runSerialize(this.#require(resolveSerializeClass(instance, 'serialize')), instance, options); diff --git a/src/collect.spec.ts b/src/collect.spec.ts deleted file mode 100644 index c297c61..0000000 --- a/src/collect.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { ensureMeta } from './collect'; -import { RAW } from './symbols'; - -type MetaObject = Record; - -describe('collect', () => { - it('should create the RAW slot on the metadata object when calling ensureMeta for the first time', () => { - const metadata: MetaObject = {}; - ensureMeta(metadata, 'prop'); - expect(metadata[RAW]).toBeDefined(); - }); - - it('should reuse the existing RAW object when calling ensureMeta again on the same metadata', () => { - const metadata: MetaObject = {}; - ensureMeta(metadata, 'prop'); - const rawBefore = metadata[RAW]; - ensureMeta(metadata, 'other'); - expect(metadata[RAW]).toBe(rawBefore); - }); - - it('should create a fresh own RAW when the parent metadata is inherited via the prototype chain', () => { - const parent: MetaObject = {}; - ensureMeta(parent, 'p'); - const child: MetaObject = Object.create(parent) as MetaObject; - ensureMeta(child, 'c'); - expect(Object.hasOwn(child, RAW)).toBe(true); - expect(child[RAW]).not.toBe(parent[RAW]); - }); - - it('should create default meta for a new key', () => { - const metadata: MetaObject = {}; - const meta = ensureMeta(metadata, 'newProp'); - expect(meta).toBeDefined(); - expect(meta.validation).toEqual([]); - }); - - it('should return the same meta object for an already-registered key', () => { - const metadata: MetaObject = {}; - const first = ensureMeta(metadata, 'prop'); - const second = ensureMeta(metadata, 'prop'); - expect(first).toBe(second); - }); - - it('should have the correct default shape', () => { - const metadata: MetaObject = {}; - const meta = ensureMeta(metadata, 'prop'); - expect(meta.validation).toEqual([]); - expect(meta.transform).toEqual([]); - expect(meta.expose).toEqual([]); - expect(meta.exclude).toBeNull(); - expect(meta.type).toBeNull(); - expect(meta.flags).toEqual({}); - }); -}); diff --git a/src/collect.ts b/src/collect.ts deleted file mode 100644 index 873fc2a..0000000 --- a/src/collect.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { RawClassMeta, RawPropertyMeta } from './types'; - -import { RAW } from './symbols'; - -type MetaObject = Record & { [RAW]?: RawClassMeta }; - -// ───────────────────────────────────────────────────────────────────────────── -// ensureMeta — Internal utility (§3.1) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Returns the RawPropertyMeta for the given propertyKey on the class's decorator metadata. - * Creates the RAW slot and the per-key default meta if absent. - * - * The own-RAW check is required: a subclass's metadata inherits the parent's RAW via the - * metadata prototype chain, so a bare assignment would pollute the parent. Creating a fresh - * own RAW (null prototype) keeps child fields isolated. - */ -export function ensureMeta(metadata: MetaObject, key: string): RawPropertyMeta { - if (!Object.hasOwn(metadata, RAW)) { - metadata[RAW] = Object.create(null) as RawClassMeta; - } - const raw = metadata[RAW]!; - - return (raw[key] ??= { - validation: [], - transform: [], - expose: [], - exclude: null, - type: null, - flags: {}, - }); -} diff --git a/src/common/enums.ts b/src/common/enums.ts new file mode 100644 index 0000000..7f70c27 --- /dev/null +++ b/src/common/enums.ts @@ -0,0 +1,13 @@ +// Cross-cutting enums with no single owning stage (string-valued; inlined in --production builds). + +/** Direction of a (de)serialization pass. */ +export enum Direction { + Deserialize = 'deserialize', + Serialize = 'serialize', +} + +/** Cached accessor a RulePlan reuses across checks. */ +export enum CacheKey { + Length = 'length', + Time = 'time', +} diff --git a/src/error-system.spec.ts b/src/common/error-system.spec.ts similarity index 92% rename from src/error-system.spec.ts rename to src/common/error-system.spec.ts index 7480d5b..5caf9f4 100644 --- a/src/error-system.spec.ts +++ b/src/common/error-system.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'bun:test'; -import { createRule } from './create-rule'; +import { createRule } from '../rules/create-rule'; +import { isPassportNumber } from '../rules/locales'; +import { isDivisibleBy, max, min } from '../rules/number'; import { BakerError } from './errors'; -import { isPassportNumber } from './rules/locales'; -import { isDivisibleBy, max, min } from './rules/number'; // Every developer-misuse condition discoverable WITHOUT external input must throw the single // throw-channel class `BakerError` (never a bare Error/TypeError). These all throw at diff --git a/src/errors.spec.ts b/src/common/errors.spec.ts similarity index 98% rename from src/errors.spec.ts rename to src/common/errors.spec.ts index 05126ef..53060b9 100644 --- a/src/errors.spec.ts +++ b/src/common/errors.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'bun:test'; import type { BakerIssue } from './errors'; -import { assertBakerIssueSet } from '../test/integration/helpers/assert'; +import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { isBakerIssueSet, BAKER_ERROR, BakerError, toBakerIssueSet } from './errors'; describe('isBakerIssueSet', () => { diff --git a/src/errors.ts b/src/common/errors.ts similarity index 96% rename from src/errors.ts rename to src/common/errors.ts index 0748d03..0e70462 100644 --- a/src/errors.ts +++ b/src/common/errors.ts @@ -1,5 +1,5 @@ // ───────────────────────────────────────────────────────────────────────────── -// BakerIssue — Individual field error (§12.2) +// BakerIssue — Individual field error // ───────────────────────────────────────────────────────────────────────────── /** @@ -25,7 +25,7 @@ export interface BakerIssue { } // ───────────────────────────────────────────────────────────────────────────── -// BakerIssueSet — Validation failure return (§12.2) +// BakerIssueSet — Validation failure return // ───────────────────────────────────────────────────────────────────────────── /** Symbol tag for isBakerIssueSet() type guard — collision-proof discriminator */ @@ -63,7 +63,7 @@ export function toBakerIssueSet(errors: BakerIssue[]): BakerIssueSet { } // ───────────────────────────────────────────────────────────────────────────── -// BakerError — the single throw channel (§12.2) +// BakerError — the single throw channel // ───────────────────────────────────────────────────────────────────────────── /** diff --git a/src/common/index.ts b/src/common/index.ts new file mode 100644 index 0000000..4c5b5ec --- /dev/null +++ b/src/common/index.ts @@ -0,0 +1,7 @@ +// Directory barrel — cross-cutting primitives consumed across the pipeline. +export { BakerError, isBakerIssueSet, toBakerIssueSet } from './errors'; +export type { BakerIssue, BakerIssueSet } from './errors'; +export { Direction, CacheKey } from './enums'; +export type { ClassCtor } from './types'; +export type { RuntimeOptions } from './interfaces'; +export { isAsyncFunction, isPromiseLike } from './utils'; diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts new file mode 100644 index 0000000..5782e25 --- /dev/null +++ b/src/common/interfaces.ts @@ -0,0 +1,9 @@ +// ───────────────────────────────────────────────────────────────────────────── +// RuntimeOptions — per-call runtime options. Seam type: seal threads it through +// SealedExecutors' signature, runtime consumes it — neither stage owns it. +// ───────────────────────────────────────────────────────────────────────────── + +export interface RuntimeOptions { + /** Per-request groups — passed at runtime since they may vary per request */ + groups?: string[]; +} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..fc8570a --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,2 @@ +/** Generic class constructor — contravariant `never[]` args accept any user constructor */ +export type ClassCtor = new (...args: never[]) => T; diff --git a/src/utils.ts b/src/common/utils.ts similarity index 100% rename from src/utils.ts rename to src/common/utils.ts diff --git a/src/config/config-normalizer.ts b/src/config/config-normalizer.ts new file mode 100644 index 0000000..57d0bb0 --- /dev/null +++ b/src/config/config-normalizer.ts @@ -0,0 +1,29 @@ +import type { SealOptions } from '../seal'; +import type { BakerConfig } from './interfaces'; + +import { BakerError } from '../common'; +import { BAKER_CONFIG_KEYS } from './constants'; + +/** + * Validates a {@link BakerConfig} and maps it to the internal {@link SealOptions}. Used by + * `new Baker(config)`. Stateless — a plain function (no instance/class needed). + */ +export function normalizeConfig(config: BakerConfig): SealOptions { + if (config === null || typeof config !== 'object' || Array.isArray(config)) { + throw new BakerError( + `[baker] config requires a plain object. Received: ${config === null ? 'null' : Array.isArray(config) ? 'array' : typeof config}.`, + ); + } + for (const key of Object.keys(config)) { + if (!BAKER_CONFIG_KEYS.has(key as keyof BakerConfig)) { + throw new BakerError(`[baker] unknown key '${key}'. ` + `Valid keys: ${[...BAKER_CONFIG_KEYS].join(', ')}.`); + } + } + return Object.freeze({ + enableImplicitConversion: config.autoConvert ?? false, + exposeDefaultValues: config.allowClassDefaults ?? false, + stopAtFirstError: config.stopAtFirstError ?? false, + whitelist: config.forbidUnknown ?? false, + debug: config.debug ?? false, + }); +} diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..210bf6b --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,13 @@ +import type { BakerConfig } from './interfaces'; + +/** + * The valid {@link BakerConfig} keys. Shared single source: the ConfigNormalizer rejects unknown keys + * with it, and the per-call options guard (runtime) rejects a seal-time config key passed at call time. + */ +export const BAKER_CONFIG_KEYS = new Set([ + 'autoConvert', + 'allowClassDefaults', + 'stopAtFirstError', + 'forbidUnknown', + 'debug', +]); diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..6512adf --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,4 @@ +// Directory barrel — config normalization (BakerConfig → SealOptions). +export { normalizeConfig } from './config-normalizer'; +export { BAKER_CONFIG_KEYS } from './constants'; +export type { BakerConfig } from './interfaces'; diff --git a/src/config/interfaces.ts b/src/config/interfaces.ts new file mode 100644 index 0000000..76d3b96 --- /dev/null +++ b/src/config/interfaces.ts @@ -0,0 +1,16 @@ +// ───────────────────────────────────────────────────────────────────────────── +// BakerConfig — per-Baker configuration (passed to `new Baker(config)`) +// ───────────────────────────────────────────────────────────────────────────── + +export interface BakerConfig { + /** Automatic type conversion ("123" → 123). @default false */ + autoConvert?: boolean; + /** Use class default values when key is missing from input. @default false */ + allowClassDefaults?: boolean; + /** Stop at first error. @default false */ + stopAtFirstError?: boolean; + /** Reject undeclared fields with an error. @default false */ + forbidUnknown?: boolean; + /** Include field exclusion reasons as comments in generated code. @default false */ + debug?: boolean; +} diff --git a/src/configure.ts b/src/configure.ts deleted file mode 100644 index 0d6c5e6..0000000 --- a/src/configure.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SealOptions } from './interfaces'; - -import { BakerError } from './errors'; - -// ───────────────────────────────────────────────────────────────────────────── -// BakerConfig — per-Baker configuration (passed to `new Baker(config)`) -// ───────────────────────────────────────────────────────────────────────────── - -interface BakerConfig { - /** Automatic type conversion ("123" → 123). @default false */ - autoConvert?: boolean; - /** Use class default values when key is missing from input. @default false */ - allowClassDefaults?: boolean; - /** Stop at first error. @default false */ - stopAtFirstError?: boolean; - /** Reject undeclared fields with an error. @default false */ - forbidUnknown?: boolean; - /** Include field exclusion reasons as comments in generated code. @default false */ - debug?: boolean; -} - -const BAKER_CONFIG_KEYS = new Set([ - 'autoConvert', - 'allowClassDefaults', - 'stopAtFirstError', - 'forbidUnknown', - 'debug', -]); - -/** - * Validate a BakerConfig and map it to the internal SealOptions. Used by `new Baker(config)`. - */ -function normalizeConfig(config: BakerConfig): SealOptions { - if (config === null || typeof config !== 'object' || Array.isArray(config)) { - throw new BakerError( - `[baker] config requires a plain object. Received: ${config === null ? 'null' : Array.isArray(config) ? 'array' : typeof config}.`, - ); - } - for (const key of Object.keys(config)) { - if (!BAKER_CONFIG_KEYS.has(key as keyof BakerConfig)) { - throw new BakerError(`[baker] unknown key '${key}'. ` + `Valid keys: ${[...BAKER_CONFIG_KEYS].join(', ')}.`); - } - } - return Object.freeze({ - enableImplicitConversion: config.autoConvert ?? false, - exposeDefaultValues: config.allowClassDefaults ?? false, - stopAtFirstError: config.stopAtFirstError ?? false, - whitelist: config.forbidUnknown ?? false, - debug: config.debug ?? false, - }); -} - -export { normalizeConfig }; -export type { BakerConfig }; diff --git a/src/decorators/constants.ts b/src/decorators/constants.ts new file mode 100644 index 0000000..7679570 --- /dev/null +++ b/src/decorators/constants.ts @@ -0,0 +1,32 @@ +import type { FieldOptions } from './interfaces'; + +// Brand symbol for the arrayOf() element-rules marker. Globally registered (Symbol.for) so a +// bundler-duplicated copy of baker still recognizes a marker produced by the other copy. Typed as +// `unique symbol` so it can key the precise `ArrayOfMarker` shape (no catch-all index signature). +export const ARRAY_OF: unique symbol = Symbol.for('baker:arrayOf'); + +// The valid FieldOptions keys — the single source used to tell an options object apart from a +// positional rule/marker. Built from a `Record` literal so a new (or +// removed) FieldOptions field is a COMPILE error here until this set is updated; exposed as a +// `Set` so membership tests against arbitrary input keys need no cast. +export const FIELD_OPTION_KEYS: ReadonlySet = new Set( + Object.keys({ + type: true, + discriminator: true, + keepDiscriminatorProperty: true, + rules: true, + optional: true, + nullable: true, + name: true, + deserializeName: true, + serializeName: true, + exclude: true, + groups: true, + when: true, + transform: true, + message: true, + context: true, + mapValue: true, + setValue: true, + } satisfies Record), +); diff --git a/src/decorators/enums.ts b/src/decorators/enums.ts new file mode 100644 index 0000000..7a8113d --- /dev/null +++ b/src/decorators/enums.ts @@ -0,0 +1,5 @@ +/** Direction in which a field is excluded. */ +export enum ExcludeMode { + DeserializeOnly = 'deserializeOnly', + SerializeOnly = 'serializeOnly', +} diff --git a/src/decorators/field-guards.spec.ts b/src/decorators/field-guards.spec.ts index f809ef3..a5b1891 100644 --- a/src/decorators/field-guards.spec.ts +++ b/src/decorators/field-guards.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { isString } from '../rules/index'; import { Field } from './field'; @@ -39,6 +39,16 @@ describe('@Field — target guards', () => { expect(() => Field({ name: 'wire', serializeName: 'out' })(undefined, fieldContext({}))).toThrow(/cannot be combined/); }); + it('rejects providing both mapValue and setValue', () => { + class Foo {} + expect(() => Field({ type: () => Map, mapValue: () => Foo, setValue: () => Foo })(undefined, fieldContext({}))).toThrow( + BakerError, + ); + expect(() => Field({ type: () => Map, mapValue: () => Foo, setValue: () => Foo })(undefined, fieldContext({}))).toThrow( + /cannot both be set/, + ); + }); + it('accepts a normal instance field', () => { expect(() => Field(isString)(undefined, fieldContext({ name: 'name' }))).not.toThrow(); }); diff --git a/src/decorators/field.ts b/src/decorators/field.ts index 5452d44..7aaf0b3 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,21 +1,18 @@ -import type { ClassCtor, EmittableRule, InternalRule, RawPropertyMeta, RuleDef, ExposeDef, TypeDef, Transformer } from '../types'; +import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata'; +import type { EmittableRule, InternalRule } from '../rules'; +import type { Transformer } from '../transformers'; +import type { ArrayOfMarker, FieldOptions } from './interfaces'; +import type { FieldDecorator, RuleArg } from './types'; -import { ensureMeta } from '../collect'; -import { Direction, ExcludeMode } from '../enums'; -import { BakerError } from '../errors'; -import { isAsyncFunction, isPromiseLike } from '../utils'; +import { Direction, BakerError, isAsyncFunction, isPromiseLike } from '../common'; +import { metaStore } from '../metadata'; +import { ARRAY_OF, FIELD_OPTION_KEYS } from './constants'; +import { ExcludeMode } from './enums'; // ───────────────────────────────────────────────────────────────────────────── -// arrayOf — Array element validation marker (replaces each: true) +// arrayOf — Array element validation marker (compiles to per-rule `each: true`) // ───────────────────────────────────────────────────────────────────────────── -const ARRAY_OF = Symbol.for('baker:arrayOf'); - -interface ArrayOfMarker { - readonly [key: symbol]: true; - readonly rules: EmittableRule[]; -} - /** * Apply rules to each element of an array. * @@ -26,82 +23,17 @@ interface ArrayOfMarker { * ``` */ function arrayOf(...rules: EmittableRule[]): ArrayOfMarker { - const marker: { rules: EmittableRule[]; [key: symbol]: true } = { rules, [ARRAY_OF]: true }; - return marker as ArrayOfMarker; + return { rules, [ARRAY_OF]: true }; } function isArrayOfMarker(arg: unknown): arg is ArrayOfMarker { return typeof arg === 'object' && arg !== null && (arg as Record)[ARRAY_OF] === true; } -// ───────────────────────────────────────────────────────────────────────────── -// FieldOptions — @Field options object -// ───────────────────────────────────────────────────────────────────────────── - -interface FieldOptions { - /** Nested DTO type. Thunk — supports circular references. [Dto] for arrays. */ - type?: () => ClassCtor | ClassCtor[] | MapConstructor | SetConstructor; - /** Polymorphic discriminator configuration — used with type */ - discriminator?: { - property: string; - subTypes: { value: Function; name: string }[]; - }; - /** Whether to keep the discriminator property in the result object */ - keepDiscriminatorProperty?: boolean; - /** Validation rules array */ - rules?: (EmittableRule | ArrayOfMarker)[]; - /** Allow undefined */ - optional?: boolean; - /** Allow null */ - nullable?: boolean; - /** JSON key mapping (bidirectional) */ - name?: string; - /** Deserialize direction key mapping (cannot be used with name) */ - deserializeName?: string; - /** Serialize direction key mapping (cannot be used with name) */ - serializeName?: string; - /** Field exclusion — true: bidirectional, 'deserializeOnly': deserialization only, 'serializeOnly': serialization only */ - exclude?: boolean | ExcludeMode; - /** Groups — field visibility control + conditional validation rule application */ - groups?: string[]; - /** Conditional validation — skip all field validation when false */ - when?: (obj: Record) => boolean; - /** Transformer or array of transformers (serialize direction applies in reverse order) */ - transform?: Transformer | Transformer[]; - /** Error message on validation failure — applied to all rules of the field (rule's own message takes precedence) */ - message?: string | ((args: { property: string; value: unknown; constraints: Record }) => string); - /** Error context on validation failure — applied to all rules of the field (rule's own context takes precedence) */ - context?: unknown; - /** Nested DTO class thunk for Map values — used with type: () => Map */ - mapValue?: () => ClassCtor; - /** Nested DTO class thunk for Set elements — used with type: () => Set */ - setValue?: () => ClassCtor; -} - // ───────────────────────────────────────────────────────────────────────────── // FieldOptions detection — distinguish from EmittableRule/ArrayOfMarker // ───────────────────────────────────────────────────────────────────────────── -const FIELD_OPTION_KEYS = new Set([ - 'type', - 'discriminator', - 'keepDiscriminatorProperty', - 'rules', - 'optional', - 'nullable', - 'name', - 'deserializeName', - 'serializeName', - 'exclude', - 'groups', - 'when', - 'transform', - 'message', - 'context', - 'mapValue', - 'setValue', -]); - function isFieldOptions(arg: unknown): arg is FieldOptions { if (typeof arg === 'function') { return false; @@ -124,8 +56,6 @@ function isFieldOptions(arg: unknown): arg is FieldOptions { // Internal helpers — Field() decorator decomposition // ───────────────────────────────────────────────────────────────────────────── -type RuleArg = EmittableRule | ArrayOfMarker; - /** W5: assert that a value is a valid baker rule (has `.emit` fn + `.ruleName` string). */ function assertRule(value: unknown, fieldKey: string, slot?: string): void { const loc = slot ? `${fieldKey} ${slot}` : fieldKey; @@ -171,35 +101,40 @@ function parseFieldArgs(args: unknown[]): { rules: RuleArg[]; options: FieldOpti return { rules: args as RuleArg[], options: {} }; } +// Copy the field-level groups/message/context options onto a rule def (only when provided). The +// message/context copy is REQUIRED, not redundant: the per-element ('each') emission path reads +// `rd.message`/`rd.context` directly via computeRuleExtras and does NOT fall back to the field-level +// meta.message/meta.context (that fallback only covers the non-each, field-own-path failures). +function decorateRuleDef(rd: RuleDef, options: FieldOptions): RuleDef { + if (options.groups !== undefined) { + rd.groups = options.groups; + } + if (options.message !== undefined) { + rd.message = options.message; + } + if (options.context !== undefined) { + rd.context = options.context; + } + return rd; +} + +/** Copy the field-level groups option onto an expose def (only when provided). */ +function withGroups(ed: ExposeDef, options: FieldOptions): ExposeDef { + if (options.groups !== undefined) { + ed.groups = options.groups; + } + return ed; +} + /** Register validation rules + handle arrayOf */ function applyValidation(meta: RawPropertyMeta, rules: RuleArg[], options: FieldOptions): void { for (const rule of rules) { if (isArrayOfMarker(rule)) { for (const innerRule of rule.rules) { - const rd: RuleDef = { rule: innerRule, each: true }; - if (options.groups !== undefined) { - rd.groups = options.groups; - } - if (options.message !== undefined) { - rd.message = options.message; - } - if (options.context !== undefined) { - rd.context = options.context; - } - meta.validation.push(rd); + meta.validation.push(decorateRuleDef({ rule: innerRule, each: true }, options)); } } else { - const rd: RuleDef = { rule: rule as InternalRule }; - if (options.groups !== undefined) { - rd.groups = options.groups; - } - if (options.message !== undefined) { - rd.message = options.message; - } - if (options.context !== undefined) { - rd.context = options.context; - } - meta.validation.push(rd); + meta.validation.push(decorateRuleDef({ rule: rule as InternalRule }, options)); } } } @@ -207,25 +142,13 @@ function applyValidation(meta: RawPropertyMeta, rules: RuleArg[], options: Field /** Handle expose 5-branch logic */ function applyExpose(meta: RawPropertyMeta, options: FieldOptions): void { if (options.name) { - const ed: ExposeDef = { name: options.name }; - if (options.groups !== undefined) { - ed.groups = options.groups; - } - meta.expose.push(ed); + meta.expose.push(withGroups({ name: options.name }, options)); } else if (options.deserializeName || options.serializeName) { if (options.deserializeName) { - const ed: ExposeDef = { name: options.deserializeName, deserializeOnly: true }; - if (options.groups !== undefined) { - ed.groups = options.groups; - } - meta.expose.push(ed); + meta.expose.push(withGroups({ name: options.deserializeName, deserializeOnly: true }, options)); } if (options.serializeName) { - const ed: ExposeDef = { name: options.serializeName, serializeOnly: true }; - if (options.groups !== undefined) { - ed.groups = options.groups; - } - meta.expose.push(ed); + meta.expose.push(withGroups({ name: options.serializeName, serializeOnly: true }, options)); } } else if (options.groups) { meta.expose.push({ groups: options.groups }); @@ -273,8 +196,6 @@ function applyTransform(meta: RawPropertyMeta, propertyKey: string, options: Fie // @Field — Field decorator (4 overloads) // ───────────────────────────────────────────────────────────────────────────── -type FieldDecorator = (value: undefined, context: ClassFieldDecoratorContext) => void; - /** `@Field`() — empty field registration */ function Field(): FieldDecorator; /** `@Field`(isString(), email()) — variadic rules */ @@ -295,7 +216,7 @@ function Field(...args: unknown[]): FieldDecorator { throw new BakerError(`@Field: symbol property keys are not supported. Use a string property name.`); } const propertyKey = context.name; - const meta = ensureMeta(context.metadata, propertyKey); + const meta = metaStore.ensure(context.metadata, propertyKey); const { rules, options } = parseFieldArgs(args); @@ -308,6 +229,14 @@ function Field(...args: unknown[]): FieldDecorator { ); } + // `mapValue` (Map value type) and `setValue` (Set element type) both fill the single collection + // value slot — providing both is ambiguous and would silently drop one. Reject it instead. + if (options.mapValue !== undefined && options.setValue !== undefined) { + throw new BakerError( + `@Field on ${propertyKey}: 'mapValue' and 'setValue' cannot both be set — use 'mapValue' for a Map value type and 'setValue' for a Set element type.`, + ); + } + // W5: validate each rule shape — `.emit` function + `.ruleName` string required. // Catches D2/D4: `@Field(isString())` (boolean), `@Field(isNumber)` (factory unstamped), `@Field(() => true)`. for (let i = 0; i < rules.length; i++) { diff --git a/src/decorators/index.ts b/src/decorators/index.ts index aeff3bf..35c6beb 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -1,2 +1,7 @@ -export { Field, arrayOf } from './field'; -export type { FieldOptions, ArrayOfMarker } from './field'; +// Directory barrel — the FULL internal surface other modules import via `../decorators`. +// The published `./decorators` subpath points at `./public` (curated public surface); ExcludeMode is +// re-exported here for the root entry to assemble without a deep import, not via the `./decorators` path. + +export * from './public'; + +export { ExcludeMode } from './enums'; diff --git a/src/decorators/interfaces.ts b/src/decorators/interfaces.ts new file mode 100644 index 0000000..6a0b780 --- /dev/null +++ b/src/decorators/interfaces.ts @@ -0,0 +1,57 @@ +import type { ClassCtor } from '../common'; +import type { DiscriminatorDef } from '../metadata'; +import type { EmittableRule } from '../rules'; +import type { Transformer } from '../transformers'; +import type { ExcludeMode } from './enums'; + +import { ARRAY_OF } from './constants'; + +// ───────────────────────────────────────────────────────────────────────────── +// arrayOf marker — produced by arrayOf(...), compiles to per-rule `each: true` +// ───────────────────────────────────────────────────────────────────────────── + +export interface ArrayOfMarker { + readonly [ARRAY_OF]: true; + readonly rules: EmittableRule[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// FieldOptions — @Field options object +// ───────────────────────────────────────────────────────────────────────────── + +export interface FieldOptions { + /** Nested DTO type. Thunk — supports circular references. [Dto] for arrays. */ + type?: () => ClassCtor | ClassCtor[] | MapConstructor | SetConstructor; + /** Polymorphic discriminator configuration — used with type */ + discriminator?: DiscriminatorDef; + /** Whether to keep the discriminator property in the result object */ + keepDiscriminatorProperty?: boolean; + /** Validation rules array */ + rules?: (EmittableRule | ArrayOfMarker)[]; + /** Allow undefined */ + optional?: boolean; + /** Allow null */ + nullable?: boolean; + /** JSON key mapping (bidirectional) */ + name?: string; + /** Deserialize direction key mapping (cannot be used with name) */ + deserializeName?: string; + /** Serialize direction key mapping (cannot be used with name) */ + serializeName?: string; + /** Field exclusion — true: bidirectional, 'deserializeOnly': deserialization only, 'serializeOnly': serialization only */ + exclude?: boolean | ExcludeMode; + /** Groups — field visibility control + conditional validation rule application */ + groups?: string[]; + /** Conditional validation — skip all field validation when false */ + when?: (obj: Record) => boolean; + /** Transformer or array of transformers (serialize direction applies in reverse order) */ + transform?: Transformer | Transformer[]; + /** Error message on validation failure — applied to all rules of the field (rule's own message takes precedence) */ + message?: string | ((args: { property: string; value: unknown; constraints: Record }) => string); + /** Error context on validation failure — applied to all rules of the field (rule's own context takes precedence) */ + context?: unknown; + /** Nested DTO class thunk for Map values — used with type: () => Map */ + mapValue?: () => ClassCtor; + /** Nested DTO class thunk for Set elements — used with type: () => Set */ + setValue?: () => ClassCtor; +} diff --git a/src/decorators/public.ts b/src/decorators/public.ts new file mode 100644 index 0000000..5d1b669 --- /dev/null +++ b/src/decorators/public.ts @@ -0,0 +1,2 @@ +export { Field, arrayOf } from './field'; +export type { FieldOptions, ArrayOfMarker } from './interfaces'; diff --git a/src/decorators/transform.spec.ts b/src/decorators/transform.spec.ts index 47fb2f8..8e17036 100644 --- a/src/decorators/transform.spec.ts +++ b/src/decorators/transform.spec.ts @@ -1,11 +1,13 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import type { EmittableRule, RawPropertyMeta, TransformDef, TransformParams, TypeDef } from '../types'; +import type { RawPropertyMeta, TransformDef, TypeDef } from '../metadata/interfaces'; +import type { EmittableRule } from '../rules/interfaces'; +import type { TransformParams } from '../transformers/interfaces'; import { assertDefined } from '../../test/integration/helpers/assert'; import { applyField } from '../../test/integration/helpers/modern-decorator'; -import { ExcludeMode } from '../enums'; -import { deleteRaw, requireRaw } from '../meta-access'; +import { metaStore } from '../metadata'; +import { ExcludeMode } from './enums'; import { Field } from './field'; const createdCtors: Function[] = []; @@ -17,7 +19,7 @@ function makeClass(): new () => unknown { } function fieldMeta(ctor: Function, key: string): RawPropertyMeta { - const m = requireRaw(ctor)[key]; + const m = metaStore.require(ctor)[key]; if (!m) { throw new Error(`${ctor.name}.${key} not registered`); } @@ -42,7 +44,7 @@ function fieldTransform(ctor: Function, key: string, idx: number): TransformDef afterEach(() => { for (const ctor of createdCtors) { - deleteRaw(ctor); + metaStore.delete(ctor); } createdCtors.length = 0; }); diff --git a/src/decorators/types.ts b/src/decorators/types.ts new file mode 100644 index 0000000..2fadec8 --- /dev/null +++ b/src/decorators/types.ts @@ -0,0 +1,8 @@ +import type { EmittableRule } from '../rules'; +import type { ArrayOfMarker } from './interfaces'; + +/** A positional @Field argument — either a rule or an arrayOf(...) element-rules marker. */ +export type RuleArg = EmittableRule | ArrayOfMarker; + +/** The class-field decorator @Field returns — TC39 field decorators receive `undefined` as the value. */ +export type FieldDecorator = (value: undefined, context: ClassFieldDecoratorContext) => void; diff --git a/src/enums.ts b/src/enums.ts deleted file mode 100644 index ad650f9..0000000 --- a/src/enums.ts +++ /dev/null @@ -1,66 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// Enums — shared, cross-cutting literal sets. -// -// All enums are string-valued: their values are identical to the string literals -// they replace, so `===` comparisons, Record keys, and any value interpolated into -// generated code remain byte-identical. `--production` builds inline them. -// ───────────────────────────────────────────────────────────────────────────── - -/** Type a rule assumes for its value — drives the builder's type gate, gate dedup, and autoConvert target. */ -export enum RequiredType { - String = 'string', - Number = 'number', - Boolean = 'boolean', - Date = 'date', - Array = 'array', - Object = 'object', -} - -/** Direction of a (de)serialization pass. */ -export enum Direction { - Deserialize = 'deserialize', - Serialize = 'serialize', -} - -/** Collection container type for a nested field. */ -export enum CollectionType { - Map = 'Map', - Set = 'Set', -} - -/** Cached accessor a RulePlan reuses across checks. */ -export enum CacheKey { - Length = 'length', - Time = 'time', -} - -/** Discriminant for a RulePlanExpr node. */ -export enum RulePlanExprKind { - Value = 'value', - Member = 'member', - Call0 = 'call0', - Literal = 'literal', -} - -/** Discriminant for a RulePlanCheck node. */ -export enum RulePlanCheckKind { - Compare = 'compare', - And = 'and', - Or = 'or', -} - -/** Comparison operator emitted into generated check code. */ -export enum RuleOp { - Lt = '<', - Lte = '<=', - Gt = '>', - Gte = '>=', - Eq = '===', - Neq = '!==', -} - -/** Direction in which a field is excluded. */ -export enum ExcludeMode { - DeserializeOnly = 'deserializeOnly', - SerializeOnly = 'serializeOnly', -} diff --git a/src/interfaces.ts b/src/interfaces.ts deleted file mode 100644 index 00c44ba..0000000 --- a/src/interfaces.ts +++ /dev/null @@ -1,41 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// SealOptions — seal-time options resolved from a Baker's config (§1.4) -// ───────────────────────────────────────────────────────────────────────────── - -export interface SealOptions { - /** - * Automatic conversion using validation decorators as type hints. - * @default false - */ - enableImplicitConversion?: boolean; - /** - * Use class default values when the key is missing from input. - * @default false - */ - exposeDefaultValues?: boolean; - /** - * true: return immediately on first error. false (default): collect all errors. - * @default false - */ - stopAtFirstError?: boolean; - /** - * true: reject undeclared fields. Uses the key set from mergeInheritance(Class) as the allowlist. - * `@Exclude` fields are also included in the whitelist — their presence is allowed but they are excluded from the result. - * @default false - */ - whitelist?: boolean; - /** - * true: include field exclusion reasons as comments in generated code. - * @default false - */ - debug?: boolean; -} - -// ───────────────────────────────────────────────────────────────────────────── -// RuntimeOptions — deserialize/serialize runtime options (§5.3) -// ───────────────────────────────────────────────────────────────────────────── - -export interface RuntimeOptions { - /** Per-request groups — passed at runtime since they may vary per request */ - groups?: string[]; -} diff --git a/src/meta-access.spec.ts b/src/meta-access.spec.ts deleted file mode 100644 index e311ca4..0000000 --- a/src/meta-access.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { deleteRaw, getRaw, hasRawOwn, requireRaw, setRaw } from './meta-access'; - -function fresh(): Function { - return class Anon {}; -} - -describe('meta-access', () => { - describe('RAW slot', () => { - it('roundtrips via setRaw/getRaw', () => { - const cls = fresh(); - const raw = {}; - setRaw(cls, raw); - expect(getRaw(cls)).toBe(raw); - }); - - it('hasRawOwn returns true after setRaw, false after deleteRaw', () => { - const cls = fresh(); - expect(hasRawOwn(cls)).toBe(false); - setRaw(cls, {}); - expect(hasRawOwn(cls)).toBe(true); - deleteRaw(cls); - expect(hasRawOwn(cls)).toBe(false); - }); - - it('requireRaw returns the metadata when present', () => { - const cls = fresh(); - const raw = { x: 1 }; - setRaw(cls, raw as never); - expect(requireRaw(cls)).toBe(raw as never); - }); - - it('requireRaw throws when slot is empty', () => { - const cls = fresh(); - expect(() => requireRaw(cls)).toThrow(/no @Field/); - }); - - it('hasRawOwn is false for a child that inherits the parent metadata via the class prototype chain', () => { - class Parent {} - setRaw(Parent, { x: {} } as never); - class Child extends Parent {} - // Child has no own RAW; Child[Symbol.metadata] resolves to Parent's via the class proto chain. - // hasRawOwn must still report false so mergeInheritance does not double-count the parent. - expect(hasRawOwn(Child)).toBe(false); - expect(hasRawOwn(Parent)).toBe(true); - }); - - it('setRaw on a child does not pollute the parent metadata slot', () => { - class Parent {} - const parentRaw = { p: {} }; - setRaw(Parent, parentRaw as never); - class Child extends Parent {} - setRaw(Child, { c: {} } as never); - expect(getRaw(Parent)).toBe(parentRaw as never); - expect(hasRawOwn(Child)).toBe(true); - expect(getRaw(Child)).not.toBe(parentRaw as never); - }); - }); -}); diff --git a/src/meta-access.ts b/src/meta-access.ts deleted file mode 100644 index 04cb68e..0000000 --- a/src/meta-access.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { RawClassMeta } from './types'; - -import { RAW } from './symbols'; - -// Type boundary — the single place that bridges symbol-keyed storage to typed metadata. -// All other modules must access RAW slots through these helpers only. -// -// RAW lives on the TC39 decorator metadata object (Class[Symbol.metadata][RAW]) — that is -// where modern field decorators can write (they receive `context.metadata`, never the class). -// Sealed executors live in each Baker's own map (keyed by class), never on the class itself. -type MetaObject = Record & { [RAW]?: RawClassMeta }; -type MetaCarrier = Function & { [Symbol.metadata]?: MetaObject | null }; - -/** Returns the metadata object visible on cls (own or inherited via the class prototype chain). */ -function metaOf(cls: Function): MetaObject | undefined { - return (cls as MetaCarrier)[Symbol.metadata] ?? undefined; -} - -/** Returns the class's own metadata object, creating one if absent. */ -function ensureOwnMeta(cls: Function): MetaObject { - if (!Object.hasOwn(cls, Symbol.metadata)) { - Object.defineProperty(cls, Symbol.metadata, { - value: {} as MetaObject, - writable: true, - configurable: true, - enumerable: false, - }); - } - return (cls as MetaCarrier)[Symbol.metadata]!; -} - -export function deleteRaw(cls: Function): void { - if (Object.hasOwn(cls, Symbol.metadata)) { - delete (cls as MetaCarrier)[Symbol.metadata]![RAW]; - } -} - -export function getRaw(cls: Function): RawClassMeta | undefined { - return metaOf(cls)?.[RAW]; -} - -/** Same as getRaw but throws if the class has no @Field decorators — for callers that establish the invariant elsewhere. */ -export function requireRaw(cls: Function): RawClassMeta { - const v = getRaw(cls); - if (v === undefined) { - throw new Error(`${cls.name || ''}: class has no @Field decorators`); - } - return v; -} - -export function setRaw(cls: Function, raw: RawClassMeta): void { - ensureOwnMeta(cls)[RAW] = raw; -} - -/** - * True only when cls has its OWN RAW slot. A subclass without its own @Field decorators - * resolves Class[Symbol.metadata] to the parent's metadata via the class prototype chain; - * the dual own-check keeps mergeInheritance from double-counting inherited fields. - */ -export function hasRawOwn(cls: Function): boolean { - if (!Object.hasOwn(cls, Symbol.metadata)) { - return false; - } - const meta = (cls as MetaCarrier)[Symbol.metadata]; - return meta != null && Object.hasOwn(meta, RAW); -} diff --git a/src/metadata/enums.ts b/src/metadata/enums.ts new file mode 100644 index 0000000..3fc56e7 --- /dev/null +++ b/src/metadata/enums.ts @@ -0,0 +1,5 @@ +/** Collection container type for a nested field. */ +export enum CollectionType { + Map = 'Map', + Set = 'Set', +} diff --git a/src/metadata/index.ts b/src/metadata/index.ts new file mode 100644 index 0000000..2213ef7 --- /dev/null +++ b/src/metadata/index.ts @@ -0,0 +1,13 @@ +// Directory barrel — the RAW metadata IR layer consumed by decorators and seal. +export type { + RawClassMeta, + RawPropertyMeta, + RuleDef, + TransformDef, + ExposeDef, + TypeDef, + MessageArgs, + DiscriminatorDef, +} from './interfaces'; +export { CollectionType } from './enums'; +export { MetaStore, metaStore } from './meta-store'; diff --git a/src/metadata/interfaces.ts b/src/metadata/interfaces.ts new file mode 100644 index 0000000..e6b8fad --- /dev/null +++ b/src/metadata/interfaces.ts @@ -0,0 +1,113 @@ +import type { ClassCtor } from '../common'; +import type { InternalRule } from '../rules'; +import type { TransformFunction } from '../transformers'; +import type { CollectionType } from './enums'; + +// ───────────────────────────────────────────────────────────────────────────── +// RuleDef / TransformDef / ExposeDef / ExcludeDef / TypeDef +// ───────────────────────────────────────────────────────────────────────────── + +/** Arguments for user-defined message callback */ +export interface MessageArgs { + property: string; + value: unknown; + constraints: Record; +} + +export interface RuleDef { + rule: InternalRule; + each?: boolean; + groups?: string[]; + /** Value to include in BakerIssue.message on validation failure */ + message?: string | ((args: MessageArgs) => string); + /** Arbitrary value to include in BakerIssue.context on validation failure */ + context?: unknown; +} + +export interface TransformDef { + fn: TransformFunction; + isAsync?: boolean; + options?: { + groups?: string[]; + deserializeOnly?: boolean; + serializeOnly?: boolean; + }; +} + +export interface ExposeDef { + name?: string; + groups?: string[]; + deserializeOnly?: boolean; + serializeOnly?: boolean; +} + +export interface ExcludeDef { + deserializeOnly?: boolean; + serializeOnly?: boolean; +} + +/** A polymorphic discriminator subtype mapping — a class constructor keyed by its wire name. */ +export interface DiscriminatorSubType { + value: ClassCtor; + name: string; +} + +/** Polymorphic discriminator config — shared single source between @Field options and the IR TypeDef. */ +export interface DiscriminatorDef { + property: string; + subTypes: DiscriminatorSubType[]; +} + +export interface TypeDef { + fn: () => ClassCtor | ClassCtor[] | MapConstructor | SetConstructor; + discriminator?: DiscriminatorDef; + keepDiscriminatorProperty?: boolean; + /** seal-time normalization result — true if fn() returns an array */ + isArray?: boolean; + /** seal-time normalization result — cached class after resolving fn() (DTOs only, excluding primitives) */ + resolvedClass?: ClassCtor; + /** seal-time normalization result — Map or Set collection type */ + collection?: CollectionType; + /** Nested DTO class thunk for Map value / Set element */ + collectionValue?: () => ClassCtor; + /** seal-time normalization result — cached class after resolving collectionValue */ + resolvedCollectionValue?: ClassCtor; +} + +// ───────────────────────────────────────────────────────────────────────────── +// PropertyFlags — presence/nullability/conditional flags from @Field options + seal-time nested analysis +// ───────────────────────────────────────────────────────────────────────────── + +export interface PropertyFlags { + /** `@Field({ optional })` — skip all validation when undefined/null */ + isOptional?: boolean; + /** `@Field({ nullable })` — allow and assign null, reject undefined */ + isNullable?: boolean; + /** `@Field({ when })` — skip all field validation when the predicate returns false */ + validateIf?: (obj: Record) => boolean; + /** Seal-derived — trigger recursive validation for nested `@Field({ type })` DTOs */ + validateNested?: boolean; + /** Seal-derived — validate nested DTOs per array element */ + validateNestedEach?: boolean; +} + +// ───────────────────────────────────────────────────────────────────────────── +// RawPropertyMeta — Collection data stored in Class[Symbol.metadata][RAW][propertyKey] +// ───────────────────────────────────────────────────────────────────────────── + +export interface RawPropertyMeta { + validation: RuleDef[]; + transform: TransformDef[]; + expose: ExposeDef[]; + exclude: ExcludeDef | null; + type: TypeDef | null; + flags: PropertyFlags; + /** Field-level message applied to ALL failures of this field (gate/structural/required/conversion/rule) */ + message?: string | ((args: MessageArgs) => string); + /** Field-level context attached to ALL failures of this field */ + context?: unknown; +} + +export interface RawClassMeta { + [propertyKey: string]: RawPropertyMeta; +} diff --git a/src/metadata/meta-store.spec.ts b/src/metadata/meta-store.spec.ts new file mode 100644 index 0000000..fc11d81 --- /dev/null +++ b/src/metadata/meta-store.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'bun:test'; + +import type { RawClassMeta } from './interfaces'; + +import { RAW } from '../symbols'; +import { metaStore } from './meta-store'; + +function rawWith(key: string): RawClassMeta { + return { [key]: { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} } }; +} + +describe('MetaStore — get / set', () => { + it('returns the raw metadata set on a class', () => { + class A {} + const raw = rawWith('name'); + metaStore.set(A, raw); + expect(metaStore.get(A)).toBe(raw); + }); + + it('returns undefined for a class with no metadata', () => { + class B {} + expect(metaStore.get(B)).toBeUndefined(); + }); +}); + +describe('MetaStore — require', () => { + it('returns the raw metadata when present', () => { + class C {} + const raw = rawWith('x'); + metaStore.set(C, raw); + expect(metaStore.require(C)).toBe(raw); + }); + + it('throws when the class has no @Field decorators', () => { + class D {} + expect(() => metaStore.require(D)).toThrow(/no @Field decorators/); + }); +}); + +describe('MetaStore — delete', () => { + it('removes the raw metadata so get returns undefined', () => { + class E {} + metaStore.set(E, rawWith('y')); + metaStore.delete(E); + expect(metaStore.get(E)).toBeUndefined(); + }); + + it('is a no-op for a class that never had metadata', () => { + class F {} + expect(() => metaStore.delete(F)).not.toThrow(); + }); +}); + +describe('MetaStore — hasOwn', () => { + it('returns true for a class with its own RAW slot', () => { + class G {} + metaStore.set(G, rawWith('z')); + expect(metaStore.hasOwn(G)).toBe(true); + }); + + it('returns false for a class with no metadata', () => { + class H {} + expect(metaStore.hasOwn(H)).toBe(false); + }); + + it('returns false for a subclass that only inherits the parent RAW via the prototype chain', () => { + class Parent {} + metaStore.set(Parent, rawWith('p')); + class Child extends Parent {} + // get walks the chain and finds the parent's RAW, but hasOwn must report false (no OWN slot). + expect(metaStore.get(Child)).toBeDefined(); + expect(metaStore.hasOwn(Child)).toBe(false); + }); +}); + +describe('MetaStore — ensure', () => { + it('creates the RAW slot and a default per-key meta on a fresh metadata object', () => { + const metadata: Record = {}; + const m = metaStore.ensure(metadata, 'field'); + expect(m).toEqual({ validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }); + }); + + it('returns the same meta object on repeated calls for the same key', () => { + const metadata: Record = {}; + const first = metaStore.ensure(metadata, 'field'); + const second = metaStore.ensure(metadata, 'field'); + expect(second).toBe(first); + }); + + it('creates a fresh own RAW on a child metadata object rather than polluting the inherited parent RAW', () => { + const parent: Record = {}; + metaStore.ensure(parent, 'parentField'); + const child: Record = Object.create(parent); + metaStore.ensure(child, 'childField'); + const parentRaw = parent[RAW] as RawClassMeta; + const childRaw = child[RAW] as RawClassMeta; + expect(childRaw).not.toBe(parentRaw); // own slot, not the inherited one + expect('childField' in parentRaw).toBe(false); // child write did not leak into parent + }); +}); diff --git a/src/metadata/meta-store.ts b/src/metadata/meta-store.ts new file mode 100644 index 0000000..cf9f9fc --- /dev/null +++ b/src/metadata/meta-store.ts @@ -0,0 +1,117 @@ +import type { RawClassMeta, RawPropertyMeta } from './interfaces'; +import type { MetaObject, MetaCarrier } from './types'; + +import { BakerError } from '../common'; +import { RAW } from '../symbols'; + +/** + * The single boundary that bridges symbol-keyed decorator metadata to typed RAW metadata. All RAW + * access goes through this one process-wide `metaStore` instance (and, for the seal pipeline, an + * injected reference) — no other module touches `Class[Symbol.metadata][this.#rawKey]` directly. The private + * `#metaOf`/`#ensureOwnMeta` methods are the actual encapsulation; `RAW` itself is process-global + * (`Symbol.for('baker:raw')`), so the value of consolidating access here is one access protocol, not + * a "private symbol". + * + * RAW lives on the TC39 decorator metadata object (`Class[Symbol.metadata][this.#rawKey]`) — where modern field + * decorators can write (they receive `context.metadata`, never the class). Sealed executors live in + * each Baker's own map, never on the class. + */ +class MetaStore { + /** + * The RAW metadata key. Injected (default: the process-global `RAW`, `Symbol.for('baker:raw')`) so a + * test can hand in an isolated symbol; all RAW access below goes through this field. + */ + readonly #rawKey: typeof RAW; + + constructor(rawKey: typeof RAW = RAW) { + this.#rawKey = rawKey; + } + + /** Metadata object visible on cls (own or inherited via the class prototype chain). */ + #metaOf(cls: Function): MetaObject | undefined { + return (cls as MetaCarrier)[Symbol.metadata] ?? undefined; + } + + /** The class's OWN metadata object, creating one if absent. */ + #ensureOwnMeta(cls: Function): MetaObject { + if (!Object.hasOwn(cls, Symbol.metadata)) { + Object.defineProperty(cls, Symbol.metadata, { + value: {} as MetaObject, + writable: true, + configurable: true, + enumerable: false, + }); + } + return (cls as MetaCarrier)[Symbol.metadata]!; + } + + get(cls: Function): RawClassMeta | undefined { + return this.#metaOf(cls)?.[this.#rawKey]; + } + + /** + * Test-only: like {@link get} but throws if the class has no @Field decorators. Specs use it to + * assert metadata presence; production reads go through {@link get}/{@link hasOwn}. + */ + require(cls: Function): RawClassMeta { + const v = this.get(cls); + if (v === undefined) { + throw new BakerError(`${cls.name || ''}: class has no @Field decorators`); + } + return v; + } + + /** + * Test-only: inject RAW metadata directly, bypassing the @Field decorator path. The seal-pipeline + * specs use this to author DTOs programmatically; production metadata is written via {@link ensure}. + */ + set(cls: Function, raw: RawClassMeta): void { + this.#ensureOwnMeta(cls)[this.#rawKey] = raw; + } + + /** Test-only: drop a class's own RAW slot so specs can reset state between cases. */ + delete(cls: Function): void { + if (Object.hasOwn(cls, Symbol.metadata)) { + delete (cls as MetaCarrier)[Symbol.metadata]![this.#rawKey]; + } + } + + /** + * True only when cls has its OWN RAW slot. A subclass without its own @Field decorators resolves + * `Class[Symbol.metadata]` to the parent's metadata via the class prototype chain; the dual own-check + * keeps inheritance merging from double-counting inherited fields. + */ + hasOwn(cls: Function): boolean { + if (!Object.hasOwn(cls, Symbol.metadata)) { + return false; + } + const meta = (cls as MetaCarrier)[Symbol.metadata]; + return meta != null && Object.hasOwn(meta, this.#rawKey); + } + + /** + * The RawPropertyMeta for `key` on a decorator metadata object — creating the RAW slot and the + * per-key default meta if absent. Called by @Field, which receives `context.metadata`. + * + * The own-RAW check is required: a subclass's metadata inherits the parent's RAW via the metadata + * prototype chain, so a bare assignment would pollute the parent. A fresh own RAW (null prototype) + * keeps child fields isolated. + */ + ensure(metadata: MetaObject, key: string): RawPropertyMeta { + if (!Object.hasOwn(metadata, this.#rawKey)) { + metadata[this.#rawKey] = Object.create(null) as RawClassMeta; + } + const raw = metadata[this.#rawKey]!; + return (raw[key] ??= { + validation: [], + transform: [], + expose: [], + exclude: null, + type: null, + flags: {}, + }); + } +} + +export { MetaStore }; +export const metaStore = new MetaStore(); diff --git a/src/metadata/types.ts b/src/metadata/types.ts new file mode 100644 index 0000000..6e01f30 --- /dev/null +++ b/src/metadata/types.ts @@ -0,0 +1,13 @@ +import type { RawClassMeta } from './interfaces'; + +import { RAW } from '../symbols'; + +// `RAW` is imported as a VALUE (not `import type`) because it is used as a computed property key below. +// `RAW` is a `unique symbol` (symbols.ts), so `{ [RAW]?: ... }` is a valid computed key. This is the one +// sanctioned value-import inside a types file — MetaStore is the sole reader/writer of the RAW slot. + +/** The TC39 decorator-metadata object that carries the baker RAW slot (`Class[Symbol.metadata]`). */ +export type MetaObject = Record & { [RAW]?: RawClassMeta }; + +/** A class (constructor) viewed as a carrier of decorator metadata. */ +export type MetaCarrier = Function & { [Symbol.metadata]?: MetaObject | null }; diff --git a/src/rules/array.spec.ts b/src/rules/array.spec.ts index 086a8b3..08ce891 100644 --- a/src/rules/array.spec.ts +++ b/src/rules/array.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; import { arrayContains, arrayNotContains, arrayMinSize, arrayMaxSize, arrayUnique, arrayNotEmpty } from './array'; diff --git a/src/rules/array.ts b/src/rules/array.ts index 6587c0f..c6816ac 100644 --- a/src/rules/array.ts +++ b/src/rules/array.ts @@ -1,7 +1,8 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { CacheKey, RequiredType, RuleOp } from '../enums'; -import { makePlannedRule, makeRule, planCompare, planLength } from '../rule-plan'; +import { CacheKey } from '../common'; +import { RequiredType, RuleOp } from './enums'; +import { makePlannedRule, makeRule, planCompare, planLength } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // arrayContains(values) — array contains all specified values diff --git a/src/rules/binary.spec.ts b/src/rules/binary.spec.ts index 32dff60..45344cf 100644 --- a/src/rules/binary.spec.ts +++ b/src/rules/binary.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; import { isUint8Array, isByteSize } from './binary'; diff --git a/src/rules/binary.ts b/src/rules/binary.ts index c5adabc..8c47734 100644 --- a/src/rules/binary.ts +++ b/src/rules/binary.ts @@ -1,6 +1,6 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // isUint8Array — instanceof guard (self-narrowing, no typeof gate; mirrors isRegExp) @@ -10,8 +10,7 @@ export const isUint8Array = makeRule({ name: 'isUint8Array', constraints: {}, validate: value => value instanceof Uint8Array, - emit: (varName: string, ctx: EmitContext): string => - `if (!(${varName} instanceof Uint8Array)) ${ctx.fail('isUint8Array')};`, + emit: (varName: string, ctx: EmitContext): string => `if (!(${varName} instanceof Uint8Array)) ${ctx.fail('isUint8Array')};`, }); // ───────────────────────────────────────────────────────────────────────────── @@ -29,7 +28,7 @@ export const isUint8Array = makeRule({ export function isByteSize(min: number, max?: number): EmittableRule { return makeRule({ name: 'isByteSize', - constraints: { min, max }, + constraints: max !== undefined ? { min, max } : { min }, // Fail-form mirrors emit exactly (same as isByteLength), so validate() and the generated code // agree for ALL inputs — including degenerate NaN bounds, where pass-form (>= NaN) would reject // but the emitted (< NaN) accepts, breaking validate/emit parity. diff --git a/src/rules/combinators.spec.ts b/src/rules/combinators.spec.ts index 81ae4dc..e0af000 100644 --- a/src/rules/combinators.spec.ts +++ b/src/rules/combinators.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; -import { createRule } from '../create-rule'; import { oneOf, arrayEvery } from './combinators'; +import { createRule } from './create-rule'; import { isString, isBoolean, isNumber } from './typechecker'; // addRef returns incrementing indices so multiple branches map to distinct refs[i]. @@ -59,10 +59,6 @@ describe('oneOf', () => { expect(() => oneOf()).toThrow(); }); - it('should throw at construction when given a non-rule branch', () => { - expect(() => oneOf(isString, 123 as unknown as typeof isString)).toThrow(); - }); - it('should have ruleName oneOf and undefined requiresType', () => { const rule = oneOf(isString, isBoolean); expect(rule.ruleName).toBe('oneOf'); @@ -166,10 +162,6 @@ describe('arrayEvery', () => { expect(() => arrayEvery()).toThrow(); }); - it('should throw at construction when given a non-rule', () => { - expect(() => arrayEvery(123 as unknown as typeof isString)).toThrow(); - }); - it('should return a Promise resolving to true when async element rules all pass', async () => { expect(await arrayEvery(asyncRule)(['async-ok', 'async-ok'])).toBe(true); }); diff --git a/src/rules/combinators.ts b/src/rules/combinators.ts index b401a50..8d27889 100644 --- a/src/rules/combinators.ts +++ b/src/rules/combinators.ts @@ -1,25 +1,7 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { BakerError } from '../errors'; -import { makeRule } from '../rule-plan'; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** Assert that a value is a baker rule (callable with `.emit` fn + `.ruleName` string). */ -function assertRuleArg(value: unknown, combinator: string): asserts value is EmittableRule { - if ( - typeof value === 'function' && - typeof (value as { emit?: unknown }).emit === 'function' && - typeof (value as { ruleName?: unknown }).ruleName === 'string' - ) { - return; - } - throw new BakerError( - `${combinator}: every argument must be a baker rule (function with .emit and .ruleName). Use createRule() or import a rule from @zipbul/baker/rules.`, - ); -} +import { BakerError } from '../common'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // oneOf — OR combinator: value matches at least one of the given rules. @@ -31,9 +13,6 @@ function oneOf(...branches: EmittableRule[]): EmittableRule { if (branches.length === 0) { throw new BakerError('oneOf requires at least one rule.'); } - for (const b of branches) { - assertRuleArg(b, 'oneOf'); - } const constraints = { oneOf: branches.map(b => b.ruleName) }; const isAsync = branches.some(b => b.isAsync === true); @@ -58,7 +37,10 @@ function oneOf(...branches: EmittableRule[]): EmittableRule { }); } - const validate = (value: unknown): boolean => branches.some(b => b(value) as boolean); + // Sync branch: `isAsync` was false, so every branch returns boolean — view them as sync once here + // instead of asserting `as boolean` on each call. + const syncBranches = branches as ((value: unknown) => boolean)[]; + const validate = (value: unknown): boolean => syncBranches.some(b => b(value)); return makeRule({ name: 'oneOf', constraints, @@ -80,9 +62,6 @@ function arrayEvery(...rules: EmittableRule[]): EmittableRule { if (rules.length === 0) { throw new BakerError('arrayEvery requires at least one rule.'); } - for (const r of rules) { - assertRuleArg(r, 'arrayEvery'); - } const constraints = { arrayEvery: rules.map(r => r.ruleName) }; const isAsync = rules.some(r => r.isAsync === true); @@ -112,7 +91,9 @@ function arrayEvery(...rules: EmittableRule[]): EmittableRule { }); } - const elementPredicate = (el: unknown): boolean => rules.every(r => r(el) as boolean); + // Sync branch: every rule returns boolean (see oneOf) — view them as sync once. + const syncRules = rules as ((value: unknown) => boolean)[]; + const elementPredicate = (el: unknown): boolean => syncRules.every(r => r(el)); const validate = (value: unknown): boolean => Array.isArray(value) && value.every(elementPredicate); return makeRule({ name: 'arrayEvery', diff --git a/src/rules/common.spec.ts b/src/rules/common.spec.ts index f12eea0..9c85d88 100644 --- a/src/rules/common.spec.ts +++ b/src/rules/common.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; import { equals, notEquals, isEmpty, isNotEmpty, isIn, isNotIn } from './common'; diff --git a/src/rules/common.ts b/src/rules/common.ts index 87e196b..856c0ff 100644 --- a/src/rules/common.ts +++ b/src/rules/common.ts @@ -1,9 +1,9 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── -// equals — strict equality (===). comparison value passed via refs (§4.8 C) +// equals — strict equality (===). comparison value passed via refs // ───────────────────────────────────────────────────────────────────────────── export function equals(comparison: unknown): EmittableRule { @@ -35,7 +35,7 @@ export function notEquals(comparison: unknown): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// isEmpty — only undefined | null | '' are treated as empty (§4.8 A) +// isEmpty — only undefined | null | '' are treated as empty // ───────────────────────────────────────────────────────────────────────────── export const isEmpty = makeRule({ @@ -47,7 +47,7 @@ export const isEmpty = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isNotEmpty — any value other than undefined | null | '' (§4.8 A) +// isNotEmpty — any value other than undefined | null | '' // ───────────────────────────────────────────────────────────────────────────── export const isNotEmpty = makeRule({ @@ -59,7 +59,7 @@ export const isNotEmpty = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isIn — checks inclusion in array. O(1) lookup via Set (§4.8 C) +// isIn — checks inclusion in array. O(1) lookup via Set // ───────────────────────────────────────────────────────────────────────────── export function isIn(array: unknown[]): EmittableRule { @@ -76,7 +76,7 @@ export function isIn(array: unknown[]): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// isNotIn — checks exclusion from array. O(1) lookup via Set (§4.8 C) +// isNotIn — checks exclusion from array. O(1) lookup via Set // ───────────────────────────────────────────────────────────────────────────── export function isNotIn(array: unknown[]): EmittableRule { diff --git a/src/rules/constants.ts b/src/rules/constants.ts new file mode 100644 index 0000000..8de51de --- /dev/null +++ b/src/rules/constants.ts @@ -0,0 +1,1015 @@ +// Directory barrel of static lookup tables for the rules — pure data, no behavior. + +export const IBAN_COUNTRY_LENGTH: Record = { + AD: 24, + AE: 23, + AL: 28, + AT: 20, + AZ: 28, + BA: 20, + BE: 16, + BG: 22, + BH: 22, + BR: 29, + CH: 21, + CR: 22, + CY: 28, + CZ: 24, + DE: 22, + DK: 18, + DO: 28, + EE: 20, + ES: 24, + FI: 18, + FO: 18, + FR: 27, + GB: 22, + GE: 22, + GI: 23, + GL: 18, + GR: 27, + GT: 28, + HR: 21, + HU: 28, + IE: 22, + IL: 23, + IS: 26, + IT: 27, + JO: 30, + KW: 30, + KZ: 20, + LB: 28, + LC: 32, + LI: 21, + LT: 20, + LU: 20, + LV: 21, + MC: 27, + MD: 24, + ME: 22, + MK: 19, + MR: 27, + MT: 31, + MU: 30, + NL: 18, + NO: 15, + PK: 24, + PL: 28, + PS: 29, + PT: 25, + QA: 29, + RO: 24, + RS: 22, + SA: 24, + SC: 31, + SE: 24, + SI: 19, + SK: 24, + SM: 27, + ST: 25, + SV: 28, + TL: 23, + TN: 24, + TR: 26, + UA: 29, + VA: 22, + VG: 24, + XK: 20, +}; + +export const ISO4217_CODES = new Set([ + 'AED', + 'AFN', + 'ALL', + 'AMD', + 'ANG', + 'AOA', + 'ARS', + 'AUD', + 'AWG', + 'AZN', + 'BAM', + 'BBD', + 'BDT', + 'BGN', + 'BHD', + 'BIF', + 'BMD', + 'BND', + 'BOB', + 'BOV', + 'BRL', + 'BSD', + 'BTN', + 'BWP', + 'BYN', + 'BZD', + 'CAD', + 'CDF', + 'CHE', + 'CHF', + 'CHW', + 'CLF', + 'CLP', + 'CNY', + 'COP', + 'COU', + 'CRC', + 'CUC', + 'CUP', + 'CVE', + 'CZK', + 'DJF', + 'DKK', + 'DOP', + 'DZD', + 'EGP', + 'ERN', + 'ETB', + 'EUR', + 'FJD', + 'FKP', + 'GBP', + 'GEL', + 'GHS', + 'GIP', + 'GMD', + 'GNF', + 'GTQ', + 'GYD', + 'HKD', + 'HNL', + 'HRK', + 'HTG', + 'HUF', + 'IDR', + 'ILS', + 'INR', + 'IQD', + 'IRR', + 'ISK', + 'JMD', + 'JOD', + 'JPY', + 'KES', + 'KGS', + 'KHR', + 'KMF', + 'KPW', + 'KRW', + 'KWD', + 'KYD', + 'KZT', + 'LAK', + 'LBP', + 'LKR', + 'LRD', + 'LSL', + 'LYD', + 'MAD', + 'MDL', + 'MGA', + 'MKD', + 'MMK', + 'MNT', + 'MOP', + 'MRU', + 'MUR', + 'MVR', + 'MWK', + 'MXN', + 'MXV', + 'MYR', + 'MZN', + 'NAD', + 'NGN', + 'NIO', + 'NOK', + 'NPR', + 'NZD', + 'OMR', + 'PAB', + 'PEN', + 'PGK', + 'PHP', + 'PKR', + 'PLN', + 'PYG', + 'QAR', + 'RON', + 'RSD', + 'RUB', + 'RWF', + 'SAR', + 'SBD', + 'SCR', + 'SDG', + 'SEK', + 'SGD', + 'SHP', + 'SLE', + 'SLL', + 'SOS', + 'SRD', + 'SSP', + 'STN', + 'SVC', + 'SYP', + 'SZL', + 'THB', + 'TJS', + 'TMT', + 'TND', + 'TOP', + 'TRY', + 'TTD', + 'TWD', + 'TZS', + 'UAH', + 'UGX', + 'USD', + 'USN', + 'UYI', + 'UYU', + 'UYW', + 'UZS', + 'VED', + 'VES', + 'VND', + 'VUV', + 'WST', + 'XAF', + 'XAG', + 'XAU', + 'XBA', + 'XBB', + 'XBC', + 'XBD', + 'XCD', + 'XDR', + 'XOF', + 'XPD', + 'XPF', + 'XPT', + 'XSU', + 'XTS', + 'XUA', + 'YER', + 'ZAR', + 'ZMW', + 'ZWL', +]); + +// ISO 3166-1 Alpha-2 +export const ISO31661A2_CODES = new Set([ + 'AD', + 'AE', + 'AF', + 'AG', + 'AI', + 'AL', + 'AM', + 'AO', + 'AQ', + 'AR', + 'AS', + 'AT', + 'AU', + 'AW', + 'AX', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BL', + 'BM', + 'BN', + 'BO', + 'BQ', + 'BR', + 'BS', + 'BT', + 'BV', + 'BW', + 'BY', + 'BZ', + 'CA', + 'CC', + 'CD', + 'CF', + 'CG', + 'CH', + 'CI', + 'CK', + 'CL', + 'CM', + 'CN', + 'CO', + 'CR', + 'CU', + 'CV', + 'CW', + 'CX', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'EH', + 'ER', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FK', + 'FM', + 'FO', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GF', + 'GG', + 'GH', + 'GI', + 'GL', + 'GM', + 'GN', + 'GP', + 'GQ', + 'GR', + 'GS', + 'GT', + 'GU', + 'GW', + 'GY', + 'HK', + 'HM', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IM', + 'IN', + 'IO', + 'IQ', + 'IR', + 'IS', + 'IT', + 'JE', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KP', + 'KR', + 'KW', + 'KY', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MF', + 'MG', + 'MH', + 'MK', + 'ML', + 'MM', + 'MN', + 'MO', + 'MP', + 'MQ', + 'MR', + 'MS', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NC', + 'NE', + 'NF', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NU', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PF', + 'PG', + 'PH', + 'PK', + 'PL', + 'PM', + 'PN', + 'PR', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RE', + 'RO', + 'RS', + 'RU', + 'RW', + 'SA', + 'SB', + 'SC', + 'SD', + 'SE', + 'SG', + 'SH', + 'SI', + 'SJ', + 'SK', + 'SL', + 'SM', + 'SN', + 'SO', + 'SR', + 'SS', + 'ST', + 'SV', + 'SX', + 'SY', + 'SZ', + 'TC', + 'TD', + 'TF', + 'TG', + 'TH', + 'TJ', + 'TK', + 'TL', + 'TM', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'UM', + 'US', + 'UY', + 'UZ', + 'VA', + 'VC', + 'VE', + 'VG', + 'VI', + 'VN', + 'VU', + 'WF', + 'WS', + 'YE', + 'YT', + 'ZA', + 'ZM', + 'ZW', +]); + +// ISO 3166-1 Alpha-3 +export const ISO31661A3_CODES = new Set([ + 'ABW', + 'AFG', + 'AGO', + 'AIA', + 'ALA', + 'ALB', + 'AND', + 'ARE', + 'ARG', + 'ARM', + 'ASM', + 'ATA', + 'ATF', + 'ATG', + 'AUS', + 'AUT', + 'AZE', + 'BDI', + 'BEL', + 'BEN', + 'BES', + 'BFA', + 'BGD', + 'BGR', + 'BHR', + 'BHS', + 'BIH', + 'BLM', + 'BLR', + 'BLZ', + 'BMU', + 'BOL', + 'BRA', + 'BRB', + 'BRN', + 'BTN', + 'BVT', + 'BWA', + 'CAF', + 'CAN', + 'CCK', + 'CHE', + 'CHL', + 'CHN', + 'CIV', + 'CMR', + 'COD', + 'COG', + 'COK', + 'COL', + 'COM', + 'CPV', + 'CRI', + 'CUB', + 'CUW', + 'CXR', + 'CYM', + 'CYP', + 'CZE', + 'DEU', + 'DJI', + 'DMA', + 'DNK', + 'DOM', + 'DZA', + 'ECU', + 'EGY', + 'ERI', + 'ESH', + 'ESP', + 'EST', + 'ETH', + 'FIN', + 'FJI', + 'FLK', + 'FRA', + 'FRO', + 'FSM', + 'GAB', + 'GBR', + 'GEO', + 'GGY', + 'GHA', + 'GIB', + 'GIN', + 'GLP', + 'GMB', + 'GNB', + 'GNQ', + 'GRC', + 'GRD', + 'GRL', + 'GTM', + 'GUF', + 'GUM', + 'GUY', + 'HKG', + 'HMD', + 'HND', + 'HRV', + 'HTI', + 'HUN', + 'IDN', + 'IMN', + 'IND', + 'IOT', + 'IRL', + 'IRN', + 'IRQ', + 'ISL', + 'ISR', + 'ITA', + 'JAM', + 'JEY', + 'JOR', + 'JPN', + 'KAZ', + 'KEN', + 'KGZ', + 'KHM', + 'KIR', + 'KNA', + 'KOR', + 'KWT', + 'LAO', + 'LBN', + 'LBR', + 'LBY', + 'LCA', + 'LIE', + 'LKA', + 'LSO', + 'LTU', + 'LUX', + 'LVA', + 'MAC', + 'MAF', + 'MAR', + 'MCO', + 'MDA', + 'MDG', + 'MDV', + 'MEX', + 'MHL', + 'MKD', + 'MLI', + 'MLT', + 'MMR', + 'MNE', + 'MNG', + 'MNP', + 'MOZ', + 'MRT', + 'MSR', + 'MTQ', + 'MUS', + 'MWI', + 'MYS', + 'MYT', + 'NAM', + 'NCL', + 'NER', + 'NFK', + 'NGA', + 'NIC', + 'NIU', + 'NLD', + 'NOR', + 'NPL', + 'NRU', + 'NZL', + 'OMN', + 'PAK', + 'PAN', + 'PCN', + 'PER', + 'PHL', + 'PLW', + 'PNG', + 'POL', + 'PRI', + 'PRK', + 'PRT', + 'PRY', + 'PSE', + 'PYF', + 'QAT', + 'REU', + 'ROU', + 'RUS', + 'RWA', + 'SAU', + 'SDN', + 'SEN', + 'SGP', + 'SGS', + 'SHN', + 'SJM', + 'SLB', + 'SLE', + 'SLV', + 'SMR', + 'SOM', + 'SPM', + 'SRB', + 'SSD', + 'STP', + 'SUR', + 'SVK', + 'SVN', + 'SWE', + 'SWZ', + 'SXM', + 'SYC', + 'SYR', + 'TCA', + 'TCD', + 'TGO', + 'THA', + 'TJK', + 'TKL', + 'TKM', + 'TLS', + 'TON', + 'TTO', + 'TUN', + 'TUR', + 'TUV', + 'TWN', + 'TZA', + 'UGA', + 'UKR', + 'UMI', + 'URY', + 'USA', + 'UZB', + 'VAT', + 'VCT', + 'VEN', + 'VGB', + 'VIR', + 'VNM', + 'VUT', + 'WLF', + 'WSM', + 'YEM', + 'ZAF', + 'ZMB', + 'ZWE', +]); + +export const HASH_REGEXES: Record = { + md5: /^[a-f0-9]{32}$/i, + md4: /^[a-f0-9]{32}$/i, + md2: /^[a-f0-9]{32}$/i, + sha1: /^[a-f0-9]{40}$/i, + sha256: /^[a-f0-9]{64}$/i, + sha384: /^[a-f0-9]{96}$/i, + sha512: /^[a-f0-9]{128}$/i, + ripemd128: /^[a-f0-9]{32}$/i, + ripemd160: /^[a-f0-9]{40}$/i, + 'tiger128,3': /^[a-f0-9]{32}$/i, + 'tiger128,4': /^[a-f0-9]{32}$/i, + 'tiger160,3': /^[a-f0-9]{40}$/i, + 'tiger160,4': /^[a-f0-9]{40}$/i, + 'tiger192,3': /^[a-f0-9]{48}$/i, + 'tiger192,4': /^[a-f0-9]{48}$/i, + crc32: /^[a-f0-9]{8}$/i, + crc32b: /^[a-f0-9]{8}$/i, +}; + +export const TAX_ID_REGEXES: Record = { + US: /^\d{2}-\d{7}$/, // EIN format: XX-XXXXXXX + KR: /^\d{3}-\d{2}-\d{5}$/, // Business Registration Number: XXX-XX-XXXXX + DE: /^\d{11}$/, // Steuernummer: 11 digits + FR: /^[0-9]{13}$/, // SIRET: 13 digits + GB: /^\d{10}$/, // UTR: 10 digits + IT: /^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/i, // Codice Fiscale + ES: /^[0-9A-Z]\d{7}[0-9A-Z]$/i, // NIF/NIE/CIF + AU: /^\d{11}$/, // ABN: 11 digits + CA: /^\d{9}$/, // BN: 9 digits + IN: /^[A-Z]{5}\d{4}[A-Z]$/i, // PAN: XXXXX9999X +}; + +export const MOBILE_PHONE_REGEXES: Record = { + 'ko-KR': /^(\+?82|0)1[016789]\d{7,8}$/, + 'en-US': /^\+?1?[2-9]\d{2}[2-9]\d{6}$/, + 'zh-CN': /^(\+?86)?1[3-9]\d{9}$/, + 'zh-TW': /^(\+?886)?9\d{8}$/, + 'ja-JP': /^(\+?81)?0?[789]0[0-9]{8}$/, + 'de-DE': /^(\+?49)?1(5\d|6[0-9]|7[0-9])\d{8}$/, + 'fr-FR': /^(\+?33)?[67]\d{8}$/, + 'en-GB': /^(\+?44)?7[1-9]\d{8}$/, + 'ru-RU': /^(\+?7)?9\d{9}$/, + 'pt-BR': /^(\+?55)?[1-9]{2}9?\d{8}$/, + 'in-IN': /^(\+?91)?[6-9]\d{9}$/, + 'ar-SA': /^(\+?966)?5\d{8}$/, + 'ar-EG': /^(\+?20)?1[0125]\d{8}$/, + 'vi-VN': /^(\+?84)?[35789]\d{8}$/, + 'th-TH': /^(\+?66)?[689]\d{8}$/, + 'id-ID': /^(\+?62)?8\d{9,11}$/, + 'ms-MY': /^(\+?60)?1\d{8,9}$/, + 'nl-NL': /^(\+?31)?6\d{8}$/, + 'it-IT': /^(\+?39)?3\d{9}$/, + 'es-ES': /^(\+?34)?[67]\d{8}$/, + 'pl-PL': /^(\+?48)?[45789]\d{8}$/, +}; + +export const POSTAL_CODE_REGEXES: Record = { + AD: /^AD\d{3}$/, + AT: /^\d{4}$/, + AU: /^\d{4}$/, + AZ: /^\d{4}$/, + BE: /^\d{4}$/, + BG: /^\d{4}$/, + BR: /^\d{5}-?\d{3}$/, + BY: /^\d{6}$/, + CA: /^[ABCEGHJKLMNPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$/i, + CH: /^\d{4}$/, + CN: /^\d{6}$/, + CZ: /^\d{3} ?\d{2}$/, + DE: /^\d{5}$/, + DK: /^\d{4}$/, + EE: /^\d{5}$/, + ES: /^\d{5}$/, + FI: /^\d{5}$/, + FR: /^\d{2} ?\d{3}$/, + GB: /^(GIR ?0AA|[A-PR-UWYZ]([0-9]{1,2}|([A-HK-Y][0-9]([0-9ABEHMNPRV-Y])?)|[0-9][A-HJKPSTUW]) ?[0-9][ABD-HJLNP-UW-Z]{2})$/i, + GR: /^\d{3} ?\d{2}$/, + HR: /^\d{5}$/, + HU: /^\d{4}$/, + ID: /^\d{5}$/, + IL: /^\d{5}(\d{2})?$/, + IN: /^\d{6}$/, + IS: /^\d{3}$/, + IT: /^\d{5}$/, + JP: /^\d{3}-?\d{4}$/, + KR: /^\d{5}$/, + LI: /^(948[5-9]|949[0-7])$/, + LT: /^LT-\d{5}$/, + LU: /^\d{4}$/, + LV: /^LV-\d{4}$/, + MX: /^\d{5}$/, + MT: /^[A-Z]{3} ?\d{4}$/i, + MZ: /^\d{4}$/, + NL: /^\d{4} ?[A-Z]{2}$/i, + NO: /^\d{4}$/, + NP: /^\d{5}$/, + NZ: /^\d{4}$/, + PH: /^\d{4}$/, + PK: /^\d{5}$/, + PL: /^\d{2}-\d{3}$/, + PR: /^009\d{2}([ -]\d{4})?$/, + PT: /^\d{4}-\d{3}$/, + RO: /^\d{6}$/, + RU: /^\d{6}$/, + SE: /^\d{3} ?\d{2}$/, + SG: /^\d{6}$/, + SI: /^\d{4}$/, + SK: /^\d{3} ?\d{2}$/, + TH: /^\d{5}$/, + TN: /^\d{4}$/, + TW: /^\d{3}(\d{2})?$/, + UA: /^\d{5}$/, + US: /^\d{5}(-\d{4})?$/, + ZA: /^\d{4}$/, + ZM: /^\d{5}$/, +}; + +export const IDENTITY_CARD_REGEXES: Record = { + AF: /^\d{8}$/, + AL: /^[A-Z]\d{8}[A-Z]$/i, + AR: /^\d{7,8}$/, + AZ: /^AZE\d{8}$/, + BE: /^\d{11}$/, + BG: /^\d{10}$/, + BR: /^\d{9}$/, + BY: /^[A-Z]{2}\d{7}$/i, + CA: /^\d{9}$/, + CH: /^756\d{10}$/, + CN: /^\d{15}(\d{2}[0-9xX])?$/, + CY: /^\d{7}[A-Z]$/i, + CZ: /^\d{9,10}$/, + DE: /^[LITOUAEVBMNPRSZDFGHCK]{9}$/i, + DK: /^\d{10}$/, + EE: /^\d{11}$/, + ES: /^[0-9X-Z]\d{7}[TRWAGMYFPDXBNJZSQVHLCKE]$/i, + FI: /^\d{6}[+-A]\d{3}[0-9A-FHJ-NPR-Y]$/, + FR: /^\d{8,9}[0-9Á-ÿ]{1}$/i, + GB: /^[A-Z]{2}\d{6}[A-Z]$/i, + GR: /^[A-Z]{2}\d{6}$/i, + HR: /^\d{11}$/, + HU: /^\d{8}[A-Z]{2}$/i, + ID: /^\d{16}$/, + IE: /^\d{7}[A-W][A-W]?$/, + IL: /^\d{9}$/, + IN: /^\d{12}$/, + IR: /^\d{10}$/, + IS: /^\d{10}$/, + IT: /^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/i, + JP: /^\d{12}$/, + KR: /^\d{6}-\d{7}$/, + LT: /^\d{11}$/, + LU: /^\d{13}$/, + LV: /^\d{6}-\d{5}$/, + MK: /^\d{13}$/, + MX: /^[A-Z]{4}\d{6}[HM][A-Z]{2}[B-DF-HJ-NP-TV-Z]{3}[A-Z0-9]\d$/i, + MT: /^\d{7}[A-Z]$/i, + NL: /^\d{9}$/, + NO: /^\d{11}$/, + PL: /^\d{11}$/, + PT: /^[1-9]\d{7}[0-9TV]$/i, + RO: /^\d{13}$/, + RS: /^\d{13}$/, + RU: /^\d{10}$/, + SE: /^\d{10,12}$/, + SI: /^\d{13}$/, + SK: /^\d{9,10}$/, + TH: /^\d{13}$/, + TR: /^\d{11}$/, + TW: /^[A-Z]\d{9}$/i, + UA: /^\d{9}$/, + US: /^\d{3}-\d{2}-\d{4}$/, + ZA: /^\d{13}$/, +}; + +export const PASSPORT_REGEXES: Record = { + AM: /^[A-Z]{2}\d{7}$/i, + AR: /^[A-Z]{3}\d{6}$/i, + AT: /^[A-Z]\d{7}$/i, + AU: /^[A-Z]\d{7}$/i, + AZ: /^[Aa]\d{8}$/, + BE: /^[A-Z]{2}\d{6}$/i, + BG: /^\d{9}$/, + BH: /^[A-Z]{2}\d{6}$/i, + BR: /^[A-Z]{2}\d{6}$/i, + BY: /^[A-Z]{2}\d{7}$/i, + CA: /^[A-Z]{2}\d{6}$/i, + CH: /^[A-Z]\d{7}$/i, + CN: /^G\d{8}$/, + CY: /^[A-Z](\d{6}|\d{8})$/i, + CZ: /^\d{8}$/, + DE: /^[CFGHJKLMNPRTVWXYZ0-9]{9}$/i, + DK: /^\d{9}$/, + EE: /^([A-Z]\d{7}|[A-Z]{2}\d{7})$/i, + ES: /^[A-Z0-9]{2}([A-Z0-9]?)\d{6}$/i, + FI: /^[A-Z]{2}\d{7}$/i, + FR: /^[A-Z0-9]{9}$/i, + GB: /^\d{9}$/, + GR: /^[A-Z]{2}\d{7}$/i, + HR: /^\d{9}$/, + HU: /^[A-Z]{2}(\d{6}|\d{7})$/i, + ID: /^[A-C]\d{7}$/i, + IE: /^[A-Z0-9]{2}\d{7}$/i, + IL: /^\d{9}$/, + IN: /^[A-Z]\d{7}$/i, + IR: /^[A-Z]\d{8}$/i, + IS: /^(A)\d{7}$/i, + IT: /^[A-Z0-9]{9}$/i, + JO: /^[A-Z]{2}\d{7}$/i, + JP: /^[A-Z]{2}\d{7}$/i, + KR: /^[A-Z][A-Z0-9]\d{7}$/i, + KW: /^\d{8}$/, + KZ: /^[A-Z]\d{8}$/i, + LI: /^[A-Z]\d{6}X$/i, + LT: /^[A-Z0-9]{8}$/i, + LU: /^[A-Z0-9]{8}$/i, + LV: /^[A-Z0-9]{2}\d{7}$/i, + LY: /^[A-Z]{2}\d{7}$/i, + MA: /^[A-Z0-9]{2}\d{7}$/i, + MD: /^[A-Z]{2}\d{7}$/i, + ME: /^[A-Z]{2}\d{7}$/i, + MK: /^[A-Z]\d{7}$/i, + MT: /^\d{7}$/, + MX: /^[A-Z]\d{8}$/i, + MY: /^[AHK]\d{8}[A-Z]$/i, + NL: /^[A-NP-Z]{2}[A-NP-Z0-9]{6}\d$/i, + NO: /^\d{9}$/, + NZ: /^[A-Z]{2}\d{6}$/i, + PH: /^[A-Z]\d{7}[A-Z]$/i, + PK: /^[A-Z]{2}\d{7}$/i, + PL: /^[A-Z]{2}\d{7}$/i, + PT: /^[A-Z]\d{6}$/i, + RO: /^\d{8}$/, + RS: /^\d{9}$/, + RU: /^\d{9}$/, + SA: /^[A-Z]\d{8}$/i, + SE: /^\d{8}$/, + SL: /^(P)[A-Z]\d{7}$/i, + SK: /^[0-9A-Z]\d{7}$/i, + TH: /^[A-Z]{1,2}\d{6,7}$/i, + TN: /^\d{8}$/, + TR: /^[A-Z]\d{8}$/i, + TW: /^[A-Z]\d{9}$/i, + UA: /^[A-Z]{2}\d{6}$/i, + US: /^\d{9}$/, + ZA: /^[A-Z]\d{8}$/i, +}; diff --git a/src/create-rule.spec.ts b/src/rules/create-rule.spec.ts similarity index 99% rename from src/create-rule.spec.ts rename to src/rules/create-rule.spec.ts index 45d8c6e..2aa4254 100644 --- a/src/create-rule.spec.ts +++ b/src/rules/create-rule.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { createRule } from './create-rule'; diff --git a/src/create-rule.ts b/src/rules/create-rule.ts similarity index 95% rename from src/create-rule.ts rename to src/rules/create-rule.ts index d2d4629..0a8fc28 100644 --- a/src/create-rule.ts +++ b/src/rules/create-rule.ts @@ -1,12 +1,11 @@ import type { RequiredType } from './enums'; -import type { EmittableRule, EmitContext, InternalRule } from './types'; +import type { EmittableRule, EmitContext, InternalRule } from './interfaces'; -import { BakerError } from './errors'; +import { BakerError, isAsyncFunction, isPromiseLike } from '../common'; import { defineRuleMetadata } from './rule-metadata'; -import { isAsyncFunction, isPromiseLike } from './utils'; // ───────────────────────────────────────────────────────────────────────────── -// createRule — Custom validation rule creation Public API (§1.1) +// createRule — Custom validation rule creation Public API // ───────────────────────────────────────────────────────────────────────────── export interface CreateRuleOptions { diff --git a/src/rules/date.spec.ts b/src/rules/date.spec.ts index 7113610..f73c865 100644 --- a/src/rules/date.spec.ts +++ b/src/rules/date.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; import { minDate, maxDate } from './date'; diff --git a/src/rules/date.ts b/src/rules/date.ts index e61ebd9..501ff39 100644 --- a/src/rules/date.ts +++ b/src/rules/date.ts @@ -1,10 +1,11 @@ -import type { EmittableRule } from '../types'; +import type { EmittableRule } from './interfaces'; -import { CacheKey, RequiredType, RuleOp } from '../enums'; -import { makePlannedRule, planCompare, planLiteral, planOr, planTime } from '../rule-plan'; +import { CacheKey } from '../common'; +import { RequiredType, RuleOp } from './enums'; +import { makePlannedRule, planCompare, planLiteral, planOr, planTime } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── -// minDate — v >= date (inclusive, getTime comparison). (§4.8 C — refs function call) +// minDate — v >= date (inclusive, getTime comparison). (refs function call) // ───────────────────────────────────────────────────────────────────────────── export function minDate(date: Date): EmittableRule { @@ -23,7 +24,7 @@ export function minDate(date: Date): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// maxDate — v <= date (inclusive, getTime comparison). (§4.8 C — refs function call) +// maxDate — v <= date (inclusive, getTime comparison). (refs function call) // ───────────────────────────────────────────────────────────────────────────── export function maxDate(date: Date): EmittableRule { diff --git a/src/rules/enums.ts b/src/rules/enums.ts new file mode 100644 index 0000000..6755c0a --- /dev/null +++ b/src/rules/enums.ts @@ -0,0 +1,36 @@ +// Rule-domain enums (string-valued; inlined in --production builds). + +/** Type a rule assumes for its value — drives the builder's type gate, gate dedup, and autoConvert target. */ +export enum RequiredType { + String = 'string', + Number = 'number', + Boolean = 'boolean', + Date = 'date', + Array = 'array', + Object = 'object', +} + +/** Discriminant for a RulePlanExpr node. */ +export enum RulePlanExprKind { + Value = 'value', + Member = 'member', + Call0 = 'call0', + Literal = 'literal', +} + +/** Discriminant for a RulePlanCheck node. */ +export enum RulePlanCheckKind { + Compare = 'compare', + And = 'and', + Or = 'or', +} + +/** Comparison operator emitted into generated check code. */ +export enum RuleOp { + Lt = '<', + Lte = '<=', + Gt = '>', + Gte = '>=', + Eq = '===', + Neq = '!==', +} diff --git a/src/rules/index.ts b/src/rules/index.ts index a14adde..9dd026d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,110 +1,12 @@ -export { - isString, - isNumber, - isBoolean, - isDate, - isEnum, - isInt, - isArray, - isObject, - isRegExp, - isFunction, - isStatelessRegExp, -} from './typechecker'; -export type { IsNumberOptions } from './typechecker'; -export { oneOf, arrayEvery } from './combinators'; -export { min, max, isPositive, isNegative, isDivisibleBy } from './number'; -export { minDate, maxDate } from './date'; -export { equals, notEquals, isEmpty, isNotEmpty, isIn, isNotIn } from './common'; -export { - minLength, - maxLength, - length, - contains, - notContains, - matches, - isLowercase, - isUppercase, - isAscii, - isAlpha, - isAlphanumeric, - isHttpToken, - isOrigin, - isCorsOrigin, - isBooleanString, - isNumberString, - isDecimal, - isFullWidth, - isHalfWidth, - isVariableWidth, - isMultibyte, - isSurrogatePair, - isHexadecimal, - isOctal, - isEmail, - isURL, - isUUID, - isIP, - isHexColor, - isRgbColor, - isHSL, - isMACAddress, - isISBN, - isISIN, - isISO8601, - isISRC, - isISSN, - isJWT, - isLatLong, - isLocale, - isDataURI, - isFQDN, - isPort, - isEAN, - isISO31661Alpha2, - isISO31661Alpha3, - isBIC, - isFirebasePushId, - isSemVer, - isMongoId, - isJSON, - isBase32, - isBase58, - isBase64, - isDateString, - isMimeType, - isCurrency, - isMagnetURI, - isCreditCard, - isIBAN, - isByteLength, - isHash, - isRFC3339, - isMilitaryTime, - isLatitude, - isLongitude, - isEthereumAddress, - isBtcAddress, - isISO4217CurrencyCode, - isPhoneNumber, - isStrongPassword, - isTaxId, - isULID, - isCUID2, -} from './string'; -export type { - IsURLOptions, - IsBase64Options, - IsMACAddressOptions, - IsIBANOptions, - IsISSNOptions, - IsFQDNOptions, - IsISO8601Options, - IsNumberStringOptions, - IsStrongPasswordOptions, -} from './string'; -export { arrayContains, arrayNotContains, arrayMinSize, arrayMaxSize, arrayUnique, arrayNotEmpty } from './array'; -export { isNotEmptyObject, isInstance } from './object'; -export type { IsNotEmptyObjectOptions } from './object'; -export { isMobilePhone, isPostalCode, isIdentityCard, isPassportNumber } from './locales'; -export { isUint8Array, isByteSize } from './binary'; +// Directory barrel — the FULL internal surface other domains import via `../rules`. +// The published `./rules` subpath points at `./public` (curated public surface) instead, so these +// internal re-exports (EmitContext / InternalRule / emitRulePlan) never leak into the public API. + +export * from './public'; + +// Internal surface — consumed cross-domain but NOT part of the published `./rules`. +// (createRule is part of the public surface and comes through `export * from './public'` above.) +export { emitRulePlan } from './rule-plan'; +export { RequiredType } from './enums'; +export type { EmittableRule, InternalRule, EmitContext } from './interfaces'; +export type { RulePlanCache } from './types'; diff --git a/src/rules/interfaces.ts b/src/rules/interfaces.ts new file mode 100644 index 0000000..26cb7a9 --- /dev/null +++ b/src/rules/interfaces.ts @@ -0,0 +1,57 @@ +import type { CacheKey } from '../common'; +// Single upward type-only edge `rules → seal`: EmitContext.addExecutor references the compiled +// executor type. `import type` is erased at compile time, so it adds no runtime dependency or cycle. +import type { SealedExecutors } from '../seal'; +import type { RequiredType } from './enums'; +import type { RulePlanCheck } from './types'; + +// ───────────────────────────────────────────────────────────────────────────── +// EmitContext — Code generation context +// ───────────────────────────────────────────────────────────────────────────── + +export interface EmitContext { + /** Register a RegExp in the reference array, return its index */ + addRegex(re: RegExp): number; + /** Register in the reference array, return its index — functions, arrays, Sets, primitives, etc. */ + addRef(value: unknown): number; + /** Register a SealedExecutors object in the reference array — for nested @Type DTOs */ + addExecutor(executor: SealedExecutors): number; + /** Generate a failure code string from an error code — path is bound by the builder */ + fail(code: string): string; + /** Whether error collection mode is enabled (= !stopAtFirstError) */ + collectErrors: boolean; + /** Whether this emit runs inside a type gate (typeof/instanceof already verified) */ + insideTypeGate?: boolean; + /** @internal Path expression for inline nested — used by makeRuleEmitCtx */ + pathExpr?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// EmittableRule — Validation function + .emit() +// ───────────────────────────────────────────────────────────────────────────── + +export interface EmittableRule { + (value: unknown): boolean | Promise; + emit(varName: string, ctx: EmitContext): string; + readonly ruleName: string; + /** + * Meta for the builder to determine whether to insert a typeof guard. + * Only set for rules that assume a specific type (e.g., isEmail → 'string'). + * `@IsString` itself is undefined (it includes its own typeof check). + */ + readonly requiresType?: RequiredType; + /** Expose rule parameters for external reading */ + readonly constraints?: Record; + /** true when the rule is explicitly async and must be awaited */ + readonly isAsync?: boolean; +} + +/** @internal internal rule shape used by builders for optimization metadata */ +export interface InternalRule extends EmittableRule { + readonly plan?: RulePlan; +} + +export interface RulePlan { + cacheKey?: CacheKey; + failure: RulePlanCheck; +} diff --git a/src/rules/locales.spec.ts b/src/rules/locales.spec.ts index 3309280..35bbfc3 100644 --- a/src/rules/locales.spec.ts +++ b/src/rules/locales.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; +import { RequiredType } from './enums'; import { isMobilePhone, isPostalCode, isIdentityCard, isPassportNumber } from './locales'; function makeCtx(refIndex: number = 0) { diff --git a/src/rules/locales.ts b/src/rules/locales.ts index 56f509a..474f873 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -1,8 +1,9 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { RequiredType } from '../enums'; -import { BakerError } from '../errors'; -import { makeRule } from '../rule-plan'; +import { BakerError } from '../common'; +import { MOBILE_PHONE_REGEXES, POSTAL_CODE_REGEXES, IDENTITY_CARD_REGEXES, PASSPORT_REGEXES } from './constants'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // Locale-specific Validators @@ -10,239 +11,24 @@ import { makeRule } from '../rule-plan'; // ─── isMobilePhone ──────────────────────────────────────────────────────────── -const MOBILE_PHONE_REGEXES: Record = { - 'ko-KR': /^(\+?82|0)1[016789]\d{7,8}$/, - 'en-US': /^\+?1?[2-9]\d{2}[2-9]\d{6}$/, - 'zh-CN': /^(\+?86)?1[3-9]\d{9}$/, - 'zh-TW': /^(\+?886)?9\d{8}$/, - 'ja-JP': /^(\+?81)?0?[789]0[0-9]{8}$/, - 'de-DE': /^(\+?49)?1(5\d|6[0-9]|7[0-9])\d{8}$/, - 'fr-FR': /^(\+?33)?[67]\d{8}$/, - 'en-GB': /^(\+?44)?7[1-9]\d{8}$/, - 'ru-RU': /^(\+?7)?9\d{9}$/, - 'pt-BR': /^(\+?55)?[1-9]{2}9?\d{8}$/, - 'in-IN': /^(\+?91)?[6-9]\d{9}$/, - 'ar-SA': /^(\+?966)?5\d{8}$/, - 'ar-EG': /^(\+?20)?1[0125]\d{8}$/, - 'vi-VN': /^(\+?84)?[35789]\d{8}$/, - 'th-TH': /^(\+?66)?[689]\d{8}$/, - 'id-ID': /^(\+?62)?8\d{9,11}$/, - 'ms-MY': /^(\+?60)?1\d{8,9}$/, - 'nl-NL': /^(\+?31)?6\d{8}$/, - 'it-IT': /^(\+?39)?3\d{9}$/, - 'es-ES': /^(\+?34)?[67]\d{8}$/, - 'pl-PL': /^(\+?48)?[45789]\d{8}$/, -}; - function isMobilePhone(locale: string): EmittableRule { return makeLocaleRegexRule('isMobilePhone', locale, MOBILE_PHONE_REGEXES); } // ─── isPostalCode ───────────────────────────────────────────────────────────── -const POSTAL_CODE_REGEXES: Record = { - AD: /^AD\d{3}$/, - AT: /^\d{4}$/, - AU: /^\d{4}$/, - AZ: /^\d{4}$/, - BE: /^\d{4}$/, - BG: /^\d{4}$/, - BR: /^\d{5}-?\d{3}$/, - BY: /^\d{6}$/, - CA: /^[ABCEGHJKLMNPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$/i, - CH: /^\d{4}$/, - CN: /^\d{6}$/, - CZ: /^\d{3} ?\d{2}$/, - DE: /^\d{5}$/, - DK: /^\d{4}$/, - EE: /^\d{5}$/, - ES: /^\d{5}$/, - FI: /^\d{5}$/, - FR: /^\d{2} ?\d{3}$/, - GB: /^(GIR ?0AA|[A-PR-UWYZ]([0-9]{1,2}|([A-HK-Y][0-9]([0-9ABEHMNPRV-Y])?)|[0-9][A-HJKPSTUW]) ?[0-9][ABD-HJLNP-UW-Z]{2})$/i, - GR: /^\d{3} ?\d{2}$/, - HR: /^\d{5}$/, - HU: /^\d{4}$/, - ID: /^\d{5}$/, - IL: /^\d{5}(\d{2})?$/, - IN: /^\d{6}$/, - IS: /^\d{3}$/, - IT: /^\d{5}$/, - JP: /^\d{3}-?\d{4}$/, - KR: /^\d{5}$/, - LI: /^(948[5-9]|949[0-7])$/, - LT: /^LT-\d{5}$/, - LU: /^\d{4}$/, - LV: /^LV-\d{4}$/, - MX: /^\d{5}$/, - MT: /^[A-Z]{3} ?\d{4}$/i, - MZ: /^\d{4}$/, - NL: /^\d{4} ?[A-Z]{2}$/i, - NO: /^\d{4}$/, - NP: /^\d{5}$/, - NZ: /^\d{4}$/, - PH: /^\d{4}$/, - PK: /^\d{5}$/, - PL: /^\d{2}-\d{3}$/, - PR: /^009\d{2}([ -]\d{4})?$/, - PT: /^\d{4}-\d{3}$/, - RO: /^\d{6}$/, - RU: /^\d{6}$/, - SE: /^\d{3} ?\d{2}$/, - SG: /^\d{6}$/, - SI: /^\d{4}$/, - SK: /^\d{3} ?\d{2}$/, - TH: /^\d{5}$/, - TN: /^\d{4}$/, - TW: /^\d{3}(\d{2})?$/, - UA: /^\d{5}$/, - US: /^\d{5}(-\d{4})?$/, - ZA: /^\d{4}$/, - ZM: /^\d{5}$/, -}; - function isPostalCode(locale: string): EmittableRule { return makeLocaleRegexRule('isPostalCode', locale, POSTAL_CODE_REGEXES); } // ─── isIdentityCard ─────────────────────────────────────────────────────────── -const IDENTITY_CARD_REGEXES: Record = { - AF: /^\d{8}$/, - AL: /^[A-Z]\d{8}[A-Z]$/i, - AR: /^\d{7,8}$/, - AZ: /^AZE\d{8}$/, - BE: /^\d{11}$/, - BG: /^\d{10}$/, - BR: /^\d{9}$/, - BY: /^[A-Z]{2}\d{7}$/i, - CA: /^\d{9}$/, - CH: /^756\d{10}$/, - CN: /^\d{15}(\d{2}[0-9xX])?$/, - CY: /^\d{7}[A-Z]$/i, - CZ: /^\d{9,10}$/, - DE: /^[LITOUAEVBMNPRSZDFGHCK]{9}$/i, - DK: /^\d{10}$/, - EE: /^\d{11}$/, - ES: /^[0-9X-Z]\d{7}[TRWAGMYFPDXBNJZSQVHLCKE]$/i, - FI: /^\d{6}[+-A]\d{3}[0-9A-FHJ-NPR-Y]$/, - FR: /^\d{8,9}[0-9Á-ÿ]{1}$/i, - GB: /^[A-Z]{2}\d{6}[A-Z]$/i, - GR: /^[A-Z]{2}\d{6}$/i, - HR: /^\d{11}$/, - HU: /^\d{8}[A-Z]{2}$/i, - ID: /^\d{16}$/, - IE: /^\d{7}[A-W][A-W]?$/, - IL: /^\d{9}$/, - IN: /^\d{12}$/, - IR: /^\d{10}$/, - IS: /^\d{10}$/, - IT: /^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/i, - JP: /^\d{12}$/, - KR: /^\d{6}-\d{7}$/, - LT: /^\d{11}$/, - LU: /^\d{13}$/, - LV: /^\d{6}-\d{5}$/, - MK: /^\d{13}$/, - MX: /^[A-Z]{4}\d{6}[HM][A-Z]{2}[B-DF-HJ-NP-TV-Z]{3}[A-Z0-9]\d$/i, - MT: /^\d{7}[A-Z]$/i, - NL: /^\d{9}$/, - NO: /^\d{11}$/, - PL: /^\d{11}$/, - PT: /^[1-9]\d{7}[0-9TV]$/i, - RO: /^\d{13}$/, - RS: /^\d{13}$/, - RU: /^\d{10}$/, - SE: /^\d{10,12}$/, - SI: /^\d{13}$/, - SK: /^\d{9,10}$/, - TH: /^\d{13}$/, - TR: /^\d{11}$/, - TW: /^[A-Z]\d{9}$/i, - UA: /^\d{9}$/, - US: /^\d{3}-\d{2}-\d{4}$/, - ZA: /^\d{13}$/, -}; - function isIdentityCard(locale: string): EmittableRule { return makeLocaleRegexRule('isIdentityCard', locale, IDENTITY_CARD_REGEXES); } // ─── isPassportNumber ───────────────────────────────────────────────────────── -const PASSPORT_REGEXES: Record = { - AM: /^[A-Z]{2}\d{7}$/i, - AR: /^[A-Z]{3}\d{6}$/i, - AT: /^[A-Z]\d{7}$/i, - AU: /^[A-Z]\d{7}$/i, - AZ: /^[Aa]\d{8}$/, - BE: /^[A-Z]{2}\d{6}$/i, - BG: /^\d{9}$/, - BH: /^[A-Z]{2}\d{6}$/i, - BR: /^[A-Z]{2}\d{6}$/i, - BY: /^[A-Z]{2}\d{7}$/i, - CA: /^[A-Z]{2}\d{6}$/i, - CH: /^[A-Z]\d{7}$/i, - CN: /^G\d{8}$/, - CY: /^[A-Z](\d{6}|\d{8})$/i, - CZ: /^\d{8}$/, - DE: /^[CFGHJKLMNPRTVWXYZ0-9]{9}$/i, - DK: /^\d{9}$/, - EE: /^([A-Z]\d{7}|[A-Z]{2}\d{7})$/i, - ES: /^[A-Z0-9]{2}([A-Z0-9]?)\d{6}$/i, - FI: /^[A-Z]{2}\d{7}$/i, - FR: /^[A-Z0-9]{9}$/i, - GB: /^\d{9}$/, - GR: /^[A-Z]{2}\d{7}$/i, - HR: /^\d{9}$/, - HU: /^[A-Z]{2}(\d{6}|\d{7})$/i, - ID: /^[A-C]\d{7}$/i, - IE: /^[A-Z0-9]{2}\d{7}$/i, - IL: /^\d{9}$/, - IN: /^[A-Z]\d{7}$/i, - IR: /^[A-Z]\d{8}$/i, - IS: /^(A)\d{7}$/i, - IT: /^[A-Z0-9]{9}$/i, - JO: /^[A-Z]{2}\d{7}$/i, - JP: /^[A-Z]{2}\d{7}$/i, - KR: /^[A-Z][A-Z0-9]\d{7}$/i, - KW: /^\d{8}$/, - KZ: /^[A-Z]\d{8}$/i, - LI: /^[A-Z]\d{6}X$/i, - LT: /^[A-Z0-9]{8}$/i, - LU: /^[A-Z0-9]{8}$/i, - LV: /^[A-Z0-9]{2}\d{7}$/i, - LY: /^[A-Z]{2}\d{7}$/i, - MA: /^[A-Z0-9]{2}\d{7}$/i, - MD: /^[A-Z]{2}\d{7}$/i, - ME: /^[A-Z]{2}\d{7}$/i, - MK: /^[A-Z]\d{7}$/i, - MT: /^\d{7}$/, - MX: /^[A-Z]\d{8}$/i, - MY: /^[AHK]\d{8}[A-Z]$/i, - NL: /^[A-NP-Z]{2}[A-NP-Z0-9]{6}\d$/i, - NO: /^\d{9}$/, - NZ: /^[A-Z]{2}\d{6}$/i, - PH: /^[A-Z]\d{7}[A-Z]$/i, - PK: /^[A-Z]{2}\d{7}$/i, - PL: /^[A-Z]{2}\d{7}$/i, - PT: /^[A-Z]\d{6}$/i, - RO: /^\d{8}$/, - RS: /^\d{9}$/, - RU: /^\d{9}$/, - SA: /^[A-Z]\d{8}$/i, - SE: /^\d{8}$/, - SL: /^(P)[A-Z]\d{7}$/i, - SK: /^[0-9A-Z]\d{7}$/i, - TH: /^[A-Z]{1,2}\d{6,7}$/i, - TN: /^\d{8}$/, - TR: /^[A-Z]\d{8}$/i, - TW: /^[A-Z]\d{9}$/i, - UA: /^[A-Z]{2}\d{6}$/i, - US: /^\d{9}$/, - ZA: /^[A-Z]\d{8}$/i, -}; - function isPassportNumber(locale: string): EmittableRule { return makeLocaleRegexRule('isPassportNumber', locale, PASSPORT_REGEXES); } diff --git a/src/rules/number.spec.ts b/src/rules/number.spec.ts index 666372d..5db755e 100644 --- a/src/rules/number.spec.ts +++ b/src/rules/number.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; +import { RequiredType } from './enums'; import { min, max, isPositive, isNegative, isDivisibleBy } from './number'; function makeCtx(refIndex: number = 0) { diff --git a/src/rules/number.ts b/src/rules/number.ts index e95b1e9..42ea2bd 100644 --- a/src/rules/number.ts +++ b/src/rules/number.ts @@ -1,11 +1,11 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { RequiredType, RuleOp } from '../enums'; -import { BakerError } from '../errors'; -import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from '../rule-plan'; +import { BakerError } from '../common'; +import { RequiredType, RuleOp } from './enums'; +import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── -// min — v >= n check. requiresType='number' (§4.7, §4.8 A) +// min — v >= n check. requiresType='number' // ───────────────────────────────────────────────────────────────────────────── export function min(n: number, opts?: { exclusive?: boolean }): EmittableRule { @@ -29,7 +29,7 @@ export function min(n: number, opts?: { exclusive?: boolean }): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// max — v <= n check. requiresType='number' (§4.7, §4.8 A) +// max — v <= n check. requiresType='number' // ───────────────────────────────────────────────────────────────────────────── export function max(n: number, opts?: { exclusive?: boolean }): EmittableRule { @@ -53,7 +53,7 @@ export function max(n: number, opts?: { exclusive?: boolean }): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// isPositive — v > 0 (0 not included). requiresType='number' (§4.8 A) +// isPositive — v > 0 (0 not included). requiresType='number' // ───────────────────────────────────────────────────────────────────────────── export const isPositive = makePlannedRule({ @@ -67,7 +67,7 @@ export const isPositive = makePlannedRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isNegative — v < 0 (0 not included). requiresType='number' (§4.8 A) +// isNegative — v < 0 (0 not included). requiresType='number' // ───────────────────────────────────────────────────────────────────────────── export const isNegative = makePlannedRule({ @@ -81,7 +81,7 @@ export const isNegative = makePlannedRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isDivisibleBy — v % n === 0 check. requiresType='number' (§4.8 A) +// isDivisibleBy — v % n === 0 check. requiresType='number' // ───────────────────────────────────────────────────────────────────────────── export function isDivisibleBy(n: number): EmittableRule { diff --git a/src/rules/object.spec.ts b/src/rules/object.spec.ts index 67ce708..5e088dd 100644 --- a/src/rules/object.spec.ts +++ b/src/rules/object.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; import { isNotEmptyObject, isInstance } from './object'; diff --git a/src/rules/object.ts b/src/rules/object.ts index 923bb7e..ac95dac 100644 --- a/src/rules/object.ts +++ b/src/rules/object.ts @@ -1,7 +1,7 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { RequiredType } from '../enums'; -import { makeRule } from '../rule-plan'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // isNotEmptyObject(options?) — not an empty object (at least 1 key) @@ -35,7 +35,7 @@ export function isNotEmptyObject(options?: IsNotEmptyObjectOptions): EmittableRu return makeRule({ name: 'isNotEmptyObject', requiresType: RequiredType.Object, - constraints: { nullable: options?.nullable }, + constraints: options?.nullable !== undefined ? { nullable: options.nullable } : {}, validate, // Codegen: for-in with break — measured ~1 ns faster than Object.keys allocation // (Bun 1.3.13 / i7-13700K). The generated body is not subject to source-lint rules. diff --git a/src/rules/public.ts b/src/rules/public.ts new file mode 100644 index 0000000..89778f7 --- /dev/null +++ b/src/rules/public.ts @@ -0,0 +1,111 @@ +export { createRule } from './create-rule'; +export { + isString, + isNumber, + isBoolean, + isDate, + isEnum, + isInt, + isArray, + isObject, + isRegExp, + isFunction, + isStatelessRegExp, +} from './typechecker'; +export type { IsNumberOptions } from './typechecker'; +export { oneOf, arrayEvery } from './combinators'; +export { min, max, isPositive, isNegative, isDivisibleBy } from './number'; +export { minDate, maxDate } from './date'; +export { equals, notEquals, isEmpty, isNotEmpty, isIn, isNotIn } from './common'; +export { + minLength, + maxLength, + length, + contains, + notContains, + matches, + isLowercase, + isUppercase, + isAscii, + isAlpha, + isAlphanumeric, + isHttpToken, + isOrigin, + isCorsOrigin, + isBooleanString, + isNumberString, + isDecimal, + isFullWidth, + isHalfWidth, + isVariableWidth, + isMultibyte, + isSurrogatePair, + isHexadecimal, + isOctal, + isEmail, + isURL, + isUUID, + isIP, + isHexColor, + isRgbColor, + isHSL, + isMACAddress, + isISBN, + isISIN, + isISO8601, + isISRC, + isISSN, + isJWT, + isLatLong, + isLocale, + isDataURI, + isFQDN, + isPort, + isEAN, + isISO31661Alpha2, + isISO31661Alpha3, + isBIC, + isFirebasePushId, + isSemVer, + isMongoId, + isJSON, + isBase32, + isBase58, + isBase64, + isDateString, + isMimeType, + isCurrency, + isMagnetURI, + isCreditCard, + isIBAN, + isByteLength, + isHash, + isRFC3339, + isMilitaryTime, + isLatitude, + isLongitude, + isEthereumAddress, + isBtcAddress, + isISO4217CurrencyCode, + isPhoneNumber, + isStrongPassword, + isTaxId, + isULID, + isCUID2, +} from './string'; +export type { + IsURLOptions, + IsBase64Options, + IsMACAddressOptions, + IsIBANOptions, + IsISSNOptions, + IsFQDNOptions, + IsISO8601Options, + IsNumberStringOptions, + IsStrongPasswordOptions, +} from './string'; +export { arrayContains, arrayNotContains, arrayMinSize, arrayMaxSize, arrayUnique, arrayNotEmpty } from './array'; +export { isNotEmptyObject, isInstance } from './object'; +export type { IsNotEmptyObjectOptions } from './object'; +export { isMobilePhone, isPostalCode, isIdentityCard, isPassportNumber } from './locales'; +export { isUint8Array, isByteSize } from './binary'; diff --git a/src/rule-metadata.ts b/src/rules/rule-metadata.ts similarity index 90% rename from src/rule-metadata.ts rename to src/rules/rule-metadata.ts index 0d1f7c7..e8d7bca 100644 --- a/src/rule-metadata.ts +++ b/src/rules/rule-metadata.ts @@ -1,4 +1,4 @@ -import type { EmittableRule, InternalRule, RulePlan } from './types'; +import type { EmittableRule, InternalRule, RulePlan } from './interfaces'; // Type boundary — the single place that brands a bare validator function with // the readonly metadata properties declared on InternalRule. All other modules @@ -28,7 +28,7 @@ export function defineRuleMetadata(fn: InternalRule, meta: RuleMetadata): void { if (meta.isAsync !== undefined) { target.isAsync = meta.isAsync; } - if (meta.plan) { + if (meta.plan !== undefined) { target.plan = meta.plan; } } diff --git a/src/rule-plan.ts b/src/rules/rule-plan.ts similarity index 83% rename from src/rule-plan.ts rename to src/rules/rule-plan.ts index 2fd1698..d9dd193 100644 --- a/src/rule-plan.ts +++ b/src/rules/rule-plan.ts @@ -1,27 +1,15 @@ import type { RequiredType } from './enums'; -import type { EmitContext, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './types'; +import type { EmitContext, InternalRule, RulePlan } from './interfaces'; +import type { RulePlanCache, RulePlanCheck, RulePlanExpr } from './types'; import { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; import { defineRuleMetadata } from './rule-metadata'; -type RulePlanCache = { - length?: string; - time?: string; -}; - const planValue = (): RulePlanExpr => ({ kind: RulePlanExprKind.Value }); -const planLength = (object: RulePlanExpr = planValue()): RulePlanExpr => ({ - kind: RulePlanExprKind.Member, - object, - property: 'length', -}); +const planLength = (): RulePlanExpr => ({ kind: RulePlanExprKind.Member, property: 'length' }); -const planTime = (object: RulePlanExpr = planValue()): RulePlanExpr => ({ - kind: RulePlanExprKind.Call0, - object, - method: 'getTime', -}); +const planTime = (): RulePlanExpr => ({ kind: RulePlanExprKind.Call0, method: 'getTime' }); const planLiteral = (value: number): RulePlanExpr => ({ kind: RulePlanExprKind.Literal, value }); @@ -117,18 +105,17 @@ function isSelfComparison(check: RulePlanCheck): boolean { } function exprEqual(a: RulePlanExpr, b: RulePlanExpr): boolean { - if (a.kind !== b.kind) { - return false; - } + // Each `b.kind === …` check narrows `b` to the same member as `a` (no casts). Value/Member/Call0 + // carry no distinguishing data beyond `kind`, so kind-equality is full equality; only Literal compares a value. switch (a.kind) { case RulePlanExprKind.Value: - return true; - case RulePlanExprKind.Literal: - return a.value === (b as typeof a).value; + return b.kind === RulePlanExprKind.Value; case RulePlanExprKind.Member: - return exprEqual(a.object, (b as typeof a).object); + return b.kind === RulePlanExprKind.Member; case RulePlanExprKind.Call0: - return a.method === (b as typeof a).method && exprEqual(a.object, (b as typeof a).object); + return b.kind === RulePlanExprKind.Call0; + case RulePlanExprKind.Literal: + return b.kind === RulePlanExprKind.Literal && a.value === b.value; default: // Compile-time exhaustiveness: adding a RulePlanExpr.kind without a case fails to compile here. return a satisfies never; @@ -150,9 +137,9 @@ function emitPlanExpr(expr: RulePlanExpr, varName: string, cache?: RulePlanCache case RulePlanExprKind.Literal: return String(expr.value); case RulePlanExprKind.Member: - return cache?.length ?? `${emitPlanExpr(expr.object, varName, cache)}.length`; + return cache?.length ?? `${varName}.length`; case RulePlanExprKind.Call0: - return cache?.time ?? `${emitPlanExpr(expr.object, varName, cache)}.getTime()`; + return cache?.time ?? `${varName}.getTime()`; default: // Compile-time exhaustiveness: adding a RulePlanExpr.kind without a case fails to compile here. return expr satisfies never; diff --git a/src/rules/string-basic.spec.ts b/src/rules/string-basic.spec.ts new file mode 100644 index 0000000..c0cd0aa --- /dev/null +++ b/src/rules/string-basic.spec.ts @@ -0,0 +1,716 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './interfaces'; + +import { RequiredType } from './enums'; +import { + minLength, + maxLength, + length, + contains, + notContains, + matches, + isLowercase, + isUppercase, + isAscii, + isAlpha, + isAlphanumeric, + isBooleanString, + isNumberString, + isDecimal, + isHttpToken, + isOrigin, + isCorsOrigin, +} from './string'; + +function makeCtx(refIndex: number = 0) { + const addRefMock = mock((_fn: unknown) => refIndex); + const addRegexMock = mock((_re: RegExp) => refIndex); + const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); + const ctx: Partial = { + addRegex: addRegexMock, + addRef: addRefMock, + addExecutor: mock(() => 0), + fail: failMock, + collectErrors: true, + }; + return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; +} + +describe('minLength', () => { + it('should return true when string length equals minimum', () => { + const rule = minLength(3); + expect(rule('abc')).toBe(true); + }); + + it('should return true when string length exceeds minimum', () => { + const rule = minLength(3); + expect(rule('abcde')).toBe(true); + }); + + it('should return false when string length is less than minimum', () => { + const rule = minLength(3); + expect(rule('ab')).toBe(false); + }); + + it('should return true for empty string when minimum is 0', () => { + const rule = minLength(0); + expect(rule('')).toBe(true); + }); + + it('should generate v.length < n check code when calling emit()', () => { + const rule = minLength(3); + const { ctx, failMock } = makeCtx(); + const code = rule.emit('v', ctx); + expect(code).toContain('v.length < 3'); + expect(failMock).toHaveBeenCalledWith('minLength'); + }); + + it('should have ruleName minLength and requiresType string', () => { + const rule = minLength(3); + expect(rule.ruleName).toBe('minLength'); + expect(rule.requiresType).toBe(RequiredType.String); + }); + + it('should return independent rule objects on multiple factory calls', () => { + const r1 = minLength(3); + const r2 = minLength(3); + expect(r1).not.toBe(r2); + }); +}); + +describe('maxLength', () => { + it('should return true when string length is within maximum', () => { + const rule = maxLength(5); + expect(rule('abc')).toBe(true); + }); + + it('should return true when string length equals maximum', () => { + const rule = maxLength(5); + expect(rule('abcde')).toBe(true); + }); + + it('should return false when string length exceeds maximum', () => { + const rule = maxLength(5); + expect(rule('abcdef')).toBe(false); + }); + + it('should return true for empty string when maximum is 0', () => { + const rule = maxLength(0); + expect(rule('')).toBe(true); + }); + + it('should generate v.length > n check code when calling emit()', () => { + const rule = maxLength(5); + const { ctx, failMock } = makeCtx(); + const code = rule.emit('v', ctx); + expect(code).toContain('v.length > 5'); + expect(failMock).toHaveBeenCalledWith('maxLength'); + }); + + it('should have ruleName maxLength and requiresType string', () => { + const rule = maxLength(5); + expect(rule.ruleName).toBe('maxLength'); + expect(rule.requiresType).toBe(RequiredType.String); + }); +}); + +describe('length', () => { + it('should return true when string length is within range', () => { + const rule = length(3, 5); + expect(rule('abcd')).toBe(true); + }); + + it('should return true when string length equals minimum boundary', () => { + const rule = length(3, 5); + expect(rule('abc')).toBe(true); + }); + + it('should return true when string length equals maximum boundary', () => { + const rule = length(3, 5); + expect(rule('abcde')).toBe(true); + }); + + it('should return false when string length is below minimum', () => { + const rule = length(3, 5); + expect(rule('ab')).toBe(false); + }); + + it('should return false when string length exceeds maximum', () => { + const rule = length(3, 5); + expect(rule('abcdef')).toBe(false); + }); + + it('should return true for exact single length when min equals max', () => { + const rule = length(3, 3); + expect(rule('abc')).toBe(true); + }); + + it('should generate range check code when calling emit()', () => { + const rule = length(3, 5); + const { ctx, failMock } = makeCtx(); + const code = rule.emit('v', ctx); + expect(code).toContain('v.length < 3'); + expect(code).toContain('v.length > 5'); + expect(failMock).toHaveBeenCalledWith('length'); + }); + + it('should have ruleName length and requiresType string', () => { + const rule = length(3, 5); + expect(rule.ruleName).toBe('length'); + expect(rule.requiresType).toBe(RequiredType.String); + }); +}); + +describe('contains', () => { + it('should return true when string contains seed', () => { + const rule = contains('foo'); + expect(rule('foobar')).toBe(true); + }); + + it('should return false when string does not contain seed', () => { + const rule = contains('foo'); + expect(rule('barbaz')).toBe(false); + }); + + it('should call ctx.addRef with seed and generate includes check when calling emit()', () => { + const rule = contains('foo'); + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = rule.emit('v', ctx); + expect(addRefMock).toHaveBeenCalledTimes(1); + expect(addRefMock).toHaveBeenCalledWith('foo'); + expect(code).toContain('refs[0]'); + expect(failMock).toHaveBeenCalledWith('contains'); + }); + + it('should have ruleName contains and requiresType string', () => { + const rule = contains('foo'); + expect(rule.ruleName).toBe('contains'); + expect(rule.requiresType).toBe(RequiredType.String); + }); +}); + +describe('notContains', () => { + it('should return true when string does not contain seed', () => { + const rule = notContains('foo'); + expect(rule('barbaz')).toBe(true); + }); + + it('should return false when string contains seed', () => { + const rule = notContains('foo'); + expect(rule('foobar')).toBe(false); + }); + + it('should call ctx.addRef with seed and generate inverse includes check when calling emit()', () => { + const rule = notContains('foo'); + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = rule.emit('v', ctx); + expect(addRefMock).toHaveBeenCalledTimes(1); + expect(code).toContain('refs[0]'); + expect(failMock).toHaveBeenCalledWith('notContains'); + }); + + it('should have ruleName notContains', () => { + const rule = notContains('foo'); + expect(rule.ruleName).toBe('notContains'); + }); +}); + +describe('matches', () => { + it('should return true when string matches pattern', () => { + const rule = matches(/^[a-z]+$/); + expect(rule('hello')).toBe(true); + }); + + it('should return false when string does not match pattern', () => { + const rule = matches(/^[a-z]+$/); + expect(rule('Hello123')).toBe(false); + }); + + it('should support string pattern with modifiers', () => { + const rule = matches('^[a-z]+$', 'i'); + expect(rule('HELLO')).toBe(true); + }); + + it('should call ctx.addRegex and generate test check code when calling emit()', () => { + const rule = matches(/^[a-z]+$/); + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = rule.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toContain('re[0]'); + expect(code).toContain('.test('); + expect(failMock).toHaveBeenCalledWith('matches'); + }); + + it('should have ruleName matches and requiresType string', () => { + const rule = matches(/^[a-z]+$/); + expect(rule.ruleName).toBe('matches'); + expect(rule.requiresType).toBe(RequiredType.String); + }); + + it('should return false for empty string when pattern requires content', () => { + const rule = matches(/^[a-z]+$/); + expect(rule('')).toBe(false); + }); + + it('should be stateless across calls when the pattern carries a global flag', () => { + const rule = matches(/^[a-z]+$/g); + expect(rule('abc')).toBe(true); + // A stateful (g-flagged) regex advances lastIndex, so the second identical value would wrongly fail. + expect(rule('abc')).toBe(true); + expect(rule('abc')).toBe(true); + }); + + it('should be stateless across calls when the pattern carries a sticky flag', () => { + const rule = matches(/^[a-z]+$/y); + expect(rule('abc')).toBe(true); + expect(rule('abc')).toBe(true); + }); + + it('should be stateless when the global flag comes from the string-and-modifiers form', () => { + const rule = matches('^[a-z]+$', 'gi'); + expect(rule('ABC')).toBe(true); + expect(rule('ABC')).toBe(true); + }); + + it('should preserve non-stateful flags such as case-insensitive', () => { + const rule = matches(/^[a-z]+$/i); + expect(rule('ABC')).toBe(true); + }); +}); + +// ─── Group B: Simple Boolean Checks ────────────────────────────────────────── + +describe('isLowercase', () => { + it('should return true for all lowercase string', () => { + expect(isLowercase('hello world')).toBe(true); + }); + + it('should return false when string contains uppercase character', () => { + expect(isLowercase('Hello')).toBe(false); + }); + + it('should generate toLowerCase comparison code when calling emit() and have ruleName isLowercase', () => { + const { ctx, failMock } = makeCtx(); + const code = isLowercase.emit('v', ctx); + expect(code).toContain('toLowerCase'); + expect(failMock).toHaveBeenCalledWith('isLowercase'); + expect(isLowercase.ruleName).toBe('isLowercase'); + expect(isLowercase.requiresType).toBe(RequiredType.String); + }); + + it('should return true for empty string', () => { + expect(isLowercase('')).toBe(true); + }); +}); + +describe('isUppercase', () => { + it('should return true for all uppercase string', () => { + expect(isUppercase('HELLO WORLD')).toBe(true); + }); + + it('should return false when string contains lowercase character', () => { + expect(isUppercase('Hello')).toBe(false); + }); + + it('should generate toUpperCase comparison code when calling emit() and have ruleName isUppercase', () => { + const { ctx, failMock } = makeCtx(); + const code = isUppercase.emit('v', ctx); + expect(code).toContain('toUpperCase'); + expect(failMock).toHaveBeenCalledWith('isUppercase'); + expect(isUppercase.ruleName).toBe('isUppercase'); + }); + + it('should return true for empty string', () => { + expect(isUppercase('')).toBe(true); + }); +}); + +describe('isAscii', () => { + it('should return true for ASCII-only string', () => { + expect(isAscii('Hello World! 123')).toBe(true); + }); + + it('should return false when string contains non-ASCII character', () => { + expect(isAscii('café')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isAscii', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isAscii.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toContain('re[0]'); + expect(failMock).toHaveBeenCalledWith('isAscii'); + expect(isAscii.ruleName).toBe('isAscii'); + }); + + it('should return true for empty string', () => { + expect(isAscii('')).toBe(true); + }); +}); + +describe('isHttpToken', () => { + // RFC 9110 §5.6.2 token = 1*tchar + it('should return true for valid tokens (methods, field-names, tchar-only)', () => { + for (const v of [ + 'GET', + 'POST', + 'X-Foo', + 'X-Custom-Header', + 'Content-Type', + 'PROPFIND', + 'MKCALENDAR', + 'M-SEARCH', + 'foo.bar', + '!#$%&', + "!#$%&'*+-.^_`|~", + 'a`b', + ]) { + expect(isHttpToken(v)).toBe(true); + } + }); + + it('should return false for non-tokens (separators, spaces, CTL, non-ASCII)', () => { + for (const v of [ + '', + ' ', + 'X Foo', + 'X-Foo(bar)', + 'X-Foo:Bar', + 'X-Foo,Bar', + 'X-Foo;', + 'X-Foo<>', + 'X-Foo\t', + 'X-Foo\n', + 'X-Foo\r', + 'GET\n', + '\nGET', + 'X-한글', + ]) { + expect(isHttpToken(v)).toBe(false); + } + }); + + it('should generate regex test code when calling emit() and have ruleName isHttpToken', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isHttpToken.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toContain('re[0]'); + expect(failMock).toHaveBeenCalledWith('isHttpToken'); + expect(isHttpToken.ruleName).toBe('isHttpToken'); + }); +}); + +describe('isOrigin', () => { + // RFC 6454 §6.2 serialized origin — WHATWG URL `.origin` byte-equality. + it('should return true for canonical serialized origins and the opaque "null" literal', () => { + for (const v of [ + 'https://a.com', + 'https://a.com:8080', + 'http://localhost', + 'http://localhost:3000', + 'https://[::1]', + 'https://[::1]:8443', + 'https://xn--bj0bj06e.com', // punycode IDN + 'ws://a.com', // WebSocket origin (RFC 6455 §10.2) — tuple origin + 'wss://a.com:8443', + 'null', // RFC 6454 §6.2 opaque origin literal (does not parse via URL) + ]) { + expect(isOrigin(v)).toBe(true); + } + }); + + it('should return false for non-canonical forms, parse failures, and the CORS wildcard', () => { + for (const v of [ + '', + ' ', + 'https://a.com/', // trailing slash + 'https://a.com/path', // path + 'https://a.com?q=1', // query + 'https://a.com#h', // fragment + 'HTTPS://A.COM', // uppercase scheme + host + 'https://A.com', // mixed-case host + 'https://a.com:443', // explicit default port (https) + 'http://a.com:80', // explicit default port (http) + 'http://[::1]:80', // IPv6 explicit default port + 'https://user:pass@a.com', // userinfo — URL.origin strips credentials + 'https://user@a.com', // userinfo (user only) + ' https://a.com', // leading whitespace — URL trims, byte-mismatch + 'https://한글.com', // raw IDN unicode (punycode required) + 'not-a-url', // parse failure + 'file:///x', // opaque scheme → URL.origin === 'null' + 'data:text/plain,foo', // opaque scheme → URL.origin === 'null' + 'blob:https://a.com/uuid', // blob → URL.origin === 'https://a.com' ≠ input + '*', // CORS wildcard — rejected by general isOrigin + ]) { + expect(isOrigin(v)).toBe(false); + } + }); + + it('should return false for non-string input', () => { + expect(isOrigin(42 as unknown as string)).toBe(false); + expect(isOrigin(null as unknown as string)).toBe(false); + expect(isOrigin(undefined as unknown as string)).toBe(false); + }); + + it('should generate a refs[] predicate call when calling emit() and have ruleName isOrigin', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = isOrigin.emit('v', ctx); + expect(addRefMock).toHaveBeenCalledTimes(1); + expect(addRefMock.mock.calls[0]?.[0]).toBeInstanceOf(Function); + expect(code).toContain('refs[0](v)'); // must actually call the predicate, not just reference it + expect(failMock).toHaveBeenCalledWith('isOrigin'); + expect(isOrigin.ruleName).toBe('isOrigin'); + expect(isOrigin.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isCorsOrigin', () => { + // CORS-only superset of isOrigin: additionally accepts the '*' wildcard literal. + it('should return true for everything isOrigin accepts plus the "*" wildcard', () => { + for (const v of [ + 'https://a.com', + 'https://a.com:8080', // non-default port + 'http://localhost', + 'https://[::1]', + 'https://xn--bj0bj06e.com', + 'ws://a.com', // superset of isOrigin — WebSocket origin + 'wss://a.com:8443', + 'null', + '*', // CORS wildcard literal + ]) { + expect(isCorsOrigin(v)).toBe(true); + } + }); + + it('should return false for non-canonical forms and parse failures', () => { + for (const v of [ + '', + ' ', + 'https://a.com/', + 'HTTPS://A.COM', + 'https://a.com:443', + 'https://user:pass@a.com', // userinfo stripped → byte-mismatch + 'https://한글.com', + 'not-a-url', + 'file:///x', + '**', // not the bare wildcard + ]) { + expect(isCorsOrigin(v)).toBe(false); + } + }); + + it('should return false for non-string input', () => { + expect(isCorsOrigin(42 as unknown as string)).toBe(false); + expect(isCorsOrigin(null as unknown as string)).toBe(false); + }); + + it('should generate a refs[] predicate call when calling emit() and have ruleName isCorsOrigin', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = isCorsOrigin.emit('v', ctx); + expect(addRefMock).toHaveBeenCalledTimes(1); + expect(addRefMock.mock.calls[0]?.[0]).toBeInstanceOf(Function); + expect(code).toContain('refs[0](v)'); // must actually call the predicate, not just reference it + expect(failMock).toHaveBeenCalledWith('isCorsOrigin'); + expect(isCorsOrigin.ruleName).toBe('isCorsOrigin'); + expect(isCorsOrigin.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isAlpha', () => { + it('should return true for alphabetic-only string with default locale', () => { + expect(isAlpha('HelloWorld')).toBe(true); + }); + + it('should return false when string contains digit', () => { + expect(isAlpha('Hello1')).toBe(false); + }); + + it('should return false when string contains space', () => { + expect(isAlpha('Hello World')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isAlpha', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isAlpha.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toContain('re[0]'); + expect(failMock).toHaveBeenCalledWith('isAlpha'); + expect(isAlpha.ruleName).toBe('isAlpha'); + }); +}); + +describe('isAlphanumeric', () => { + it('should return true for alphanumeric string with default locale', () => { + expect(isAlphanumeric('Hello123')).toBe(true); + }); + + it('should return false when string contains special character', () => { + expect(isAlphanumeric('Hello!')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isAlphanumeric', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isAlphanumeric.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isAlphanumeric'); + expect(isAlphanumeric.ruleName).toBe('isAlphanumeric'); + }); + + it('should return false for empty string', () => { + expect(isAlphanumeric('')).toBe(false); + }); +}); + +describe('isBooleanString', () => { + it('should return true for "true"', () => { + expect(isBooleanString('true')).toBe(true); + }); + + it('should return true for "false"', () => { + expect(isBooleanString('false')).toBe(true); + }); + + it('should return true for "1"', () => { + expect(isBooleanString('1')).toBe(true); + }); + + it('should return true for "0"', () => { + expect(isBooleanString('0')).toBe(true); + }); + + it('should return false for arbitrary string', () => { + expect(isBooleanString('yes')).toBe(false); + }); + + it('should generate inline boolean check code when calling emit() and have ruleName isBooleanString', () => { + const { ctx, failMock } = makeCtx(); + const code = isBooleanString.emit('v', ctx); + expect(code).toContain('true'); + expect(code).toContain('false'); + expect(failMock).toHaveBeenCalledWith('isBooleanString'); + expect(isBooleanString.ruleName).toBe('isBooleanString'); + }); +}); + +describe('isNumberString', () => { + it('should return true for integer string', () => { + expect(isNumberString()('42')).toBe(true); + }); + + it('should return true for decimal string', () => { + expect(isNumberString()('3.14')).toBe(true); + }); + + it('should return false for non-numeric string', () => { + expect(isNumberString()('hello')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isNumberString()('')).toBe(false); + }); + + it('should return false for whitespace-only string', () => { + expect(isNumberString()(' ')).toBe(false); + }); + + it('should return false for a hex literal string', () => { + expect(isNumberString()('0x1A')).toBe(false); + }); + + it('should return false for a numeric value padded with whitespace', () => { + expect(isNumberString()(' 12 ')).toBe(false); + }); + + it('should return false for scientific notation', () => { + expect(isNumberString()('1e5')).toBe(false); + }); + + it('should return true for a leading-dot decimal', () => { + expect(isNumberString()('.5')).toBe(true); + }); + + it('should return false for a trailing-dot number', () => { + expect(isNumberString()('5.')).toBe(false); + }); + + it('should generate number check code when calling emit() and have ruleName isNumberString', () => { + const { ctx, failMock } = makeCtx(); + const code = isNumberString().emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isNumberString'); + expect(isNumberString().ruleName).toBe('isNumberString'); + }); + + it('should emit a regex test (not Number coercion)', () => { + const { ctx } = makeCtx(); + const code = isNumberString().emit('v', ctx); + expect(code).toContain('re['); + expect(code).not.toContain('Number('); + expect(code).not.toContain('isFinite'); + }); +}); + +describe('isNumberString — noSymbols option', () => { + it('should reject "+123" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('+123')).toBe(false); + }); + + it('should reject "-456" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('-456')).toBe(false); + }); + + it('should reject "1.5" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('1.5')).toBe(false); + }); + + it('should reject "1e5" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('1e5')).toBe(false); + }); + + it('should accept "123" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('123')).toBe(true); + }); + + it('should accept "0" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('0')).toBe(true); + }); + + it('should accept "+123" when noSymbols is false (default)', () => { + expect(isNumberString({ noSymbols: false })('+123')).toBe(true); + }); + + it('should accept "+123" when no options provided', () => { + expect(isNumberString()('+123')).toBe(true); + }); +}); + +describe('isDecimal', () => { + it('should return true for decimal number string', () => { + expect(isDecimal()('1.5')).toBe(true); + }); + + it('should return true for integer string (no decimal required)', () => { + expect(isDecimal()('42')).toBe(true); + }); + + it('should return false for non-numeric string', () => { + expect(isDecimal()('hello')).toBe(false); + }); + + it('should return false for a trailing-dot number', () => { + expect(isDecimal()('5.')).toBe(false); + }); + + it('should return true for a leading-dot decimal', () => { + expect(isDecimal()('.5')).toBe(true); + }); + + it('should generate regex check code when calling emit() and have ruleName isDecimal', () => { + const { ctx, failMock } = makeCtx(0); + const code = isDecimal().emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isDecimal'); + expect(isDecimal().ruleName).toBe('isDecimal'); + }); +}); diff --git a/src/rules/string-basic.ts b/src/rules/string-basic.ts new file mode 100644 index 0000000..00efd2c --- /dev/null +++ b/src/rules/string-basic.ts @@ -0,0 +1,267 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { CacheKey } from '../common'; +import { RequiredType, RuleOp } from './enums'; +import { makePlannedRule, makeRule, planCompare, planLength, planOr } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +// ───────────────────────────────────────────────────────────────────────────── +// Group A: Length / Range +// ───────────────────────────────────────────────────────────────────────────── + +function minLength(min: number): EmittableRule { + const plan = { cacheKey: CacheKey.Length, failure: planCompare(planLength(), RuleOp.Lt, min) } as const; + return makePlannedRule({ + name: 'minLength', + requiresType: RequiredType.String, + constraints: { min }, + plan, + validate: value => typeof value === 'string' && value.length >= min, + }); +} + +function maxLength(max: number): EmittableRule { + const plan = { cacheKey: CacheKey.Length, failure: planCompare(planLength(), RuleOp.Gt, max) } as const; + return makePlannedRule({ + name: 'maxLength', + requiresType: RequiredType.String, + constraints: { max }, + plan, + validate: value => typeof value === 'string' && value.length <= max, + }); +} + +function length(minLen: number, maxLen: number): EmittableRule { + const plan = { + cacheKey: CacheKey.Length, + failure: planOr(planCompare(planLength(), RuleOp.Lt, minLen), planCompare(planLength(), RuleOp.Gt, maxLen)), + } as const; + return makePlannedRule({ + name: 'length', + requiresType: RequiredType.String, + constraints: { min: minLen, max: maxLen }, + plan, + validate: value => typeof value === 'string' && value.length >= minLen && value.length <= maxLen, + }); +} + +function contains(seed: string): EmittableRule { + return makeRule({ + name: 'contains', + requiresType: RequiredType.String, + constraints: { seed }, + validate: value => typeof value === 'string' && value.includes(seed), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRef(seed); + return `if (!${varName}.includes(refs[${i}])) ${ctx.fail('contains')};`; + }, + }); +} + +function notContains(seed: string): EmittableRule { + return makeRule({ + name: 'notContains', + requiresType: RequiredType.String, + constraints: { seed }, + validate: value => typeof value === 'string' && !value.includes(seed), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRef(seed); + return `if (${varName}.includes(refs[${i}])) ${ctx.fail('notContains')};`; + }, + }); +} + +function matches(pattern: string | RegExp, modifiers?: string): EmittableRule { + // Strip stateful flags (g/y): the compiled regex is shared in the executor closure and reused for + // every value, so a stateful `lastIndex` would make `.test()` alternate true/false across calls. + // Rebuilding from source also detaches any externally-shared RegExp instance. + const source = pattern instanceof RegExp ? pattern.source : pattern; + const flags = (pattern instanceof RegExp ? pattern.flags : (modifiers ?? '')).replace(/[gy]/g, ''); + const re = new RegExp(source, flags); + return makeRule({ + name: 'matches', + requiresType: RequiredType.String, + constraints: { pattern: re.source }, + validate: value => typeof value === 'string' && re.test(value), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('matches')};`; + }, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Group B: Simple Boolean Checks +// ───────────────────────────────────────────────────────────────────────────── + +const isLowercase = makeRule({ + name: 'isLowercase', + requiresType: RequiredType.String, + constraints: {}, + validate: value => typeof value === 'string' && value === value.toLowerCase(), + emit: (varName: string, ctx: EmitContext): string => `if (${varName} !== ${varName}.toLowerCase()) ${ctx.fail('isLowercase')};`, +}); + +const isUppercase = makeRule({ + name: 'isUppercase', + requiresType: RequiredType.String, + constraints: {}, + validate: value => typeof value === 'string' && value === value.toUpperCase(), + emit: (varName: string, ctx: EmitContext): string => `if (${varName} !== ${varName}.toUpperCase()) ${ctx.fail('isUppercase')};`, +}); + +// ASCII: all code points in [0x00, 0x7F] +const ASCII_RE = new RegExp(`^[${String.fromCharCode(0)}-${String.fromCharCode(0x7f)}]*$`); +const isAscii = makeStringRule( + 'isAscii', + v => ASCII_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ASCII_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isAscii')};`; + }, +); + +// Alpha — [a-zA-Z]+ singleton +const ALPHA_DEFAULT_RE = /^[a-zA-Z]+$/; +// length > 0 guard is dead — `+` quantifier requires ≥1 char so the regex returns false on empty. +const isAlpha = makeStringRule( + 'isAlpha', + v => ALPHA_DEFAULT_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ALPHA_DEFAULT_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isAlpha')};`; + }, +); + +// Alphanumeric — [a-zA-Z0-9]+ singleton (same empty-input rationale as isAlpha) +const ALNUM_DEFAULT_RE = /^[a-zA-Z0-9]+$/; +const isAlphanumeric = makeStringRule( + 'isAlphanumeric', + v => ALNUM_DEFAULT_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ALNUM_DEFAULT_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isAlphanumeric')};`; + }, +); + +// HTTP token — RFC 9110 §5.6.2: token = 1*tchar. +// tchar = "!"/"#"/"$"/"%"/"&"/"'"/"*"/"+"/"-"/"."/"^"/"_"/"`"/"|"/"~" / DIGIT / ALPHA. +// Used for HTTP method names and header field-names (not field-values). The hyphen is +// escaped so it stays literal — an unescaped `+-.` would form a range that admits ",". +const HTTP_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const isHttpToken = makeStringRule( + 'isHttpToken', + v => HTTP_TOKEN_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(HTTP_TOKEN_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isHttpToken')};`; + }, +); + +// RFC 6454 §6.2 serialized origin — a string equal to WHATWG URL `.origin`. +// The opaque-origin literal 'null' is matched explicitly because `new URL('null')` throws. +// '*' (CORS wildcard) is rejected here; use isCorsOrigin for the CORS superset. +const isOriginValue = (value: string): boolean => { + if (value === 'null') { + return true; + } + try { + return new URL(value).origin === value; + } catch { + return false; + } +}; +const isOrigin = makeRule({ + name: 'isOrigin', + requiresType: RequiredType.String, + constraints: { format: 'origin' }, + validate: value => typeof value === 'string' && isOriginValue(value), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRef(isOriginValue); + return `if (!(refs[${i}](${varName}))) ${ctx.fail('isOrigin')};`; + }, +}); + +// CORS superset of isOrigin: additionally accepts the '*' wildcard literal +// (Access-Control-Allow-Origin). Keep '*' out of the general isOrigin. +const isCorsOriginValue = (value: string): boolean => value === '*' || isOriginValue(value); +const isCorsOrigin = makeRule({ + name: 'isCorsOrigin', + requiresType: RequiredType.String, + constraints: { format: 'origin', allowWildcard: true }, + validate: value => typeof value === 'string' && isCorsOriginValue(value), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRef(isCorsOriginValue); + return `if (!(refs[${i}](${varName}))) ${ctx.fail('isCorsOrigin')};`; + }, +}); + +// BooleanString: 'true' | 'false' | '1' | '0' +const isBooleanString = makeRule({ + name: 'isBooleanString', + requiresType: RequiredType.String, + constraints: {}, + validate: value => value === 'true' || value === 'false' || value === '1' || value === '0', + emit: (varName: string, ctx: EmitContext): string => + `if (${varName} !== 'true' && ${varName} !== 'false' && ${varName} !== '1' && ${varName} !== '0') ${ctx.fail('isBooleanString')};`, +}); + +interface IsNumberStringOptions { + noSymbols?: boolean; +} + +const NO_SYMBOLS_RE = /^[0-9]+$/; +// A numeric string: optional sign, integer/decimal/leading-dot form. No whitespace, hex, or +// exponent — `Number()` coercion accepted all of those (e.g. " ", "0x1A", "1e5"), which is far +// looser than "is this string a number". Matches validator.js's default isNumeric behavior. +const NUMERIC_STRING_RE = /^[+-]?(?:[0-9]*\.)?[0-9]+$/; + +function isNumberString(options?: IsNumberStringOptions): EmittableRule { + const noSymbols = options?.noSymbols ?? false; + const re = noSymbols ? NO_SYMBOLS_RE : NUMERIC_STRING_RE; + + return makeStringRule( + 'isNumberString', + (s: string): boolean => re.test(s), + (varName, ctx) => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isNumberString')};`; + }, + RequiredType.String, + { noSymbols }, + ); +} + +function isDecimal(): EmittableRule { + // Require a digit after the dot — `\d+(?:\.\d*)?` accepted a dangling "5.". + const decimalRe = /^[-+]?(?:\d+(?:\.\d+)?|\.\d+)$/; + return makeStringRule( + 'isDecimal', + v => decimalRe.test(v), + (varName, ctx) => { + const i = ctx.addRegex(decimalRe); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isDecimal')};`; + }, + ); +} + +export { + minLength, + maxLength, + length, + contains, + notContains, + matches, + isLowercase, + isUppercase, + isAscii, + isAlpha, + isAlphanumeric, + isHttpToken, + isOrigin, + isCorsOrigin, + isBooleanString, + isNumberString, + isDecimal, +}; +export type { IsNumberStringOptions }; diff --git a/src/rules/string-crypto.ts b/src/rules/string-crypto.ts new file mode 100644 index 0000000..54d2033 --- /dev/null +++ b/src/rules/string-crypto.ts @@ -0,0 +1,56 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { BakerError } from '../common'; +import { HASH_REGEXES } from './constants'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +const isEthereumAddress = makeStringRule( + 'isEthereumAddress', + v => ETH_ADDRESS_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ETH_ADDRESS_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isEthereumAddress')};`; + }, +); + +const BTC_P2PKH_RE = /^1[a-km-zA-HJ-NP-Z1-9]{25,34}$/; + +const BTC_P2SH_RE = /^3[a-km-zA-HJ-NP-Z1-9]{25,34}$/; + +// bech32 (BIP-173): mainnet `bc1` / testnet `tb1`. Case-insensitive but never mixed-case — accept +// all-lowercase or all-uppercase, reject a mix. +const BTC_BECH32_RE = /^(?:(?:bc1|tb1)[a-z0-9]{6,87}|(?:BC1|TB1)[A-Z0-9]{6,87})$/; + +const isBtcAddress = makeStringRule( + 'isBtcAddress', + v => BTC_P2PKH_RE.test(v) || BTC_P2SH_RE.test(v) || BTC_BECH32_RE.test(v), + (varName, ctx) => { + const i1 = ctx.addRegex(BTC_P2PKH_RE); + const i2 = ctx.addRegex(BTC_P2SH_RE); + const i3 = ctx.addRegex(BTC_BECH32_RE); + return `if (!re[${i1}].test(${varName}) && !re[${i2}].test(${varName}) && !re[${i3}].test(${varName})) ${ctx.fail('isBtcAddress')};`; + }, +); + +function isHash(algorithm: string): EmittableRule { + const re = HASH_REGEXES[algorithm]; + if (!re) { + throw new BakerError(`Unsupported algorithm: "${algorithm}" for isHash`); + } + return makeRule({ + name: 'isHash', + requiresType: RequiredType.String, + constraints: { algorithm }, + validate: value => typeof value === 'string' && re.test(value), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isHash')};`; + }, + }); +} + +export { isEthereumAddress, isBtcAddress, isHash }; diff --git a/src/rules/string-datetime.ts b/src/rules/string-datetime.ts new file mode 100644 index 0000000..665f63a --- /dev/null +++ b/src/rules/string-datetime.ts @@ -0,0 +1,25 @@ +import { makeStringRule } from './string-shared'; + +const RFC3339_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i; + +const isRFC3339 = makeStringRule( + 'isRFC3339', + v => RFC3339_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(RFC3339_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isRFC3339')};`; + }, +); + +const MILITARY_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +const isMilitaryTime = makeStringRule( + 'isMilitaryTime', + v => MILITARY_TIME_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(MILITARY_TIME_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isMilitaryTime')};`; + }, +); + +export { isRFC3339, isMilitaryTime }; diff --git a/src/rules/string-encoding.spec.ts b/src/rules/string-encoding.spec.ts new file mode 100644 index 0000000..72b08fd --- /dev/null +++ b/src/rules/string-encoding.spec.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './interfaces'; + +import { RequiredType } from './enums'; +import { isHexadecimal, isOctal, isHexColor, isRgbColor, isHSL, isBase32, isBase58, isBase64 } from './string'; + +function makeCtx(refIndex: number = 0) { + const addRefMock = mock((_fn: unknown) => refIndex); + const addRegexMock = mock((_re: RegExp) => refIndex); + const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); + const ctx: Partial = { + addRegex: addRegexMock, + addRef: addRefMock, + addExecutor: mock(() => 0), + fail: failMock, + collectErrors: true, + }; + return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; +} + +describe('isHexadecimal', () => { + it('should return true for hexadecimal string', () => { + expect(isHexadecimal('deadbeef')).toBe(true); + }); + + it('should return true for uppercase hex string', () => { + expect(isHexadecimal('DEADBEEF')).toBe(true); + }); + + it('should return false for non-hex character', () => { + expect(isHexadecimal('xyz')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isHexadecimal', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isHexadecimal.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isHexadecimal'); + expect(isHexadecimal.ruleName).toBe('isHexadecimal'); + expect(isHexadecimal.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isOctal', () => { + it('should return true for octal string', () => { + expect(isOctal('0755')).toBe(true); + }); + + it('should return false for string containing 8 or 9', () => { + expect(isOctal('089')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isOctal', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isOctal.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isOctal'); + expect(isOctal.ruleName).toBe('isOctal'); + }); + + it('should return false for empty string', () => { + expect(isOctal('')).toBe(false); + }); +}); + +describe('isHexColor', () => { + it('should return true for valid 6-digit hex color', () => { + expect(isHexColor('#ff0000')).toBe(true); + }); + + it('should return true for valid 3-digit hex color', () => { + expect(isHexColor('#f00')).toBe(true); + }); + + it('should return false for hex color without hash', () => { + expect(isHexColor('ff0000')).toBe(false); + }); + + it('should return false for invalid hex color', () => { + expect(isHexColor('#xyz')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isHexColor', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isHexColor.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isHexColor'); + expect(isHexColor.ruleName).toBe('isHexColor'); + expect(isHexColor.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isRgbColor', () => { + it('should return true for valid rgb() color', () => { + expect(isRgbColor()('rgb(255,0,0)')).toBe(true); + }); + + it('should return true for valid rgba() color', () => { + expect(isRgbColor()('rgba(255,0,0,0.5)')).toBe(true); + }); + + it('should return false for invalid rgb color', () => { + expect(isRgbColor()('rgb(256,0,0)')).toBe(false); + }); + + it('should return true for rgb with percentage values when includePercentValues is true', () => { + expect(isRgbColor(true)('rgb(100%,0%,0%)')).toBe(true); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isRgbColor', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isRgbColor().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isRgbColor'); + expect(isRgbColor().ruleName).toBe('isRgbColor'); + }); + + it('should generate percent-regex check code when emit() is called with includePercentValues=true', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isRgbColor(true).emit('v', ctx); + // Percent mode registers 4 regex slots: rgb-percent, rgba-percent, rgb-int, rgba-int. + expect(addRegexMock).toHaveBeenCalledTimes(4); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isRgbColor'); + }); +}); + +describe('isHSL', () => { + it('should return true for valid hsl() color', () => { + expect(isHSL('hsl(360,100%,50%)')).toBe(true); + }); + + it('should return true for valid hsla() color', () => { + expect(isHSL('hsla(360,100%,50%,0.5)')).toBe(true); + }); + + it('should return false for invalid hsl color', () => { + expect(isHSL('hsl(400,100%,50%)')).toBe(false); + }); + + it('should return false for hsl() carrying an alpha channel (alpha is only valid on hsla())', () => { + expect(isHSL('hsl(120,50%,50%,0.5)')).toBe(false); + }); + + it('should return false for hsla() missing the alpha channel', () => { + expect(isHSL('hsla(120,50%,50%)')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isHSL', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isHSL.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isHSL'); + expect(isHSL.ruleName).toBe('isHSL'); + }); +}); + +describe('isBase32', () => { + it('should return true for valid Base32 string', () => { + expect(isBase32()('JBSWY3DPEB3W64TMMQQQ====')).toBe(true); + }); + + it('should return false for invalid Base32 string', () => { + expect(isBase32()('Not!Valid')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBase32', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isBase32().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isBase32'); + expect(isBase32().ruleName).toBe('isBase32'); + }); +}); + +describe('isBase58', () => { + it('should return true for valid Base58 string', () => { + expect(isBase58('3yZe7d')).toBe(true); + }); + + it('should return false for Base58 string containing 0, O, I, l', () => { + expect(isBase58('0OIl')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBase58', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isBase58.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isBase58'); + expect(isBase58.ruleName).toBe('isBase58'); + }); +}); + +describe('isBase64', () => { + it('should return true for valid standard Base64 string', () => { + expect(isBase64()('SGVsbG8gV29ybGQ=')).toBe(true); + }); + + it('should return false for invalid Base64 string', () => { + expect(isBase64()('Not!base64')).toBe(false); + }); + + it('should return true for URL-safe Base64 when urlSafe option is true', () => { + expect(isBase64({ urlSafe: true })('SGVsbG8gV29ybGQ')).toBe(true); + }); + + it('should reject a URL-safe string with an invalid Base64 length (single char)', () => { + expect(isBase64({ urlSafe: true })('a')).toBe(false); + }); + + it('should reject a malformed padded URL-safe string', () => { + expect(isBase64({ urlSafe: true })('a===')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBase64', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isBase64().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isBase64'); + expect(isBase64().ruleName).toBe('isBase64'); + }); +}); diff --git a/src/rules/string-encoding.ts b/src/rules/string-encoding.ts new file mode 100644 index 0000000..ac9a53f --- /dev/null +++ b/src/rules/string-encoding.ts @@ -0,0 +1,142 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +// Hexadecimal +const HEX_RE = /^[0-9a-fA-F]+$/; +const isHexadecimal = makeStringRule( + 'isHexadecimal', + v => HEX_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(HEX_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isHexadecimal')};`; + }, +); + +// Octal +const OCTAL_RE = /^(0[oO])?[0-7]+$/; +const isOctal = makeStringRule( + 'isOctal', + v => OCTAL_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(OCTAL_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isOctal')};`; + }, +); + +// HexColor: #RGB or #RRGGBB +const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const isHexColor = makeStringRule( + 'isHexColor', + v => HEX_COLOR_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(HEX_COLOR_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isHexColor')};`; + }, +); + +// RgbColor +const RGB_RE = + /^rgb\(\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*\)$/; +const RGBA_RE = + /^rgba\(\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(0|0?\.\d+|1(\.0+)?)\s*\)$/; +// Percent forms: rgb(...) must NOT have alpha; rgba(...) MUST have alpha. +const RGB_PERCENT_NOALPHA_RE = /^rgb\(\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*\)$/; +const RGBA_PERCENT_RE = /^rgba\(\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*,\s*(0|0?\.\d+|1(?:\.0+)?)\s*\)$/; + +function isRgbColor(includePercentValues: boolean = false): EmittableRule { + return makeRule({ + name: 'isRgbColor', + requiresType: RequiredType.String, + constraints: { includePercentValues }, + validate: value => { + if (typeof value !== 'string') { + return false; + } + if (includePercentValues) { + return RGB_PERCENT_NOALPHA_RE.test(value) || RGBA_PERCENT_RE.test(value) || RGB_RE.test(value) || RGBA_RE.test(value); + } + return RGB_RE.test(value) || RGBA_RE.test(value); + }, + emit: (varName: string, ctx: EmitContext): string => { + if (includePercentValues) { + const ip1 = ctx.addRegex(RGB_PERCENT_NOALPHA_RE); + const ip2 = ctx.addRegex(RGBA_PERCENT_RE); + const ip3 = ctx.addRegex(RGB_RE); + const ip4 = ctx.addRegex(RGBA_RE); + return `if (!re[${ip1}].test(${varName}) && !re[${ip2}].test(${varName}) && !re[${ip3}].test(${varName}) && !re[${ip4}].test(${varName})) ${ctx.fail('isRgbColor')};`; + } + const i1 = ctx.addRegex(RGB_RE); + const i2 = ctx.addRegex(RGBA_RE); + return `if (!re[${i1}].test(${varName}) && !re[${i2}].test(${varName})) ${ctx.fail('isRgbColor')};`; + }, + }); +} + +// HSL: hsl(H, S%, L%) or hsla(H, S%, L%, A) +// Alpha belongs to hsla() only — `hsla?(...)?` previously let hsl() carry alpha and hsla() omit it. +const HSL_RE = + /^(?:hsl\(\s*(?:360|3[0-5]\d|[12]\d{2}|[1-9]\d|\d)\s*,\s*(?:100|[1-9]\d|\d)%\s*,\s*(?:100|[1-9]\d|\d)%\s*\)|hsla\(\s*(?:360|3[0-5]\d|[12]\d{2}|[1-9]\d|\d)\s*,\s*(?:100|[1-9]\d|\d)%\s*,\s*(?:100|[1-9]\d|\d)%\s*,\s*(?:0|0?\.\d+|1(?:\.0+)?)\s*\))$/; +const isHSL = makeStringRule( + 'isHSL', + v => HSL_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(HSL_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isHSL')};`; + }, +); + +// Base32 +const BASE32_RE = /^[A-Z2-7]+=*$/i; +// Empty-string fails the `+`-quantified regex anyway, so the explicit length===0 check is dead. +function isBase32(): EmittableRule { + return makeStringRule( + 'isBase32', + v => v.length % 8 === 0 && BASE32_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(BASE32_RE); + return `if (${varName}.length % 8 !== 0 || !re[${i}].test(${varName})) ${ctx.fail('isBase32')};`; + }, + ); +} + +// Base58 +const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; +const isBase58 = makeStringRule( + 'isBase58', + v => BASE58_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(BASE58_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isBase58')};`; + }, +); + +// Base64 +const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; +// URL-safe Base64 (RFC 4648 §5): `-_` alphabet, padding optional — but the length must still form +// valid 4-char blocks (a lone trailing char is not valid Base64), mirroring the strict BASE64_RE. +const BASE64_URL_RE = /^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2}(?:==)?|[A-Za-z0-9_-]{3}=?|[A-Za-z0-9_-]{4})$/; + +interface IsBase64Options { + urlSafe?: boolean; +} + +function isBase64(options?: IsBase64Options): EmittableRule { + const re = options?.urlSafe ? BASE64_URL_RE : BASE64_RE; + // Empty-string check is redundant — both base64 regexes require ≥1 char and fail on empty input. + return makeStringRule( + 'isBase64', + v => re.test(v), + (varName, ctx) => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isBase64')};`; + }, + RequiredType.String, + options?.urlSafe !== undefined ? { urlSafe: options.urlSafe } : {}, + ); +} + +export { isHexadecimal, isOctal, isHexColor, isRgbColor, isHSL, isBase32, isBase58, isBase64 }; +export type { IsBase64Options }; diff --git a/src/rules/string-finance.spec.ts b/src/rules/string-finance.spec.ts new file mode 100644 index 0000000..50b7079 --- /dev/null +++ b/src/rules/string-finance.spec.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './interfaces'; + +import { RequiredType } from './enums'; +import { isISBN, isISIN, isISSN, isEAN, isBIC, isCreditCard, isIBAN, isCurrency, isISO4217CurrencyCode } from './string'; + +function makeCtx(refIndex: number = 0) { + const addRefMock = mock((_fn: unknown) => refIndex); + const addRegexMock = mock((_re: RegExp) => refIndex); + const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); + const ctx: Partial = { + addRegex: addRegexMock, + addRef: addRefMock, + addExecutor: mock(() => 0), + fail: failMock, + collectErrors: true, + }; + return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; +} + +describe('isISBN', () => { + it('should return true for valid ISBN-13', () => { + expect(isISBN()('978-3-16-148410-0')).toBe(true); + }); + + it('should return true for valid ISBN-10', () => { + expect(isISBN()('0-306-40615-2')).toBe(true); + }); + + it('should return false for invalid ISBN', () => { + expect(isISBN()('1234567890')).toBe(false); + }); + + it('should return true for ISBN-13 with version 13 constraint', () => { + expect(isISBN(13)('978-3-16-148410-0')).toBe(true); + }); + + it('should return false for ISBN-10 with version 13 constraint', () => { + expect(isISBN(13)('0-306-40615-2')).toBe(false); + }); + + it('should generate code when calling emit() and have ruleName isISBN', () => { + const { ctx, failMock } = makeCtx(0); + const code = isISBN().emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isISBN'); + expect(isISBN().ruleName).toBe('isISBN'); + expect(isISBN().requiresType).toBe(RequiredType.String); + }); +}); + +describe('isISIN', () => { + it('should return true for valid ISIN', () => { + expect(isISIN('US0378331005')).toBe(true); + }); + + it('should return false for invalid ISIN', () => { + expect(isISIN('US03783310')).toBe(false); + }); + + it('should return false for ISIN that passes regex but fails Luhn checksum', () => { + // US0378331006 matches ISIN_RE but has wrong Luhn check digit (valid: US0378331005) + expect(isISIN('US0378331006')).toBe(false); + }); + + it('should emit inline regex + Luhn checksum code (no addRef)', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = isISIN.emit('v', ctx); + expect(addRefMock).not.toHaveBeenCalled(); + expect(code).toContain('re['); + expect(code).toContain('isSum'); + expect(failMock).toHaveBeenCalledWith('isISIN'); + expect(isISIN.ruleName).toBe('isISIN'); + }); +}); +describe('isISSN', () => { + it('should return true for valid ISSN', () => { + expect(isISSN()('0378-5955')).toBe(true); + }); + + it('should return false for invalid ISSN', () => { + expect(isISSN()('1234-5678')).toBe(false); + }); + + it('should return true for ISSN without hyphen when requireHyphen is false', () => { + expect(isISSN({ requireHyphen: false })('03785955')).toBe(true); + }); + + it('should return false for ISSN that passes regex but fails mod-11 checksum', () => { + // 0378-5950 matches regex \\d{4}-\\d{3}[\\dX] but check-digit 0 is wrong (valid: 0378-5955) + expect(isISSN()('0378-5950')).toBe(false); + }); + + it('should emit inline regex + mod-11 checksum code (no addRef)', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = isISSN().emit('v', ctx); + expect(addRefMock).not.toHaveBeenCalled(); + expect(code).toContain('re['); + expect(code).toContain('iss'); + expect(failMock).toHaveBeenCalledWith('isISSN'); + expect(isISSN().ruleName).toBe('isISSN'); + }); +}); +describe('isEAN', () => { + it('should return true for valid EAN-13', () => { + expect(isEAN('5901234123457')).toBe(true); + }); + + it('should return true for valid EAN-8', () => { + expect(isEAN('96385074')).toBe(true); + }); + + it('should return false for invalid EAN', () => { + expect(isEAN('1234567890123')).toBe(false); + }); + + it('should generate code when calling emit() and have ruleName isEAN', () => { + const { ctx, failMock } = makeCtx(0); + const code = isEAN.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isEAN'); + expect(isEAN.ruleName).toBe('isEAN'); + }); +}); +describe('isBIC', () => { + it('should return true for valid BIC/SWIFT code (8 chars)', () => { + expect(isBIC('DEUTDEDB')).toBe(true); + }); + + it('should return true for valid BIC/SWIFT code (11 chars)', () => { + expect(isBIC('DEUTDEDBFRA')).toBe(true); + }); + + it('should return false for invalid BIC', () => { + expect(isBIC('INVALID')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBIC', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isBIC.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isBIC'); + expect(isBIC.ruleName).toBe('isBIC'); + }); +}); +describe('isCurrency', () => { + it('should return true for valid currency amount', () => { + expect(isCurrency()('$10.50')).toBe(true); + }); + + it('should return true for amount without symbol', () => { + expect(isCurrency()('100.00')).toBe(true); + }); + + it('should return false for invalid currency format', () => { + expect(isCurrency()('abc')).toBe(false); + }); + + it('should return false for double sign', () => { + expect(isCurrency()('+-5')).toBe(false); + expect(isCurrency()('-$-5')).toBe(false); + expect(isCurrency()('+$-5')).toBe(false); + }); + + it('should return true for a single sign before or after the currency symbol', () => { + expect(isCurrency()('-5')).toBe(true); + expect(isCurrency()('-$5')).toBe(true); + expect(isCurrency()('$-5')).toBe(true); + expect(isCurrency()('+$5')).toBe(true); + expect(isCurrency()('$5')).toBe(true); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isCurrency', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isCurrency().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isCurrency'); + expect(isCurrency().ruleName).toBe('isCurrency'); + }); +}); +describe('isCreditCard', () => { + it('should return true for valid Visa test number (Luhn pass)', () => { + expect(isCreditCard('4111111111111111')).toBe(true); + }); + + it('should return true for valid Mastercard test number', () => { + expect(isCreditCard('5500005555555559')).toBe(true); + }); + + it('should return true for valid Amex test number', () => { + expect(isCreditCard('378282246310005')).toBe(true); + }); + + it('should return true for number with dashes stripped', () => { + expect(isCreditCard('4111-1111-1111-1111')).toBe(true); + }); + + it('should return true for number with spaces stripped', () => { + expect(isCreditCard('4111 1111 1111 1111')).toBe(true); + }); + + it('should return false for number failing Luhn check', () => { + expect(isCreditCard('1234567890123456')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isCreditCard('')).toBe(false); + }); + + it('should generate Luhn algorithm inline code when calling emit() and have ruleName isCreditCard', () => { + const { ctx, failMock } = makeCtx(); + const code = isCreditCard.emit('v', ctx); + expect(code).toContain('%'); + expect(failMock).toHaveBeenCalledWith('isCreditCard'); + expect(isCreditCard.ruleName).toBe('isCreditCard'); + expect(isCreditCard.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isIBAN', () => { + it('should return true for valid IBAN (GB)', () => { + expect(isIBAN()('GB82WEST12345698765432')).toBe(true); + }); + + it('should return true for valid IBAN with spaces when allowSpaces is true', () => { + expect(isIBAN({ allowSpaces: true })('GB82 WEST 1234 5698 7654 32')).toBe(true); + }); + + it('should return false for invalid IBAN checksum', () => { + expect(isIBAN()('GB00WEST12345698765432')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isIBAN()('')).toBe(false); + }); + + it('should generate mod-97 algorithm code when calling emit() and have ruleName isIBAN', () => { + const { ctx, failMock } = makeCtx(); + const code = isIBAN().emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isIBAN'); + expect(isIBAN().ruleName).toBe('isIBAN'); + expect(isIBAN().requiresType).toBe(RequiredType.String); + }); + + it('should return independent rule objects on multiple factory calls', () => { + const r1 = isIBAN(); + const r2 = isIBAN(); + expect(r1).not.toBe(r2); + }); +}); +describe('isISO4217CurrencyCode', () => { + it('should return true for USD', () => { + expect(isISO4217CurrencyCode('USD')).toBe(true); + }); + + it('should return true for EUR', () => { + expect(isISO4217CurrencyCode('EUR')).toBe(true); + }); + + it('should return true for KRW', () => { + expect(isISO4217CurrencyCode('KRW')).toBe(true); + }); + + it('should return false for lowercase usd', () => { + expect(isISO4217CurrencyCode('usd')).toBe(false); + }); + + it('should return false for non-existent code XXX', () => { + expect(isISO4217CurrencyCode('XXX')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isISO4217CurrencyCode('')).toBe(false); + }); + + it('should return false for non-string input', () => { + expect(isISO4217CurrencyCode(123 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isISO4217CurrencyCode', () => { + expect(isISO4217CurrencyCode.requiresType).toBe(RequiredType.String); + expect(isISO4217CurrencyCode.ruleName).toBe('isISO4217CurrencyCode'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isISO4217CurrencyCode.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isISO4217CurrencyCode'); + }); +}); diff --git a/src/rules/string-finance.ts b/src/rules/string-finance.ts new file mode 100644 index 0000000..2f09dce --- /dev/null +++ b/src/rules/string-finance.ts @@ -0,0 +1,365 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { IBAN_COUNTRY_LENGTH, ISO4217_CODES } from './constants'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +// ISBN +function validateISBN10(str: string): boolean { + const s = str.replace(/[-\s]/g, ''); + if (!/^\d{9}[\dX]$/.test(s)) { + return false; + } + let sum = 0; + for (let i = 0; i < 9; i++) { + sum += (10 - i) * (s.charCodeAt(i) - 48); + } + const last = s[9] === 'X' ? 10 : s.charCodeAt(9) - 48; + sum += last; + return sum % 11 === 0; +} + +function validateISBN13(str: string): boolean { + const s = str.replace(/[-\s]/g, ''); + if (!/^\d{13}$/.test(s)) { + return false; + } + let sum = 0; + for (let i = 0; i < 12; i++) { + sum += (s.charCodeAt(i) - 48) * (i % 2 === 0 ? 1 : 3); + } + const check = (10 - (sum % 10)) % 10; + return check === s.charCodeAt(12) - 48; +} + +function isISBN(version?: 10 | 13): EmittableRule { + const validateFn = (value: unknown): boolean => { + if (typeof value !== 'string') { + return false; + } + if (version === 10) { + return validateISBN10(value); + } + if (version === 13) { + return validateISBN13(value); + } + return validateISBN10(value) || validateISBN13(value); + }; + + const emitISBN10 = (v: string): string => + `{var s=${v}.replace(/[-\\s]/g,'');` + + `if(!/^\\d{9}[\\dX]$/.test(s)){%%FAIL%%}` + + `else{var sm=0;for(var i=0;i<9;i++)sm+=(10-i)*(s.charCodeAt(i)-48);` + + `var l=s[9]==='X'?10:(s.charCodeAt(9)-48);sm+=l;` + + `if(sm%11!==0){%%FAIL%%}}}`; + + const emitISBN13 = (v: string): string => + `{var s=${v}.replace(/[-\\s]/g,'');` + + `if(!/^\\d{13}$/.test(s)){%%FAIL%%}` + + `else{var sm=0;for(var i=0;i<12;i++)sm+=(s.charCodeAt(i)-48)*(i%2===0?1:3);` + + `var ck=(10-(sm%10))%10;` + + `if(ck!==(s.charCodeAt(12)-48)){%%FAIL%%}}}`; + + return makeRule({ + name: 'isISBN', + requiresType: RequiredType.String, + constraints: { version }, + validate: validateFn, + emit: (varName: string, ctx: EmitContext): string => { + const fail = ctx.fail('isISBN'); + if (version === 10) { + return emitISBN10(varName).replace(/%%FAIL%%/g, fail); + } + if (version === 13) { + return emitISBN13(varName).replace(/%%FAIL%%/g, fail); + } + const emit10 = emitISBN10(varName).replace(/%%FAIL%%/g, '__isbn_ok=false'); + const emit13 = emitISBN13(varName).replace(/%%FAIL%%/g, '__isbn_ok=false'); + return `{var __isbn_ok=true;${emit10} if(!__isbn_ok){__isbn_ok=true;${emit13}} if(!__isbn_ok)${fail};}`; + }, + }); +} + +// ISIN — ISO 6166 +const ISIN_RE = /^[A-Z]{2}[A-Z0-9]{9}[0-9]$/; + +function validateISINStr(v: string): boolean { + if (!ISIN_RE.test(v)) { + return false; + } + // Luhn mod10 on expanded digits — walk right-to-left, expanding letters as A=10..Z=35 on the fly. + // No intermediate string/array allocations. + let sum = 0; + let alternate = false; + for (let i = v.length - 1; i >= 0; i--) { + const code = v.charCodeAt(i); + if (code <= 57) { + // ASCII digit '0'..'9' + let n = code - 48; + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } else { + // ASCII letter 'A'..'Z' → two-digit value, ones first when walking right-to-left + const value = code - 55; + const ones = value % 10; + let n = ones; + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + n = (value - ones) / 10; + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } + } + return sum % 10 === 0; +} + +const isISIN = makeStringRule('isISIN', validateISINStr, (varName, ctx) => { + const i = ctx.addRegex(ISIN_RE); + return ( + `if (!re[${i}].test(${varName})) ${ctx.fail('isISIN')};\n` + + `else { var isSum=0,isAlt=false;\n` + + `for(var isI=${varName}.length-1;isI>=0;isI--){var isCd=${varName}.charCodeAt(isI),isN;` + + `if(isCd<=57){isN=isCd-48;if(isAlt){isN*=2;if(isN>9)isN-=9;}isSum+=isN;isAlt=!isAlt;}` + + `else{var isVal=isCd-55;var isO=isVal%10;isN=isO;if(isAlt){isN*=2;if(isN>9)isN-=9;}isSum+=isN;isAlt=!isAlt;` + + `isN=(isVal-isO)/10;if(isAlt){isN*=2;if(isN>9)isN-=9;}isSum+=isN;isAlt=!isAlt;}}\n` + + `if(isSum%10!==0)${ctx.fail('isISIN')}; }` + ); +}); + +// ISSN +interface IsISSNOptions { + requireHyphen?: boolean; +} + +function validateISSN(value: string, options?: IsISSNOptions): boolean { + const requireHyphen = options?.requireHyphen !== false; + const s = requireHyphen ? value : value.replace(/-/g, ''); + // Format with hyphen: NNNN-NNNX, without: NNNNNNXX + const re = requireHyphen ? /^\d{4}-\d{3}[\dX]$/ : /^\d{7}[\dX]$/; + if (!re.test(s)) { + return false; + } + // `s` already has hyphens stripped when !requireHyphen; only the hyphenated form needs stripping. + const digits = requireHyphen ? s.replace(/-/g, '') : s; + let sum = 0; + for (let i = 0; i < 7; i++) { + sum += (8 - i) * (digits.charCodeAt(i) - 48); + } + const last = digits[7] === 'X' ? 10 : digits.charCodeAt(7) - 48; + sum += last; + return sum % 11 === 0; +} + +function isISSN(options?: IsISSNOptions): EmittableRule { + const requireHyphen = options?.requireHyphen !== false; + const validateIssn = (value: unknown): boolean => typeof value === 'string' && validateISSN(value, options); + + const formatRe = requireHyphen ? /^\d{4}-\d{3}[\dX]$/ : /^\d{7}[\dX]$/; + + return makeRule({ + name: 'isISSN', + requiresType: RequiredType.String, + constraints: { requireHyphen }, + validate: validateIssn, + emit: (varName: string, ctx: EmitContext): string => { + const ri = ctx.addRegex(formatRe); + const strip = requireHyphen ? varName : `${varName}.replace(/-/g,'')`; + const idExpr = requireHyphen ? `issn.replace(/-/g,'')` : 'issn'; + return ( + `{var issn=${strip};` + + `if(!re[${ri}].test(issn)){${ctx.fail('isISSN')}}` + + `else{var id=${idExpr},iss=0;` + + `for(var ii=0;ii<7;ii++)iss+=(8-ii)*(id.charCodeAt(ii)-48);` + + `var il=id[7]==='X'?10:(id.charCodeAt(7)-48);iss+=il;` + + `if(iss%11!==0)${ctx.fail('isISSN')};}}` + ); + }, + }); +} + +// EAN (EAN-8 and EAN-13 with checksum) +function validateEAN(value: string): boolean { + if (!/^\d{8}$/.test(value) && !/^\d{13}$/.test(value)) { + return false; + } + // Walk via charCodeAt — no split/map array allocations + const len = value.length; + let sum = 0; + for (let i = 0; i < len - 1; i++) { + const d = value.charCodeAt(i) - 48; + sum += d * (len === 8 ? (i % 2 === 0 ? 3 : 1) : i % 2 === 0 ? 1 : 3); + } + const check = (10 - (sum % 10)) % 10; + return check === value.charCodeAt(len - 1) - 48; +} + +const isEAN = makeStringRule('isEAN', validateEAN, (varName, ctx) => { + const re8 = ctx.addRegex(/^\d{8}$/); + const re13 = ctx.addRegex(/^\d{13}$/); + return ( + `{var ev=${varName};` + + `if(!re[${re8}].test(ev)&&!re[${re13}].test(ev)){${ctx.fail('isEAN')}}` + + `else{var el=ev.length,es=0;` + + `for(var ei=0;ei BIC_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(BIC_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isBIC')};`; + }, +); + +// Currency +// A single optional sign, either before the `$` (`-$5`, `+5`) or after it (`$-5`, `$+5`) — never +// both. The previous `[-+]?\$?-?` allowed two signs (e.g. `+-5`, `-$-5`). +const CURRENCY_RE = /^(?:[-+]?\$?|\$[-+]?)(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?$/; + +function isCurrency(): EmittableRule { + // Currency regex requires at least one digit; empty input fails the regex by itself. + return makeStringRule( + 'isCurrency', + v => CURRENCY_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(CURRENCY_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isCurrency')};`; + }, + ); +} + +// Credit Card — Luhn algorithm +function luhn(str: string): boolean { + const s = str.replace(/[\s-]/g, ''); + if (s.length === 0 || !/^\d+$/.test(s)) { + return false; + } + let sum = 0; + let alternate = false; + for (let i = s.length - 1; i >= 0; i--) { + let n = s.charCodeAt(i) - 48; + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } + return sum % 10 === 0; +} + +const isCreditCard = makeRule({ + name: 'isCreditCard', + requiresType: RequiredType.String, + constraints: {}, + validate: value => typeof value === 'string' && luhn(value), + emit: (varName: string, ctx: EmitContext): string => `{ + var cs=${varName}.replace(/[\\s-]/g,''); + if(cs.length===0||!/^\\d+$/.test(cs)){${ctx.fail('isCreditCard')}} + else{var sum=0,alt=false; + for(var ci=cs.length-1;ci>=0;ci--){var cn=cs.charCodeAt(ci)-48;if(alt){cn*=2;if(cn>9)cn-=9;}sum+=cn;alt=!alt;} + if(sum%10!==0)${ctx.fail('isCreditCard')};} +}`, +}); + +// IBAN — ISO 13616 mod-97 +interface IsIBANOptions { + allowSpaces?: boolean; +} + +function validateIBAN(value: string, options?: IsIBANOptions): boolean { + let s = options?.allowSpaces ? value.replace(/\s/g, '') : value; + s = s.toUpperCase(); + if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(s)) { + return false; + } + const country = s.slice(0, 2); + const expectedLength = IBAN_COUNTRY_LENGTH[country]; + if (expectedLength !== undefined && s.length !== expectedLength) { + return false; + } + // Rearrange: move first 4 chars to end + const rearranged = s.slice(4) + s.slice(0, 4); + // Walk char-by-char accumulating mod 97 — no .replace/closure, no String() coercion, + // no parseInt() allocations. + let remainder = 0; + for (let i = 0; i < rearranged.length; i++) { + const code = rearranged.charCodeAt(i); + if (code <= 57) { + // digit + remainder = (remainder * 10 + (code - 48)) % 97; + } else { + // letter A-Z → two digits (value = code - 55) + const value = code - 55; + remainder = (remainder * 100 + value) % 97; + } + } + return remainder === 1; +} + +function isIBAN(options?: IsIBANOptions): EmittableRule { + const allowSpaces = options?.allowSpaces ?? false; + const validateIban = (value: unknown): boolean => typeof value === 'string' && validateIBAN(value, options); + return makeRule({ + name: 'isIBAN', + requiresType: RequiredType.String, + constraints: { allowSpaces }, + validate: validateIban, + emit: (varName: string, ctx: EmitContext): string => { + const baseRi = ctx.addRegex(/^[A-Z]{2}\d{2}[A-Z0-9]+$/); + const tableIdx = ctx.addRef(IBAN_COUNTRY_LENGTH); + let code = '{'; + code += `var ib=${allowSpaces ? `${varName}.replace(/\\s/g,'')` : varName}.toUpperCase();`; + code += `if(!re[${baseRi}].test(ib)){${ctx.fail('isIBAN')}}`; + code += `else{var ic=ib.slice(0,2),il=refs[${tableIdx}][ic];`; + code += `if(il!==undefined&&ib.length!==il){${ctx.fail('isIBAN')}}`; + code += `else{var ir=ib.slice(4)+ib.slice(0,4);`; + // Walk char-by-char for mod 97 — no .replace closure, no parseInt allocation + code += `var im=0;for(var ii=0;ii ISO4217_CODES.has(v), + (varName, ctx) => { + const i = ctx.addRef(ISO4217_CODES); + return `if (!refs[${i}].has(${varName})) ${ctx.fail('isISO4217CurrencyCode')};`; + }, +); + +export { isISBN, isISIN, isISSN, isEAN, isBIC, isCreditCard, isIBAN, isCurrency, isISO4217CurrencyCode }; +export type { IsISSNOptions, IsIBANOptions }; diff --git a/src/rules/string-format.spec.ts b/src/rules/string-format.spec.ts new file mode 100644 index 0000000..8607c1f --- /dev/null +++ b/src/rules/string-format.spec.ts @@ -0,0 +1,1063 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './interfaces'; + +import { BakerError } from '../common'; +import { RequiredType } from './enums'; +import { + isEmail, + isURL, + isUUID, + isIP, + isMACAddress, + isJWT, + isLatLong, + isLocale, + isDataURI, + isFQDN, + isPort, + isJSON, + isMimeType, + isMagnetURI, + isByteLength, + isHash, + isRFC3339, + isMilitaryTime, + isLatitude, + isLongitude, + isEthereumAddress, + isBtcAddress, + isPhoneNumber, + isStrongPassword, + isTaxId, +} from './string'; + +function makeCtx(refIndex: number = 0) { + const addRefMock = mock((_fn: unknown) => refIndex); + const addRegexMock = mock((_re: RegExp) => refIndex); + const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); + const ctx: Partial = { + addRegex: addRegexMock, + addRef: addRefMock, + addExecutor: mock(() => 0), + fail: failMock, + collectErrors: true, + }; + return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; +} + +describe('isEmail', () => { + it('should return true for valid email address', () => { + expect(isEmail()('user@example.com')).toBe(true); + }); + + it('should return true for email with subdomain', () => { + expect(isEmail()('user@mail.example.co.uk')).toBe(true); + }); + + it('should return true for email with plus sign in local part', () => { + expect(isEmail()('user+tag@example.com')).toBe(true); + }); + + it('should return false for email without at sign', () => { + expect(isEmail()('userexample.com')).toBe(false); + }); + + it('should return false for email without domain', () => { + expect(isEmail()('user@')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isEmail()('')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit()', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isEmail().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toContain('re[0]'); + expect(failMock).toHaveBeenCalledWith('isEmail'); + }); + + it('should have ruleName isEmail and requiresType string', () => { + expect(isEmail().ruleName).toBe('isEmail'); + expect(isEmail().requiresType).toBe(RequiredType.String); + }); +}); + +describe('isURL', () => { + it('default-protocols rules do not share a mutable constraints array', () => { + const a = isURL().constraints as { protocols: string[] }; + const b = isURL().constraints as { protocols: string[] }; + // Each rule must own its protocols array — no shared module-level reference that one rule could + // mutate and corrupt for every other rule. + expect(a.protocols).not.toBe(b.protocols); + expect(a.protocols).toEqual(['http', 'https', 'ftp']); + }); + + it('should return true for valid http URL', () => { + expect(isURL()('http://example.com')).toBe(true); + }); + + it('should return true for valid https URL', () => { + expect(isURL()('https://example.com/path?q=1')).toBe(true); + }); + + it('should return false for URL without protocol', () => { + expect(isURL()('example.com')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isURL()('')).toBe(false); + }); + + it('should return true for URL with allowedProtocols option matching', () => { + expect(isURL({ protocols: ['ftp'] })('ftp://ftp.example.com')).toBe(true); + }); + + it('should return false for URL with protocol not in allowedProtocols', () => { + expect(isURL({ protocols: ['https'] })('http://example.com')).toBe(false); + }); + + it('should generate regex-based code when calling emit()', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isURL().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isURL'); + }); + + it('should have ruleName isURL and requiresType string', () => { + expect(isURL().ruleName).toBe('isURL'); + expect(isURL().requiresType).toBe(RequiredType.String); + }); +}); + +describe('isUUID', () => { + it('should return true for valid UUID v4 without version constraint', () => { + expect(isUUID()('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + + it('should return true for UUID v4 with version 4 constraint', () => { + expect(isUUID(4)('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + + it('should return false for invalid UUID format', () => { + expect(isUUID()('not-a-uuid')).toBe(false); + }); + + it('should return false for UUID v4 with version 3 constraint', () => { + expect(isUUID(3)('550e8400-e29b-41d4-a716-446655440000')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isUUID()('')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit()', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isUUID().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toContain('re[0]'); + expect(failMock).toHaveBeenCalledWith('isUUID'); + }); + + it('should have ruleName isUUID and requiresType string', () => { + expect(isUUID().ruleName).toBe('isUUID'); + expect(isUUID().requiresType).toBe(RequiredType.String); + }); +}); + +describe('isIP', () => { + it('should return true for valid IPv4 address', () => { + expect(isIP()('192.168.1.1')).toBe(true); + }); + + it('should return true for valid IPv6 address', () => { + expect(isIP()('2001:db8::1')).toBe(true); + }); + + it('should return true for IPv4 loopback', () => { + expect(isIP()('127.0.0.1')).toBe(true); + }); + + it('should return false for IP with octet out of range', () => { + expect(isIP()('999.999.999.999')).toBe(false); + }); + + it('should return true for valid IPv4 with version 4 constraint', () => { + expect(isIP(4)('192.168.1.1')).toBe(true); + }); + + it('should return false for IPv6 with version 4 constraint', () => { + expect(isIP(4)('2001:db8::1')).toBe(false); + }); + + it('should return true for IPv6 with version 6 constraint', () => { + expect(isIP(6)('::1')).toBe(true); + }); + + it('should call ctx.addRegex and generate test code when calling emit()', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isIP().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isIP'); + }); + + it('should generate IPv4-only check code when emit() is called with version 4', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isIP(4).emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isIP'); + }); + + it('should generate IPv6-only check code when emit() is called with version 6', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isIP(6).emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isIP'); + }); + + it('should have ruleName isIP and requiresType string', () => { + expect(isIP().ruleName).toBe('isIP'); + expect(isIP().requiresType).toBe(RequiredType.String); + }); +}); + +describe('isMACAddress', () => { + it('should return true for valid colon-separated MAC address', () => { + expect(isMACAddress()('01:23:45:67:89:ab')).toBe(true); + }); + + it('should return true for valid hyphen-separated MAC address', () => { + expect(isMACAddress()('01-23-45-67-89-ab')).toBe(true); + }); + + it('should return false for invalid MAC address', () => { + expect(isMACAddress()('01:23:45:67:89')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMACAddress', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isMACAddress().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isMACAddress'); + expect(isMACAddress().ruleName).toBe('isMACAddress'); + }); + + it('should generate no-separator regex check code when emit() is called with noSeparators:true', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + const code = isMACAddress({ noSeparators: true }).emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isMACAddress'); + }); +}); + +describe('isJWT', () => { + it('should return true for valid JWT (3-part dot-separated base64url)', () => { + const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + expect(isJWT(jwt)).toBe(true); + }); + + it('should return false for string without two dots', () => { + expect(isJWT('header.payload')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isJWT('')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isJWT', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isJWT.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isJWT'); + expect(isJWT.ruleName).toBe('isJWT'); + expect(isJWT.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isLatLong', () => { + it('should return true for valid lat,long pair', () => { + expect(isLatLong()('40.7128,-74.0060')).toBe(true); + }); + + it('should return false for out-of-range latitude', () => { + expect(isLatLong()('91.0000,0.0000')).toBe(false); + }); + + it('should return false for invalid format', () => { + expect(isLatLong()('not_a_coord')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isLatLong', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isLatLong().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isLatLong'); + expect(isLatLong().ruleName).toBe('isLatLong'); + }); +}); + +describe('isLocale', () => { + it('should return true for valid BCP 47 locale (en)', () => { + expect(isLocale('en')).toBe(true); + }); + + it('should return true for valid BCP 47 locale (en-US)', () => { + expect(isLocale('en-US')).toBe(true); + }); + + it('should return false for invalid locale', () => { + expect(isLocale('a')).toBe(false); + }); + + it('should return true for a BCP 47 tag with a digit-led 4-char variant subtag (de-DE-1996)', () => { + expect(isLocale('de-DE-1996')).toBe(true); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isLocale', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isLocale.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isLocale'); + expect(isLocale.ruleName).toBe('isLocale'); + }); +}); + +describe('isDataURI', () => { + it('should return true for valid data URI', () => { + expect(isDataURI('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA')).toBe(true); + }); + + it('should return true for data URI with text content', () => { + expect(isDataURI('data:text/plain;charset=utf-8,Hello')).toBe(true); + }); + + it('should return false for non-data URI', () => { + expect(isDataURI('http://example.com')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isDataURI', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isDataURI.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isDataURI'); + expect(isDataURI.ruleName).toBe('isDataURI'); + }); +}); + +describe('isFQDN', () => { + it('should return true for valid FQDN', () => { + expect(isFQDN()('example.com')).toBe(true); + }); + + it('should return true for subdomain FQDN', () => { + expect(isFQDN()('sub.example.co.uk')).toBe(true); + }); + + it('should return false for IP address', () => { + expect(isFQDN()('192.168.1.1')).toBe(false); + }); + + it('should return false for localhost', () => { + expect(isFQDN()('localhost')).toBe(false); + }); + + it('should generate code when calling emit() and have ruleName isFQDN', () => { + const { ctx, failMock } = makeCtx(0); + const code = isFQDN().emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isFQDN'); + expect(isFQDN().ruleName).toBe('isFQDN'); + }); +}); + +describe('isPort', () => { + it('should return true for port 80', () => { + expect(isPort('80')).toBe(true); + }); + + it('should return true for port 0', () => { + expect(isPort('0')).toBe(true); + }); + + it('should return true for port 65535', () => { + expect(isPort('65535')).toBe(true); + }); + + it('should return false for port 65536', () => { + expect(isPort('65536')).toBe(false); + }); + + it('should return false for negative port', () => { + expect(isPort('-1')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isPort', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isPort.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isPort'); + expect(isPort.ruleName).toBe('isPort'); + expect(isPort.requiresType).toBe(RequiredType.String); + }); +}); + +describe('isJSON', () => { + it('should return true for valid JSON object string', () => { + expect(isJSON('{"key":"value"}')).toBe(true); + }); + + it('should return true for valid JSON array string', () => { + expect(isJSON('[1,2,3]')).toBe(true); + }); + + it('should return false for invalid JSON string', () => { + expect(isJSON('{invalid}')).toBe(false); + }); + + it('should return false for non-string value', () => { + expect(isJSON(42 as never)).toBe(false); + }); + + it('should generate try-catch or ref-based code when calling emit() and have ruleName isJSON', () => { + const { ctx, failMock } = makeCtx(0); + const code = isJSON.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isJSON'); + expect(isJSON.ruleName).toBe('isJSON'); + }); + + it('should emit inline try/catch JSON.parse code (no addRef)', () => { + const { ctx, addRefMock } = makeCtx(0); + const code = isJSON.emit('v', ctx); + expect(addRefMock).not.toHaveBeenCalled(); + expect(code).toContain('JSON.parse'); + expect(code).toContain('catch'); + }); +}); + +describe('isMimeType', () => { + it('should return true for valid MIME type', () => { + expect(isMimeType('application/json')).toBe(true); + }); + + it('should return true for valid MIME type with subtype', () => { + expect(isMimeType('image/png')).toBe(true); + }); + + it('should return false for invalid MIME type', () => { + expect(isMimeType('not-a-mime')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMimeType', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isMimeType.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isMimeType'); + expect(isMimeType.ruleName).toBe('isMimeType'); + }); +}); + +describe('isMagnetURI', () => { + it('should return true for valid magnet URI', () => { + expect(isMagnetURI('magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a')).toBe(true); + }); + + it('should return false for non-magnet URI', () => { + expect(isMagnetURI('http://example.com')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMagnetURI', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isMagnetURI.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isMagnetURI'); + expect(isMagnetURI.ruleName).toBe('isMagnetURI'); + }); +}); + +describe('isByteLength', () => { + it('should return true when byte length is within range', () => { + const rule = isByteLength(1, 10); + expect(rule('hello')).toBe(true); + }); + + it('should return true for multibyte string within range', () => { + const rule = isByteLength(1, 100); + expect(rule('日本語')).toBe(true); + }); + + it('should return false when byte length is below minimum', () => { + const rule = isByteLength(5, 10); + expect(rule('hi')).toBe(false); + }); + + it('should return false when byte length exceeds maximum', () => { + const rule = isByteLength(1, 3); + expect(rule('hello')).toBe(false); + }); + + it('should return true for empty string when minimum is 0', () => { + const rule = isByteLength(0); + expect(rule('')).toBe(true); + }); + + it('should count multibyte characters by byte length not char count', () => { + const rule = isByteLength(1, 3); + // '日' is 3 bytes in UTF-8, so within [1,3] + expect(rule('日')).toBe(true); + // '日本' is 6 bytes, exceeds max=3 + expect(rule('日本')).toBe(false); + }); + + it('should generate byte length check code when calling emit() and have ruleName isByteLength', () => { + const rule = isByteLength(1, 10); + const { ctx, failMock } = makeCtx(); + const code = rule.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isByteLength'); + expect(rule.ruleName).toBe('isByteLength'); + expect(rule.requiresType).toBe(RequiredType.String); + }); + + it('should emit inline Buffer.byteLength check (no addRef)', () => { + const rule = isByteLength(2, 5); + const { ctx, addRefMock } = makeCtx(0); + const code = rule.emit('v', ctx); + expect(addRefMock).not.toHaveBeenCalled(); + expect(code).toContain('bl'); + expect(code).toContain('2'); + expect(code).toContain('5'); + }); + + it('should return independent rule objects on multiple factory calls', () => { + const r1 = isByteLength(1, 10); + const r2 = isByteLength(1, 10); + expect(r1).not.toBe(r2); + }); +}); + +describe('isHash', () => { + it('should return true for a valid md5 hash', () => { + expect(isHash('md5')('d41d8cd98f00b204e9800998ecf8427e')).toBe(true); + }); + + it('should return false for a non-hex md5-length string', () => { + expect(isHash('md5')('z41d8cd98f00b204e9800998ecf8427e')).toBe(false); + }); + + it('should return true for a valid sha1 hash', () => { + expect(isHash('sha1')('da39a3ee5e6b4b0d3255bfef95601890afd80709')).toBe(true); + }); + + it('should return false for sha1 with wrong length', () => { + expect(isHash('sha1')('da39a3ee5e6b4b0d3255bfef95601890afd8070')).toBe(false); + }); + + it('should return true for a valid sha256 hash', () => { + expect(isHash('sha256')('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')).toBe(true); + }); + + it('should return false for sha256 with non-hex character', () => { + expect(isHash('sha256')('g3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')).toBe(false); + }); + + it('should return true for valid sha384 hash', () => { + // sha384 of empty string = 96 hex chars + expect( + isHash('sha384')('38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b'), + ).toBe(true); + }); + + it('should return true for a valid sha512 hash', () => { + // sha512 of empty string = 128 hex chars (exact) + const sha512 = + 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e'; + expect(isHash('sha512')(sha512)).toBe(true); + }); + + it('should return true for valid ripemd128 hash', () => { + expect(isHash('ripemd128')('cdf26213a150dc3ecb610f18f6b38b46')).toBe(true); + }); + + it('should return false for ripemd128 with wrong length', () => { + expect(isHash('ripemd128')('cdf26213a150dc3ecb610f18f6b38')).toBe(false); + }); + + it('should return true for valid ripemd160 hash', () => { + expect(isHash('ripemd160')('9c1185a5c5e9fc54612808977ee8f548b2258d31')).toBe(true); + }); + + it('should return true for valid crc32 hash', () => { + expect(isHash('crc32')('90abcdef')).toBe(true); + }); + + it('should return false for non-string input', () => { + expect(isHash('md5')(42 as never)).toBe(false); + }); + + it('should have requiresType string', () => { + expect(isHash('md5').requiresType).toBe(RequiredType.String); + }); + + it('should have ruleName isHash', () => { + expect(isHash('md5').ruleName).toBe('isHash'); + }); + + it('should generate emit code with regex check', () => { + const { ctx, failMock } = makeCtx(); + const code = isHash('md5').emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isHash'); + }); + + it('should throw at construction for an unknown algorithm (fail-fast, like locale rules)', () => { + expect(() => isHash('unknownAlgo')).toThrow(BakerError); + }); +}); + +describe('isRFC3339', () => { + it('should return true for UTC datetime', () => { + expect(isRFC3339('2021-01-01T00:00:00Z')).toBe(true); + }); + + it('should return true for datetime with timezone offset', () => { + expect(isRFC3339('2021-12-31T23:59:59+09:00')).toBe(true); + }); + + it('should return true for datetime with milliseconds', () => { + expect(isRFC3339('2021-06-15T12:30:45.123Z')).toBe(true); + }); + + it('should return false for date-only string', () => { + expect(isRFC3339('2021-01-01')).toBe(false); + }); + + it('should return false for a plain string', () => { + expect(isRFC3339('not-a-date')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isRFC3339('')).toBe(false); + }); + + it('should return false for non-string input', () => { + expect(isRFC3339(12345 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isRFC3339', () => { + expect(isRFC3339.requiresType).toBe(RequiredType.String); + expect(isRFC3339.ruleName).toBe('isRFC3339'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isRFC3339.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isRFC3339'); + }); +}); + +describe('isMilitaryTime', () => { + it('should return true for 00:00', () => { + expect(isMilitaryTime('00:00')).toBe(true); + }); + + it('should return true for 23:59', () => { + expect(isMilitaryTime('23:59')).toBe(true); + }); + + it('should return true for 12:30', () => { + expect(isMilitaryTime('12:30')).toBe(true); + }); + + it('should return false for 24:00', () => { + expect(isMilitaryTime('24:00')).toBe(false); + }); + + it('should return false for 12:60', () => { + expect(isMilitaryTime('12:60')).toBe(false); + }); + + it('should return false for single-digit hour', () => { + expect(isMilitaryTime('1:30')).toBe(false); + }); + + it('should return false for non-string input', () => { + expect(isMilitaryTime(1230 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isMilitaryTime', () => { + expect(isMilitaryTime.requiresType).toBe(RequiredType.String); + expect(isMilitaryTime.ruleName).toBe('isMilitaryTime'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isMilitaryTime.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isMilitaryTime'); + }); +}); + +describe('isLatitude', () => { + it('should return true for string "0"', () => { + expect(isLatitude('0')).toBe(true); + }); + + it('should return true for string "-90"', () => { + expect(isLatitude('-90')).toBe(true); + }); + + it('should return true for string "90"', () => { + expect(isLatitude('90')).toBe(true); + }); + + it('should return true for string "45.1234"', () => { + expect(isLatitude('45.1234')).toBe(true); + }); + + it('should return true for number 0', () => { + expect(isLatitude(0)).toBe(true); + }); + + it('should return true for number 45.123', () => { + expect(isLatitude(45.123)).toBe(true); + }); + + it('should return false for "-90.001"', () => { + expect(isLatitude('-90.001')).toBe(false); + }); + + it('should return false for "90.001"', () => { + expect(isLatitude('90.001')).toBe(false); + }); + + it('should return false for "abc"', () => { + expect(isLatitude('abc')).toBe(false); + }); + + it('should return false for string with extra chars like "90abc"', () => { + expect(isLatitude('90abc')).toBe(false); + }); + + it('should return false for non-string non-number input', () => { + expect(isLatitude(null as never)).toBe(false); + expect(isLatitude({} as never)).toBe(false); + }); + + it('should have ruleName isLatitude and requiresType undefined', () => { + expect(isLatitude.ruleName).toBe('isLatitude'); + expect(isLatitude.requiresType).toBeUndefined(); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isLatitude.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isLatitude'); + }); +}); + +describe('isLongitude', () => { + it('should return true for string "0"', () => { + expect(isLongitude('0')).toBe(true); + }); + + it('should return true for string "-180"', () => { + expect(isLongitude('-180')).toBe(true); + }); + + it('should return true for string "180"', () => { + expect(isLongitude('180')).toBe(true); + }); + + it('should return true for number 90.5', () => { + expect(isLongitude(90.5)).toBe(true); + }); + + it('should return false for "-180.001"', () => { + expect(isLongitude('-180.001')).toBe(false); + }); + + it('should return false for "180.001"', () => { + expect(isLongitude('180.001')).toBe(false); + }); + + it('should return false for "abc"', () => { + expect(isLongitude('abc')).toBe(false); + }); + + it('should return false for string with extra chars like "180abc"', () => { + expect(isLongitude('180abc')).toBe(false); + }); + + it('should return false for non-string non-number input', () => { + expect(isLongitude(null as never)).toBe(false); + expect(isLongitude({} as never)).toBe(false); + }); + + it('should have ruleName isLongitude and requiresType undefined', () => { + expect(isLongitude.ruleName).toBe('isLongitude'); + expect(isLongitude.requiresType).toBeUndefined(); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isLongitude.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isLongitude'); + }); +}); + +describe('isEthereumAddress', () => { + it('should return true for a valid lowercase ethereum address', () => { + expect(isEthereumAddress('0x742d35cc6634c0532925a3b8d4c9db96590c6af5')).toBe(true); + }); + + it('should return true for a valid mixed-case ethereum address', () => { + expect(isEthereumAddress('0x742d35Cc6634C0532925a3b8D4C9Db96590c7aEB')).toBe(true); + }); + + it('should return false for address without 0x prefix', () => { + expect(isEthereumAddress('742d35cc6634c0532925a3b8d4c9db96590c6af5')).toBe(false); + }); + + it('should return false for too short address', () => { + expect(isEthereumAddress('0x742d35')).toBe(false); + }); + + it('should return false for non-hex chars', () => { + expect(isEthereumAddress('0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz')).toBe(false); + }); + + it('should return false for non-string input', () => { + expect(isEthereumAddress(123 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isEthereumAddress', () => { + expect(isEthereumAddress.requiresType).toBe(RequiredType.String); + expect(isEthereumAddress.ruleName).toBe('isEthereumAddress'); + }); + + it('should generate emit code with regex', () => { + const { ctx, failMock } = makeCtx(); + const code = isEthereumAddress.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isEthereumAddress'); + }); +}); + +describe('isBtcAddress', () => { + it('should return true for a valid P2PKH address (starts with 1)', () => { + expect(isBtcAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf Na')).toBe(false); // has space + }); + + it('should return true for a valid P2PKH address', () => { + expect(isBtcAddress('1BpEi6DfDAUFd153wiGrvkiKW1iHENGLyQ')).toBe(true); + }); + + it('should return true for a valid P2SH address (starts with 3)', () => { + expect(isBtcAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy')).toBe(true); + }); + + it('should return true for a valid bech32 address', () => { + expect(isBtcAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(true); + }); + + it('should return true for an all-uppercase bech32 address (BIP-173)', () => { + expect(isBtcAddress('BC1QAR0SRRR7XFKVY5L643LYDNW9RE59GTZZWF5MDQ')).toBe(true); + }); + + it('should return true for a testnet bech32 address (tb1)', () => { + expect(isBtcAddress('tb1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(true); + }); + + it('should return false for a mixed-case bech32 address', () => { + expect(isBtcAddress('bc1QAR0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(false); + }); + + it('should return false for clearly invalid address', () => { + expect(isBtcAddress('0invalidaddress')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isBtcAddress('')).toBe(false); + }); + + it('should return false for non-string input', () => { + expect(isBtcAddress(123 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isBtcAddress', () => { + expect(isBtcAddress.requiresType).toBe(RequiredType.String); + expect(isBtcAddress.ruleName).toBe('isBtcAddress'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isBtcAddress.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isBtcAddress'); + }); +}); + +describe('isPhoneNumber', () => { + it('should return true for valid E.164 US number', () => { + expect(isPhoneNumber('+14155552671')).toBe(true); + }); + + it('should return true for valid E.164 KR number', () => { + expect(isPhoneNumber('+821012345678')).toBe(true); + }); + + it('should return true for valid E.164 UK number', () => { + expect(isPhoneNumber('+447700900077')).toBe(true); + }); + + it('should return false for number without + prefix', () => { + expect(isPhoneNumber('00821012345678')).toBe(false); + }); + + it('should return false for too short number', () => { + expect(isPhoneNumber('+123')).toBe(false); + }); + + it('should return false for +0 leading digit after +', () => { + expect(isPhoneNumber('+0123456789')).toBe(false); + }); + + it('should return false for non-string input', () => { + expect(isPhoneNumber(123 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isPhoneNumber', () => { + expect(isPhoneNumber.requiresType).toBe(RequiredType.String); + expect(isPhoneNumber.ruleName).toBe('isPhoneNumber'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isPhoneNumber.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isPhoneNumber'); + }); +}); + +describe('isStrongPassword', () => { + it('should return true for a valid strong password with defaults', () => { + expect(isStrongPassword()('Passw0rd!')).toBe(true); + }); + + it('should return true for complex password', () => { + expect(isStrongPassword()('MyP@ssw0rd123')).toBe(true); + }); + + it('should return false for too short password (< 8 chars)', () => { + expect(isStrongPassword()('Pass0!')).toBe(false); + }); + + it('should return false for password with no uppercase', () => { + expect(isStrongPassword()('password1!')).toBe(false); + }); + + it('should return false for password with no lowercase', () => { + expect(isStrongPassword()('PASSWORD1!')).toBe(false); + }); + + it('should return false for password with no numbers', () => { + expect(isStrongPassword()('Password!')).toBe(false); + }); + + it('should return false for password with no symbols', () => { + expect(isStrongPassword()('Password1')).toBe(false); + }); + + it('should respect custom minLength option', () => { + expect(isStrongPassword({ minLength: 4, minSymbols: 0 })('Pa1')).toBe(false); + expect(isStrongPassword({ minLength: 4, minSymbols: 0 })('Pa1x')).toBe(true); + }); + + it('should return false for non-string input', () => { + expect(isStrongPassword()(12345678 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isStrongPassword', () => { + expect(isStrongPassword().requiresType).toBe(RequiredType.String); + expect(isStrongPassword().ruleName).toBe('isStrongPassword'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isStrongPassword().emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isStrongPassword'); + }); + + it('should return independent rule objects on multiple factory calls', () => { + const r1 = isStrongPassword(); + const r2 = isStrongPassword(); + expect(r1).not.toBe(r2); + }); +}); + +describe('isTaxId', () => { + it('should return true for valid US EIN', () => { + expect(isTaxId('US')('12-3456789')).toBe(true); + }); + + it('should return false for invalid US format', () => { + expect(isTaxId('US')('1234567')).toBe(false); + }); + + it('should return true for valid KR business registration number', () => { + expect(isTaxId('KR')('123-45-67890')).toBe(true); + }); + + it('should return false for invalid KR format', () => { + expect(isTaxId('KR')('12345')).toBe(false); + }); + + it('should return true for valid DE tax id', () => { + expect(isTaxId('DE')('12345678901')).toBe(true); + }); + + it('should return false for invalid DE format', () => { + expect(isTaxId('DE')('1234567890')).toBe(false); + }); + + it('should return true for valid GB UTR', () => { + expect(isTaxId('GB')('1234567890')).toBe(true); + }); + + it('should return false for non-string input', () => { + expect(isTaxId('US')(123 as never)).toBe(false); + }); + + it('should have requiresType string and ruleName isTaxId', () => { + expect(isTaxId('US').requiresType).toBe(RequiredType.String); + expect(isTaxId('US').ruleName).toBe('isTaxId'); + }); + + it('should generate emit code', () => { + const { ctx, failMock } = makeCtx(); + const code = isTaxId('US').emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isTaxId'); + }); + + it('should throw at construction for an unknown locale (fail-fast, like locale rules)', () => { + expect(() => isTaxId('XX-UNKNOWN')).toThrow(BakerError); + }); + + it('should return independent rule objects on multiple factory calls', () => { + const r1 = isTaxId('US'); + const r2 = isTaxId('US'); + expect(r1).not.toBe(r2); + }); +}); diff --git a/src/rules/string-format.ts b/src/rules/string-format.ts new file mode 100644 index 0000000..8f4acbb --- /dev/null +++ b/src/rules/string-format.ts @@ -0,0 +1,487 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { BakerError } from '../common'; +import { TAX_ID_REGEXES } from './constants'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +// Email — RFC 5322 simplified +const EMAIL_RE = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; + +function isEmail(): EmittableRule { + return makeStringRule( + 'isEmail', + v => EMAIL_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(EMAIL_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isEmail')};`; + }, + RequiredType.String, + { format: 'email' }, + ); +} + +// URL — RFC 3986 simplified +interface IsURLOptions { + protocols?: string[]; +} + +const URL_PROTOCOLS_DEFAULT = Object.freeze(['http', 'https', 'ftp']); + +function isURL(options?: IsURLOptions): EmittableRule { + const protocols = options?.protocols ?? URL_PROTOCOLS_DEFAULT; + const protocolPattern = protocols.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const re = new RegExp( + `^(?:${protocolPattern}):\\/\\/(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?::(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]\\d{4}|[1-9]\\d{0,3}|0))?(?:\\/[^\\s]*)?$`, + ); + return makeRule({ + name: 'isURL', + requiresType: RequiredType.String, + // Copy so each rule owns an independent, mutable constraints array (the frozen default and a + // caller-supplied array are both isolated from `rule.constraints`). + constraints: { format: 'uri', protocols: [...protocols] }, + validate: value => typeof value === 'string' && re.test(value), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isURL')};`; + }, + }); +} + +// UUID +const UUID_RE = { + all: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + 1: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-1[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, + 2: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-2[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, + 3: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-3[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, + 4: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, + 5: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-5[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, +} as const; + +function isUUID(version?: 1 | 2 | 3 | 4 | 5 | 'all'): EmittableRule { + const re = version != null ? UUID_RE[version] : UUID_RE.all; + return makeStringRule( + 'isUUID', + v => re.test(v), + (varName, ctx) => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isUUID')};`; + }, + RequiredType.String, + { format: 'uuid', version }, + ); +} + +// IP +const IPV4_RE = + /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/; +const IPV6_RE = + /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}$|^::$|^::(?:ffff(?::0{1,4})?:)?(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/; + +function isIP(version?: 4 | 6): EmittableRule { + return makeRule({ + name: 'isIP', + requiresType: RequiredType.String, + constraints: { version }, + validate: value => { + if (typeof value !== 'string') { + return false; + } + if (version === 4) { + return IPV4_RE.test(value); + } + if (version === 6) { + return IPV6_RE.test(value); + } + return IPV4_RE.test(value) || IPV6_RE.test(value); + }, + emit: (varName: string, ctx: EmitContext): string => { + if (version === 4) { + const i = ctx.addRegex(IPV4_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isIP')};`; + } + if (version === 6) { + const i = ctx.addRegex(IPV6_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isIP')};`; + } + const i4 = ctx.addRegex(IPV4_RE); + const i6 = ctx.addRegex(IPV6_RE); + return `if (!re[${i4}].test(${varName}) && !re[${i6}].test(${varName})) ${ctx.fail('isIP')};`; + }, + }); +} + +// MAC Address +interface IsMACAddressOptions { + noSeparators?: boolean; +} + +const MAC_COLON_RE = /^[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}$/; +const MAC_HYPHEN_RE = /^[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}$/; +const MAC_NO_SEP_RE = /^[0-9a-fA-F]{12}$/; + +function isMACAddress(options?: IsMACAddressOptions): EmittableRule { + const noSeparators = options?.noSeparators ?? false; + return makeRule({ + name: 'isMACAddress', + requiresType: RequiredType.String, + constraints: { noSeparators }, + validate: value => { + if (typeof value !== 'string') { + return false; + } + if (noSeparators) { + return MAC_NO_SEP_RE.test(value); + } + return MAC_COLON_RE.test(value) || MAC_HYPHEN_RE.test(value); + }, + emit: (varName: string, ctx: EmitContext): string => { + if (noSeparators) { + const i = ctx.addRegex(MAC_NO_SEP_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isMACAddress')};`; + } + const i1 = ctx.addRegex(MAC_COLON_RE); + const i2 = ctx.addRegex(MAC_HYPHEN_RE); + return `if (!re[${i1}].test(${varName}) && !re[${i2}].test(${varName})) ${ctx.fail('isMACAddress')};`; + }, + }); +} + +// JWT — 3-part dot-separated base64url +const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const isJWT = makeStringRule( + 'isJWT', + v => JWT_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(JWT_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isJWT')};`; + }, +); + +// Locale — BCP 47 simplified. Variant subtags are `5*8alphanum` OR a digit followed by 3 alphanum +// (e.g. the `1996` orthography variant in `de-DE-1996`). +const LOCALE_RE = /^[a-zA-Z]{2,3}(?:-[a-zA-Z]{4})?(?:-(?:[a-zA-Z]{2}|\d{3}))?(?:-(?:[a-zA-Z\d]{5,8}|\d[a-zA-Z\d]{3}))*$/; +const isLocale = makeStringRule( + 'isLocale', + v => LOCALE_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(LOCALE_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isLocale')};`; + }, +); + +// DataURI +const DATA_URI_RE = /^data:([a-zA-Z0-9!#$&\-^_]+\/[a-zA-Z0-9!#$&\-^_]+)(?:;[a-zA-Z0-9-]+=[a-zA-Z0-9-]+)*(?:;base64)?,[\s\S]*$/; +const isDataURI = makeStringRule( + 'isDataURI', + v => DATA_URI_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(DATA_URI_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isDataURI')};`; + }, +); + +// FQDN +interface IsFQDNOptions { + requireTld?: boolean; + allowUnderscores?: boolean; + allowTrailingDot?: boolean; +} + +function isFQDN(options?: IsFQDNOptions): EmittableRule { + const requireTld = options?.requireTld !== false; + const allowUnderscores = options?.allowUnderscores ?? false; + const allowTrailingDot = options?.allowTrailingDot ?? false; + + const partRe = allowUnderscores ? /^[a-zA-Z0-9_-]+$/ : /^[a-zA-Z0-9-]+$/; + + const validateFqdn = (value: unknown): boolean => { + if (typeof value !== 'string') { + return false; + } + let str = value; + if (allowTrailingDot && str.endsWith('.')) { + str = str.slice(0, -1); + } + if (str.length === 0) { + return false; + } + const parts = str.split('.'); + if (requireTld && parts.length < 2) { + return false; + } + if (requireTld) { + const tld = parts[parts.length - 1]; + // `/^[a-zA-Z]{2,}$/` already rejects anything shorter than 2; `!tld` narrows the `string | undefined` index. + if (!tld || !/^[a-zA-Z]{2,}$/.test(tld)) { + return false; + } + } + return parts.every(part => { + if (part.length === 0 || part.length > 63) { + return false; + } + if (!partRe.test(part)) { + return false; + } + if (!allowUnderscores && (part.startsWith('-') || part.endsWith('-'))) { + return false; + } + return true; + }); + }; + + return makeRule({ + name: 'isFQDN', + requiresType: RequiredType.String, + constraints: { requireTld, allowUnderscores, allowTrailingDot }, + validate: validateFqdn, + emit: (varName: string, ctx: EmitContext): string => { + const ri = ctx.addRegex(partRe); + const tldRi = requireTld ? ctx.addRegex(/^[a-zA-Z]{2,}$/) : -1; + // Inline for-loop instead of fp.every(function(p){...}) — avoids per-call closure + // allocation inside the JIT executor. + const partCheck = + `if(p.length===0||p.length>63){fqOk=false;break;}` + + `if(!re[${ri}].test(p)){fqOk=false;break;}` + + (allowUnderscores ? '' : `if(p[0]==='-'||p[p.length-1]==='-'){fqOk=false;break;}`); + const loopBlock = `var fqOk=true;for(var fi=0;fi PORT_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(PORT_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isPort')};`; + }, +); + +// JSON +const validateJsonString = (value: unknown): boolean => { + if (typeof value !== 'string') { + return false; + } + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + +const isJSON = makeRule({ + name: 'isJSON', + requiresType: RequiredType.String, + constraints: {}, + validate: validateJsonString, + emit: (varName: string, ctx: EmitContext): string => `try { JSON.parse(${varName}); } catch { ${ctx.fail('isJSON')}; }`, +}); + +// MimeType +const MIME_TYPE_RE = + /^(application|audio|font|image|message|model|multipart|text|video)\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*(?:;.+)?$/; +const isMimeType = makeStringRule( + 'isMimeType', + v => MIME_TYPE_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(MIME_TYPE_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isMimeType')};`; + }, +); + +// Magnet URI +const MAGNET_URI_RE = /^magnet:\?xt=urn:[a-z0-9]+:[a-z0-9]{32,40}(?:&[a-z][a-z0-9.]*=[^&\s]*)*$/i; +const isMagnetURI = makeStringRule( + 'isMagnetURI', + v => MAGNET_URI_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(MAGNET_URI_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isMagnetURI')};`; + }, +); + +// ByteLength — counts UTF-8 bytes via Buffer.byteLength +function isByteLength(min: number, max?: number): EmittableRule { + const validateByteLength = (value: unknown): boolean => { + if (typeof value !== 'string') { + return false; + } + const byteLen = Buffer.byteLength(value, 'utf8'); + if (byteLen < min) { + return false; + } + if (max !== undefined && byteLen > max) { + return false; + } + return true; + }; + return makeRule({ + name: 'isByteLength', + requiresType: RequiredType.String, + constraints: { min, max }, + validate: validateByteLength, + emit: (varName: string, ctx: EmitContext): string => { + let code = `{var bl=Buffer.byteLength(${varName},'utf8');`; + code += `if(bl<${min})${ctx.fail('isByteLength')};`; + if (max !== undefined) { + code += `else if(bl>${max})${ctx.fail('isByteLength')};`; + } + code += '}'; + return code; + }, + }); +} + +// isPhoneNumber — E.164 international phone number + +const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/; + +const isPhoneNumber = makeStringRule( + 'isPhoneNumber', + v => PHONE_E164_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(PHONE_E164_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isPhoneNumber')};`; + }, +); + +// isStrongPassword — strong password check (factory) + +interface IsStrongPasswordOptions { + minLength?: number; + minLowercase?: number; + minUppercase?: number; + minNumbers?: number; + minSymbols?: number; +} + +function isStrongPassword(options?: IsStrongPasswordOptions): EmittableRule { + const minLength = options?.minLength ?? 8; + const minLower = options?.minLowercase ?? 1; + const minUpper = options?.minUppercase ?? 1; + const minNums = options?.minNumbers ?? 1; + const minSymbols = options?.minSymbols ?? 1; + + // Single-pass character classification — counts all categories in one scan. + // Replaces 4× v.match(/.../g) which allocates 4 result arrays per call. + const validate = (v: string): boolean => { + if (v.length < minLength) { + return false; + } + let lower = 0; + let upper = 0; + let nums = 0; + let symbols = 0; + for (let i = 0; i < v.length; i++) { + const c = v.charCodeAt(i); + if (c >= 97 && c <= 122) { + lower++; + } else if (c >= 65 && c <= 90) { + upper++; + } else if (c >= 48 && c <= 57) { + nums++; + } else { + symbols++; + } + } + return lower >= minLower && upper >= minUpper && nums >= minNums && symbols >= minSymbols; + }; + + return makeRule({ + name: 'isStrongPassword', + requiresType: RequiredType.String, + constraints: {}, + validate: value => typeof value === 'string' && validate(value), + emit: (varName: string, ctx: EmitContext): string => { + // Inline single-pass scan in the JIT executor — no regex match[] allocations + const failExpr = ctx.fail('isStrongPassword'); + const checks: string[] = []; + if (minLower > 0) { + checks.push(`spLo<${minLower}`); + } + if (minUpper > 0) { + checks.push(`spUp<${minUpper}`); + } + if (minNums > 0) { + checks.push(`spNum<${minNums}`); + } + if (minSymbols > 0) { + checks.push(`spSym<${minSymbols}`); + } + const guard = checks.length === 0 ? '' : `if(${checks.join('||')}){${failExpr}}`; + return ( + `if(${varName}.length<${minLength}){${failExpr}}else{` + + `var spLo=0,spUp=0,spNum=0,spSym=0;` + + `for(var spI=0;spI<${varName}.length;spI++){var spC=${varName}.charCodeAt(spI);` + + `if(spC>=97&&spC<=122)spLo++;else if(spC>=65&&spC<=90)spUp++;else if(spC>=48&&spC<=57)spNum++;else spSym++;}` + + guard + + `}` + ); + }, + }); +} + +// isTaxId — locale-specific tax identifier (factory) + +function isTaxId(locale: string): EmittableRule { + const re = TAX_ID_REGEXES[locale]; + if (!re) { + throw new BakerError(`Unsupported locale: "${locale}" for isTaxId`); + } + return makeRule({ + name: 'isTaxId', + requiresType: RequiredType.String, + constraints: { locale }, + validate: value => typeof value === 'string' && re.test(value), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRegex(re); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isTaxId')};`; + }, + }); +} + +export { + isEmail, + isURL, + isUUID, + isIP, + isMACAddress, + isJWT, + isLocale, + isDataURI, + isFQDN, + isPort, + isJSON, + isMimeType, + isMagnetURI, + isByteLength, + isPhoneNumber, + isStrongPassword, + isTaxId, +}; +export type { IsURLOptions, IsMACAddressOptions, IsFQDNOptions, IsStrongPasswordOptions }; diff --git a/src/rules/string-geo.ts b/src/rules/string-geo.ts new file mode 100644 index 0000000..377a589 --- /dev/null +++ b/src/rules/string-geo.ts @@ -0,0 +1,58 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +const NUMERIC_RANGE_RE = /^-?\d+(\.\d+)?$/; + +function rangeNumberOrString(name: string, lo: number, hi: number): EmittableRule { + const check = (value: unknown): boolean => { + if (typeof value === 'number') { + return value >= lo && value <= hi; + } + if (typeof value === 'string') { + // parseFloat('90abc') = 90 — strict regex rejects trailing garbage; a match guarantees parseFloat is valid. + if (!NUMERIC_RANGE_RE.test(value)) { + return false; + } + const n = parseFloat(value); + return n >= lo && n <= hi; + } + return false; + }; + return makeRule({ + name, + constraints: {}, + validate: check, + emit: (varName: string, ctx: EmitContext): string => { + const ri = ctx.addRegex(NUMERIC_RANGE_RE); + return ( + `if(typeof ${varName}==='number'){if(${varName}<${lo}||${varName}>${hi})${ctx.fail(name)};}` + + `else if(typeof ${varName}==='string'){` + + `if(!re[${ri}].test(${varName})){${ctx.fail(name)}}` + + `else{var rg=parseFloat(${varName});if(rg<${lo}||rg>${hi})${ctx.fail(name)};}}` + + `else{${ctx.fail(name)};}` + ); + }, + }); +} + +// LatLong +const LAT_LONG_RE = /^[-+]?([1-8]?\d(?:\.\d+)?|90(?:\.0+)?),\s*[-+]?(180(?:\.0+)?|1[0-7]\d(?:\.\d+)?|\d{1,2}(?:\.\d+)?)$/; + +function isLatLong(): EmittableRule { + return makeStringRule( + 'isLatLong', + v => LAT_LONG_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(LAT_LONG_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isLatLong')};`; + }, + ); +} + +const isLatitude = rangeNumberOrString('isLatitude', -90, 90); + +const isLongitude = rangeNumberOrString('isLongitude', -180, 180); + +export { isLatLong, isLatitude, isLongitude }; diff --git a/src/rules/string-identifier.spec.ts b/src/rules/string-identifier.spec.ts new file mode 100644 index 0000000..341a522 --- /dev/null +++ b/src/rules/string-identifier.spec.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './interfaces'; + +import { + isISO8601, + isISRC, + isISO31661Alpha2, + isISO31661Alpha3, + isFirebasePushId, + isSemVer, + isMongoId, + isDateString, +} from './string'; + +function makeCtx(refIndex: number = 0) { + const addRefMock = mock((_fn: unknown) => refIndex); + const addRegexMock = mock((_re: RegExp) => refIndex); + const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); + const ctx: Partial = { + addRegex: addRegexMock, + addRef: addRefMock, + addExecutor: mock(() => 0), + fail: failMock, + collectErrors: true, + }; + return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; +} + +describe('isISO8601', () => { + it('should return true for valid ISO 8601 date string', () => { + expect(isISO8601()('2023-01-01')).toBe(true); + }); + + it('should return true for valid ISO 8601 datetime string', () => { + expect(isISO8601()('2023-01-01T12:00:00Z')).toBe(true); + }); + + it('should return false for invalid date format', () => { + expect(isISO8601()('01-01-2023')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isISO8601', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isISO8601().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isISO8601'); + expect(isISO8601().ruleName).toBe('isISO8601'); + }); + + it('should return true for valid date with strict: true', () => { + expect(isISO8601({ strict: true })('2023-02-28')).toBe(true); + }); + + it('should return false for invalid month with strict: true', () => { + expect(isISO8601({ strict: true })('2023-13-01')).toBe(false); + }); + + it('should return false for invalid day with strict: true', () => { + expect(isISO8601({ strict: true })('2023-02-30')).toBe(false); + }); + + // Same proleptic Gregorian leap rule as isDateString — years 0–99 must not be remapped to 1900–1999. + it.each([ + ['0000-02-29', true], + ['0001-02-29', false], + ['2000-02-29', true], + ['1900-02-29', false], + ])('strict: true leap validity: %s -> %s', (input, expected) => { + expect(isISO8601({ strict: true })(input)).toBe(expected); + }); + + it('should reject an out-of-range month in a year-month string with strict: true', () => { + expect(isISO8601({ strict: true })('2021-13')).toBe(false); + expect(isISO8601({ strict: true })('2021-00')).toBe(false); + }); + + it('should accept a valid year-month string with strict: true', () => { + expect(isISO8601({ strict: true })('2021-12')).toBe(true); + }); + + it('strict: true emit uses inline regex + date validation (no addRef)', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + const code = isISO8601({ strict: true }).emit('v', ctx); + expect(addRefMock).not.toHaveBeenCalled(); + expect(code).toContain('re['); + expect(code).toContain('mo'); + expect(code).toContain('da'); + expect(failMock).toHaveBeenCalledWith('isISO8601'); + }); + + it('strict: true ruleName is isISO8601', () => { + expect(isISO8601({ strict: true }).ruleName).toBe('isISO8601'); + }); +}); + +describe('isISRC', () => { + it('should return true for valid ISRC', () => { + expect(isISRC('USRC17607839')).toBe(true); + }); + + it('should return false for invalid ISRC', () => { + expect(isISRC('INVALID')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isISRC', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isISRC.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isISRC'); + expect(isISRC.ruleName).toBe('isISRC'); + }); +}); +describe('isISO31661Alpha2', () => { + it('should return true for valid ISO 3166-1 alpha-2 code', () => { + expect(isISO31661Alpha2('US')).toBe(true); + }); + + it('should return true for lowercase valid code', () => { + expect(isISO31661Alpha2('us')).toBe(true); + }); + + it('should return false for invalid 2-letter code', () => { + expect(isISO31661Alpha2('XX')).toBe(false); + }); + + it('should call ctx.addRef and generate test code when calling emit() and have ruleName isISO31661Alpha2', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + isISO31661Alpha2.emit('v', ctx); + expect(addRefMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isISO31661Alpha2'); + expect(isISO31661Alpha2.ruleName).toBe('isISO31661Alpha2'); + }); +}); + +describe('isISO31661Alpha3', () => { + it('should return true for valid ISO 3166-1 alpha-3 code', () => { + expect(isISO31661Alpha3('USA')).toBe(true); + }); + + it('should return false for invalid 3-letter code', () => { + expect(isISO31661Alpha3('XXX')).toBe(false); + }); + + it('should return false for ANT (Netherlands Antilles, withdrawn from ISO 3166-1 in 2010)', () => { + expect(isISO31661Alpha3('ANT')).toBe(false); + }); + + it('should call ctx.addRef and generate test code when calling emit() and have ruleName isISO31661Alpha3', () => { + const { ctx, addRefMock, failMock } = makeCtx(0); + isISO31661Alpha3.emit('v', ctx); + expect(addRefMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isISO31661Alpha3'); + expect(isISO31661Alpha3.ruleName).toBe('isISO31661Alpha3'); + }); +}); + +describe('isFirebasePushId', () => { + it('should return true for valid Firebase Push ID (20 chars, base64url charset)', () => { + expect(isFirebasePushId('-KkI7fTh9VD5V7FTB5sl')).toBe(true); + }); + + it('should return false for ID with wrong length', () => { + expect(isFirebasePushId('abc')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isFirebasePushId', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isFirebasePushId.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isFirebasePushId'); + expect(isFirebasePushId.ruleName).toBe('isFirebasePushId'); + }); +}); + +describe('isSemVer', () => { + it('should return true for valid semantic version', () => { + expect(isSemVer('1.2.3')).toBe(true); + }); + + it('should return true for version with pre-release tag', () => { + expect(isSemVer('1.0.0-alpha.1')).toBe(true); + }); + + it('should return false for non-semver string', () => { + expect(isSemVer('1.2')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isSemVer', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isSemVer.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isSemVer'); + expect(isSemVer.ruleName).toBe('isSemVer'); + }); +}); + +describe('isMongoId', () => { + it('should return true for valid MongoDB ObjectId (24-char hex)', () => { + expect(isMongoId('507f1f77bcf86cd799439011')).toBe(true); + }); + + it('should return false for non-hex string', () => { + expect(isMongoId('507f1f77bcf86cd79943901g')).toBe(false); + }); + + it('should return false for wrong-length hex string', () => { + expect(isMongoId('507f1f77bcf86cd')).toBe(false); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMongoId', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isMongoId.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isMongoId'); + expect(isMongoId.ruleName).toBe('isMongoId'); + }); +}); + +describe('isDateString', () => { + it('should return true for valid ISO date string', () => { + expect(isDateString()('2023-01-15')).toBe(true); + }); + + it('should return false for invalid date string format', () => { + expect(isDateString()('15/01/2023')).toBe(false); + }); + + // Calendar validity must use the proleptic Gregorian leap rule for ALL years, including 0–99. + // `new Date(year, …)` remaps a 0–99 year argument to 1900–1999, so year 0 (a leap year by the + // 400 rule) was mis-judged against 1900 (not a leap year). + it.each([ + ['0000-02-29', true], // year 0: divisible by 400 -> leap + ['0001-02-29', false], // year 1: not divisible by 4 -> not leap + ['0004-02-29', true], // year 4: divisible by 4 -> leap + ['2000-02-29', true], // divisible by 400 -> leap + ['1900-02-29', false], // divisible by 100, not 400 -> not leap + ['2024-02-29', true], + ['2023-02-29', false], + ])('calendar leap validity: %s -> %s', (input, expected) => { + expect(isDateString()(input)).toBe(expected); + }); + + it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isDateString', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isDateString().emit('v', ctx); + expect(addRegexMock).toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledWith('isDateString'); + expect(isDateString().ruleName).toBe('isDateString'); + }); +}); diff --git a/src/rules/string-identifier.ts b/src/rules/string-identifier.ts new file mode 100644 index 0000000..f751610 --- /dev/null +++ b/src/rules/string-identifier.ts @@ -0,0 +1,240 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { ISO31661A2_CODES, ISO31661A3_CODES } from './constants'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +// Last calendar day of a month (1-based) under the proleptic Gregorian leap rule, valid for ALL years. +// `new Date(year, month, 0)` cannot be used: a 0–99 year argument is remapped to 1900–1999 (so year 0, +// a leap year by the 400 rule, would be judged against 1900). Pure arithmetic — also avoids a Date +// allocation in the validation hot path. +function lastDayOfMonth(year: number, month: number): number { + if (month === 2) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; + } + return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; +} + +// Codegen counterpart of `lastDayOfMonth` — inline expression over already-declared year/month vars. +function lastDayOfMonthExpr(yExpr: string, mExpr: string): string { + return `(${mExpr}===2?(((${yExpr}%4===0&&${yExpr}%100!==0)||${yExpr}%400===0)?29:28):(${mExpr}===4||${mExpr}===6||${mExpr}===9||${mExpr}===11?30:31))`; +} + +// ISO 8601 +const ISO8601_RE = /^\d{4}(?:-\d{2}(?:-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?)?)?$/; + +interface IsISO8601Options { + strict?: boolean; +} + +// Strict ISO8601: requires month/day AND hour/minute/second to be valid values +function validateISO8601Strict(v: string): boolean { + if (!ISO8601_RE.test(v)) { + return false; + } + const m = v.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?/); + if (!m) { + return true; + } // year-only — no month/day to range-check + const month = Number(m[2]); + if (month < 1 || month > 12) { + return false; + } + if (m[3] !== undefined) { + const day = Number(m[3]); + const maxDay = lastDayOfMonth(Number(m[1]), month); + if (day < 1 || day > maxDay) { + return false; + } + } + // Time component check: hour 0-23, minute 0-59, second 0-60 (leap second). + const tm = v.match(/T(\d{2}):(\d{2}):(\d{2})/); + if (!tm) { + return true; + } + const hh = Number(tm[1]); + const mm = Number(tm[2]); + const ss = Number(tm[3]); + return hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59 && ss >= 0 && ss <= 60; +} + +function isISO8601(options?: IsISO8601Options): EmittableRule { + if (options?.strict) { + const validateStrict = (v: unknown): boolean => typeof v === 'string' && validateISO8601Strict(v); + return makeRule({ + name: 'isISO8601', + requiresType: RequiredType.String, + constraints: { format: 'date-time', strict: true }, + validate: validateStrict, + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRegex(ISO8601_RE); + // Single `__iso_ok` flag so the rule fails AT MOST once: in collect-errors mode `ctx.fail()` + // pushes without returning, so the date and time checks must funnel into one failure. The time + // check runs only when the date portion is valid, mirroring `validateISO8601Strict`'s early returns. + return ( + `if (!re[${i}].test(${varName})) ${ctx.fail('isISO8601')};\n` + + `else {var __iso_ok=true;` + + `var dm=${varName}.match(/^(\\d{4})-(\\d{2})(?:-(\\d{2}))?/);` + + `if(dm){var mo=Number(dm[2]);` + + `if(mo<1||mo>12){__iso_ok=false;}` + + `else if(dm[3]!==undefined){var da=Number(dm[3]),dy=Number(dm[1]),md=${lastDayOfMonthExpr('dy', 'mo')};` + + `if(da<1||da>md){__iso_ok=false;}}}` + + `if(__iso_ok){var tm=${varName}.match(/T(\\d{2}):(\\d{2}):(\\d{2})/);` + + `if(tm){var hh=Number(tm[1]),mm=Number(tm[2]),ss=Number(tm[3]);` + + `if(hh<0||hh>23||mm<0||mm>59||ss<0||ss>60)__iso_ok=false;}}` + + `if(!__iso_ok)${ctx.fail('isISO8601')};}` + ); + }, + }); + } + // non-strict: both validate and emit use same ISO8601_RE + return makeStringRule( + 'isISO8601', + v => ISO8601_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ISO8601_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isISO8601')};`; + }, + RequiredType.String, + { format: 'date-time', strict: false }, + ); +} + +// ISRC — ISO 3901 +const ISRC_RE = /^[A-Z]{2}-[A-Z0-9]{3}-\d{2}-\d{5}$|^[A-Z]{2}[A-Z0-9]{3}\d{7}$/; +const isISRC = makeStringRule( + 'isISRC', + v => ISRC_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ISRC_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isISRC')};`; + }, +); + +const isISO31661Alpha2 = makeRule({ + name: 'isISO31661Alpha2', + requiresType: RequiredType.String, + constraints: {}, + validate: value => typeof value === 'string' && ISO31661A2_CODES.has(value.toUpperCase()), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRef(ISO31661A2_CODES); + return `if (!refs[${i}].has(${varName}.toUpperCase())) ${ctx.fail('isISO31661Alpha2')};`; + }, +}); + +const isISO31661Alpha3 = makeRule({ + name: 'isISO31661Alpha3', + requiresType: RequiredType.String, + constraints: {}, + validate: value => typeof value === 'string' && ISO31661A3_CODES.has(value.toUpperCase()), + emit: (varName: string, ctx: EmitContext): string => { + const i = ctx.addRef(ISO31661A3_CODES); + return `if (!refs[${i}].has(${varName}.toUpperCase())) ${ctx.fail('isISO31661Alpha3')};`; + }, +}); + +// Firebase Push ID — 20 chars, base64url charset (-0-9A-Za-z_) +const FIREBASE_RE = /^[a-zA-Z0-9_-]{20}$/; +const isFirebasePushId = makeStringRule( + 'isFirebasePushId', + v => FIREBASE_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(FIREBASE_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isFirebasePushId')};`; + }, +); + +// SemVer — Semantic Versioning 2.0 +const SEMVER_RE = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; +const isSemVer = makeStringRule( + 'isSemVer', + v => SEMVER_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(SEMVER_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isSemVer')};`; + }, +); + +// MongoDB ObjectId — 24-char hex +const MONGO_ID_RE = /^[0-9a-fA-F]{24}$/; +const isMongoId = makeStringRule( + 'isMongoId', + v => MONGO_ID_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(MONGO_ID_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isMongoId')};`; + }, +); + +// DateString — ISO 8601 date only (YYYY-MM-DD) with calendar validity (day must exist in month/year). +const DATE_STRING_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; + +function isCalendarValidDate(v: string): boolean { + if (!DATE_STRING_RE.test(v)) { + return false; + } + const y = Number(v.slice(0, 4)); + const m = Number(v.slice(5, 7)); + const d = Number(v.slice(8, 10)); + const maxDay = lastDayOfMonth(y, m); + return d >= 1 && d <= maxDay; +} + +function isDateString(): EmittableRule { + return makeStringRule('isDateString', isCalendarValidDate, (varName, ctx) => { + const i = ctx.addRegex(DATE_STRING_RE); + return ( + `if (!re[${i}].test(${varName})) ${ctx.fail('isDateString')};\n` + + `else { var y=Number(${varName}.slice(0,4)),m=Number(${varName}.slice(5,7)),d=Number(${varName}.slice(8,10));` + + `var md=${lastDayOfMonthExpr('y', 'm')}; if(d<1||d>md)${ctx.fail('isDateString')}; }` + ); + }); +} + +// ULID +const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/; + +function isULID(): EmittableRule { + return makeStringRule( + 'isULID', + v => ULID_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(ULID_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isULID')};`; + }, + RequiredType.String, + { format: 'ulid' }, + ); +} + +// CUID2 spec: length 24-32, lowercase alphanum, starts with a-z. +const CUID2_RE = /^[a-z][0-9a-z]{23,31}$/; + +function isCUID2(): EmittableRule { + return makeStringRule( + 'isCUID2', + v => CUID2_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(CUID2_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isCUID2')};`; + }, + RequiredType.String, + { format: 'cuid2' }, + ); +} + +export { + isISO8601, + isISRC, + isISO31661Alpha2, + isISO31661Alpha3, + isFirebasePushId, + isSemVer, + isMongoId, + isDateString, + isULID, + isCUID2, +}; +export type { IsISO8601Options }; diff --git a/src/rules/string-shared.ts b/src/rules/string-shared.ts new file mode 100644 index 0000000..7939f05 --- /dev/null +++ b/src/rules/string-shared.ts @@ -0,0 +1,24 @@ +import type { EmitContext, EmittableRule } from './interfaces'; + +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +export function makeStringRule( + name: string, + validate: (v: string) => boolean, + buildEmit: (varName: string, ctx: EmitContext) => string, + requiresType: RequiredType | undefined = RequiredType.String, + constraints: Record = {}, +): EmittableRule { + return makeRule({ + name, + requiresType, + constraints, + validate: value => typeof value === 'string' && validate(value), + emit: buildEmit, + }); +} diff --git a/src/rules/string-width.spec.ts b/src/rules/string-width.spec.ts new file mode 100644 index 0000000..ffd67ff --- /dev/null +++ b/src/rules/string-width.spec.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './interfaces'; + +import { isFullWidth, isHalfWidth, isVariableWidth, isMultibyte, isSurrogatePair } from './string'; + +function makeCtx(refIndex: number = 0) { + const addRefMock = mock((_fn: unknown) => refIndex); + const addRegexMock = mock((_re: RegExp) => refIndex); + const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); + const ctx: Partial = { + addRegex: addRegexMock, + addRef: addRefMock, + addExecutor: mock(() => 0), + fail: failMock, + collectErrors: true, + }; + return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; +} + +describe('isFullWidth', () => { + it('should return true for string containing full-width character', () => { + expect(isFullWidth('A')).toBe(true); + }); + + it('should return false for ASCII-only string', () => { + expect(isFullWidth('A')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isFullWidth', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isFullWidth.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isFullWidth'); + expect(isFullWidth.ruleName).toBe('isFullWidth'); + }); + + it('should return false for empty string', () => { + expect(isFullWidth('')).toBe(false); + }); +}); + +describe('isHalfWidth', () => { + it('should return true for string containing half-width character', () => { + expect(isHalfWidth('abc123')).toBe(true); + }); + + it('should return false for all full-width string', () => { + expect(isHalfWidth('ABCD')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isHalfWidth', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isHalfWidth.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isHalfWidth'); + expect(isHalfWidth.ruleName).toBe('isHalfWidth'); + }); + + it('should return false for empty string', () => { + expect(isHalfWidth('')).toBe(false); + }); +}); + +describe('isVariableWidth', () => { + it('should return true for string containing both full-width and half-width characters', () => { + expect(isVariableWidth('Aabc')).toBe(true); + }); + + it('should return false for all half-width string', () => { + expect(isVariableWidth('abc')).toBe(false); + }); + + it('should return false for all full-width string', () => { + expect(isVariableWidth('ABC')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isVariableWidth', () => { + const { ctx, failMock } = makeCtx(0); + const code = isVariableWidth.emit('v', ctx); + expect(code).toBeTruthy(); + expect(failMock).toHaveBeenCalledWith('isVariableWidth'); + expect(isVariableWidth.ruleName).toBe('isVariableWidth'); + }); + + // E-4: empty string → false (runtime and emit) + it('should return false for empty string', () => { + expect(isVariableWidth('')).toBe(false); + }); + + it('should emit code that fails for empty string (via FULLWIDTH+HALFWIDTH regex returning false)', () => { + const { ctx } = makeCtx(0); + const code = isVariableWidth.emit('v', ctx); + // Both regexes return false on empty input, so the codegen relies on the regex semantics + // rather than an explicit `.length === 0` guard. + expect(code).toContain('!re['); + expect(code).toContain('.test(v)'); + }); +}); + +describe('isMultibyte', () => { + it('should return true for string containing multibyte character', () => { + expect(isMultibyte('日本語')).toBe(true); + }); + + it('should return false for ASCII-only string', () => { + expect(isMultibyte('hello')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isMultibyte', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isMultibyte.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isMultibyte'); + expect(isMultibyte.ruleName).toBe('isMultibyte'); + }); + + it('should return false for empty string', () => { + expect(isMultibyte('')).toBe(false); + }); +}); + +describe('isSurrogatePair', () => { + it('should return true for string containing surrogate pair', () => { + expect(isSurrogatePair('\uD83D\uDE00')).toBe(true); + }); + + it('should return false for ASCII-only string', () => { + expect(isSurrogatePair('hello')).toBe(false); + }); + + it('should generate regex test code when calling emit() and have ruleName isSurrogatePair', () => { + const { ctx, addRegexMock, failMock } = makeCtx(0); + isSurrogatePair.emit('v', ctx); + expect(addRegexMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith('isSurrogatePair'); + expect(isSurrogatePair.ruleName).toBe('isSurrogatePair'); + }); + + it('should return false for empty string', () => { + expect(isSurrogatePair('')).toBe(false); + }); +}); diff --git a/src/rules/string-width.ts b/src/rules/string-width.ts new file mode 100644 index 0000000..99e4b52 --- /dev/null +++ b/src/rules/string-width.ts @@ -0,0 +1,59 @@ +import { makeStringRule } from './string-shared'; + +// Full-width characters (Unicode fullwidth forms) +const FULLWIDTH_RE = /[^\u0020-\u007E\uFF61-\uFF9F]/; +// Empty-string guard is redundant — non-anchored char-class regex returns false on empty input. +const isFullWidth = makeStringRule( + 'isFullWidth', + v => FULLWIDTH_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(FULLWIDTH_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isFullWidth')};`; + }, +); + +// Half-width characters +const HALFWIDTH_RE = /[\u0020-\u007E\uFF61-\uFF9F]/; +const isHalfWidth = makeStringRule( + 'isHalfWidth', + v => HALFWIDTH_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(HALFWIDTH_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isHalfWidth')};`; + }, +); + +// Variable-width: must contain both full-width AND half-width +const isVariableWidth = makeStringRule( + 'isVariableWidth', + v => FULLWIDTH_RE.test(v) && HALFWIDTH_RE.test(v), + (varName, ctx) => { + const i1 = ctx.addRegex(FULLWIDTH_RE); + const i2 = ctx.addRegex(HALFWIDTH_RE); + return `if (!re[${i1}].test(${varName}) || !re[${i2}].test(${varName})) ${ctx.fail('isVariableWidth')};`; + }, +); + +// Multibyte: any character outside Latin-1 / half-width range +const MULTIBYTE_RE = new RegExp(`[^${String.fromCharCode(0)}-${String.fromCharCode(0xff)}]`); +const isMultibyte = makeStringRule( + 'isMultibyte', + v => MULTIBYTE_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(MULTIBYTE_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isMultibyte')};`; + }, +); + +// Surrogate pairs +const SURROGATE_RE = /[\uD800-\uDBFF][\uDC00-\uDFFF]/; +const isSurrogatePair = makeStringRule( + 'isSurrogatePair', + v => SURROGATE_RE.test(v), + (varName, ctx) => { + const i = ctx.addRegex(SURROGATE_RE); + return `if (!re[${i}].test(${varName})) ${ctx.fail('isSurrogatePair')};`; + }, +); + +export { isFullWidth, isHalfWidth, isVariableWidth, isMultibyte, isSurrogatePair }; diff --git a/src/rules/string.spec.ts b/src/rules/string.spec.ts deleted file mode 100644 index 9d95634..0000000 --- a/src/rules/string.spec.ts +++ /dev/null @@ -1,2573 +0,0 @@ -import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; - -import type { EmitContext } from '../types'; - -import { - // Group A — length/range - minLength, - maxLength, - length, - contains, - notContains, - matches, - // Group B — simple boolean - isLowercase, - isUppercase, - isAscii, - isAlpha, - isAlphanumeric, - isBooleanString, - isNumberString, - isDecimal, - isFullWidth, - isHalfWidth, - isVariableWidth, - isMultibyte, - isSurrogatePair, - isHexadecimal, - isOctal, - // Group C — regex-based - isEmail, - isURL, - isUUID, - isIP, - isHexColor, - isRgbColor, - isHSL, - isMACAddress, - isISBN, - isISIN, - isISO8601, - isISRC, - isISSN, - isJWT, - isLatLong, - isLocale, - isDataURI, - isFQDN, - isPort, - isEAN, - isISO31661Alpha2, - isISO31661Alpha3, - isBIC, - isFirebasePushId, - isSemVer, - isMongoId, - isJSON, - isBase32, - isBase58, - isBase64, - isDateString, - isMimeType, - isCurrency, - isMagnetURI, - // Group D — algorithm-based - isCreditCard, - isIBAN, - isByteLength, - // Group E — new validators - isHash, - isRFC3339, - isMilitaryTime, - isLatitude, - isLongitude, - isEthereumAddress, - isBtcAddress, - isISO4217CurrencyCode, - isPhoneNumber, - isStrongPassword, - isTaxId, - isHttpToken, - isOrigin, - isCorsOrigin, -} from './string'; - -function makeCtx(refIndex: number = 0) { - const addRefMock = mock((_fn: unknown) => refIndex); - const addRegexMock = mock((_re: RegExp) => refIndex); - const failMock = mock((code: string) => `_errors.push({path:'x',code:'${code}'})`); - const ctx: Partial = { - addRegex: addRegexMock, - addRef: addRefMock, - addExecutor: mock(() => 0), - fail: failMock, - collectErrors: true, - }; - return { ctx: ctx as EmitContext, addRefMock, addRegexMock, failMock }; -} - -// ─── Group A: Length / Range ────────────────────────────────────────────────── - -describe('minLength', () => { - it('should return true when string length equals minimum', () => { - const rule = minLength(3); - expect(rule('abc')).toBe(true); - }); - - it('should return true when string length exceeds minimum', () => { - const rule = minLength(3); - expect(rule('abcde')).toBe(true); - }); - - it('should return false when string length is less than minimum', () => { - const rule = minLength(3); - expect(rule('ab')).toBe(false); - }); - - it('should return true for empty string when minimum is 0', () => { - const rule = minLength(0); - expect(rule('')).toBe(true); - }); - - it('should generate v.length < n check code when calling emit()', () => { - const rule = minLength(3); - const { ctx, failMock } = makeCtx(); - const code = rule.emit('v', ctx); - expect(code).toContain('v.length < 3'); - expect(failMock).toHaveBeenCalledWith('minLength'); - }); - - it('should have ruleName minLength and requiresType string', () => { - const rule = minLength(3); - expect(rule.ruleName).toBe('minLength'); - expect(rule.requiresType).toBe(RequiredType.String); - }); - - it('should return independent rule objects on multiple factory calls', () => { - const r1 = minLength(3); - const r2 = minLength(3); - expect(r1).not.toBe(r2); - }); -}); - -describe('maxLength', () => { - it('should return true when string length is within maximum', () => { - const rule = maxLength(5); - expect(rule('abc')).toBe(true); - }); - - it('should return true when string length equals maximum', () => { - const rule = maxLength(5); - expect(rule('abcde')).toBe(true); - }); - - it('should return false when string length exceeds maximum', () => { - const rule = maxLength(5); - expect(rule('abcdef')).toBe(false); - }); - - it('should return true for empty string when maximum is 0', () => { - const rule = maxLength(0); - expect(rule('')).toBe(true); - }); - - it('should generate v.length > n check code when calling emit()', () => { - const rule = maxLength(5); - const { ctx, failMock } = makeCtx(); - const code = rule.emit('v', ctx); - expect(code).toContain('v.length > 5'); - expect(failMock).toHaveBeenCalledWith('maxLength'); - }); - - it('should have ruleName maxLength and requiresType string', () => { - const rule = maxLength(5); - expect(rule.ruleName).toBe('maxLength'); - expect(rule.requiresType).toBe(RequiredType.String); - }); -}); - -describe('length', () => { - it('should return true when string length is within range', () => { - const rule = length(3, 5); - expect(rule('abcd')).toBe(true); - }); - - it('should return true when string length equals minimum boundary', () => { - const rule = length(3, 5); - expect(rule('abc')).toBe(true); - }); - - it('should return true when string length equals maximum boundary', () => { - const rule = length(3, 5); - expect(rule('abcde')).toBe(true); - }); - - it('should return false when string length is below minimum', () => { - const rule = length(3, 5); - expect(rule('ab')).toBe(false); - }); - - it('should return false when string length exceeds maximum', () => { - const rule = length(3, 5); - expect(rule('abcdef')).toBe(false); - }); - - it('should return true for exact single length when min equals max', () => { - const rule = length(3, 3); - expect(rule('abc')).toBe(true); - }); - - it('should generate range check code when calling emit()', () => { - const rule = length(3, 5); - const { ctx, failMock } = makeCtx(); - const code = rule.emit('v', ctx); - expect(code).toContain('v.length < 3'); - expect(code).toContain('v.length > 5'); - expect(failMock).toHaveBeenCalledWith('length'); - }); - - it('should have ruleName length and requiresType string', () => { - const rule = length(3, 5); - expect(rule.ruleName).toBe('length'); - expect(rule.requiresType).toBe(RequiredType.String); - }); -}); - -describe('contains', () => { - it('should return true when string contains seed', () => { - const rule = contains('foo'); - expect(rule('foobar')).toBe(true); - }); - - it('should return false when string does not contain seed', () => { - const rule = contains('foo'); - expect(rule('barbaz')).toBe(false); - }); - - it('should call ctx.addRef with seed and generate includes check when calling emit()', () => { - const rule = contains('foo'); - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = rule.emit('v', ctx); - expect(addRefMock).toHaveBeenCalledTimes(1); - expect(addRefMock).toHaveBeenCalledWith('foo'); - expect(code).toContain('refs[0]'); - expect(failMock).toHaveBeenCalledWith('contains'); - }); - - it('should have ruleName contains and requiresType string', () => { - const rule = contains('foo'); - expect(rule.ruleName).toBe('contains'); - expect(rule.requiresType).toBe(RequiredType.String); - }); -}); - -describe('notContains', () => { - it('should return true when string does not contain seed', () => { - const rule = notContains('foo'); - expect(rule('barbaz')).toBe(true); - }); - - it('should return false when string contains seed', () => { - const rule = notContains('foo'); - expect(rule('foobar')).toBe(false); - }); - - it('should call ctx.addRef with seed and generate inverse includes check when calling emit()', () => { - const rule = notContains('foo'); - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = rule.emit('v', ctx); - expect(addRefMock).toHaveBeenCalledTimes(1); - expect(code).toContain('refs[0]'); - expect(failMock).toHaveBeenCalledWith('notContains'); - }); - - it('should have ruleName notContains', () => { - const rule = notContains('foo'); - expect(rule.ruleName).toBe('notContains'); - }); -}); - -describe('matches', () => { - it('should return true when string matches pattern', () => { - const rule = matches(/^[a-z]+$/); - expect(rule('hello')).toBe(true); - }); - - it('should return false when string does not match pattern', () => { - const rule = matches(/^[a-z]+$/); - expect(rule('Hello123')).toBe(false); - }); - - it('should support string pattern with modifiers', () => { - const rule = matches('^[a-z]+$', 'i'); - expect(rule('HELLO')).toBe(true); - }); - - it('should call ctx.addRegex and generate test check code when calling emit()', () => { - const rule = matches(/^[a-z]+$/); - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = rule.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toContain('re[0]'); - expect(code).toContain('.test('); - expect(failMock).toHaveBeenCalledWith('matches'); - }); - - it('should have ruleName matches and requiresType string', () => { - const rule = matches(/^[a-z]+$/); - expect(rule.ruleName).toBe('matches'); - expect(rule.requiresType).toBe(RequiredType.String); - }); - - it('should return false for empty string when pattern requires content', () => { - const rule = matches(/^[a-z]+$/); - expect(rule('')).toBe(false); - }); -}); - -// ─── Group B: Simple Boolean Checks ────────────────────────────────────────── - -describe('isLowercase', () => { - it('should return true for all lowercase string', () => { - expect(isLowercase('hello world')).toBe(true); - }); - - it('should return false when string contains uppercase character', () => { - expect(isLowercase('Hello')).toBe(false); - }); - - it('should generate toLowerCase comparison code when calling emit() and have ruleName isLowercase', () => { - const { ctx, failMock } = makeCtx(); - const code = isLowercase.emit('v', ctx); - expect(code).toContain('toLowerCase'); - expect(failMock).toHaveBeenCalledWith('isLowercase'); - expect(isLowercase.ruleName).toBe('isLowercase'); - expect(isLowercase.requiresType).toBe(RequiredType.String); - }); - - it('should return true for empty string', () => { - expect(isLowercase('')).toBe(true); - }); -}); - -describe('isUppercase', () => { - it('should return true for all uppercase string', () => { - expect(isUppercase('HELLO WORLD')).toBe(true); - }); - - it('should return false when string contains lowercase character', () => { - expect(isUppercase('Hello')).toBe(false); - }); - - it('should generate toUpperCase comparison code when calling emit() and have ruleName isUppercase', () => { - const { ctx, failMock } = makeCtx(); - const code = isUppercase.emit('v', ctx); - expect(code).toContain('toUpperCase'); - expect(failMock).toHaveBeenCalledWith('isUppercase'); - expect(isUppercase.ruleName).toBe('isUppercase'); - }); - - it('should return true for empty string', () => { - expect(isUppercase('')).toBe(true); - }); -}); - -describe('isAscii', () => { - it('should return true for ASCII-only string', () => { - expect(isAscii('Hello World! 123')).toBe(true); - }); - - it('should return false when string contains non-ASCII character', () => { - expect(isAscii('café')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isAscii', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isAscii.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toContain('re[0]'); - expect(failMock).toHaveBeenCalledWith('isAscii'); - expect(isAscii.ruleName).toBe('isAscii'); - }); - - it('should return true for empty string', () => { - expect(isAscii('')).toBe(true); - }); -}); - -describe('isHttpToken', () => { - // RFC 9110 §5.6.2 token = 1*tchar - it('should return true for valid tokens (methods, field-names, tchar-only)', () => { - for (const v of [ - 'GET', - 'POST', - 'X-Foo', - 'X-Custom-Header', - 'Content-Type', - 'PROPFIND', - 'MKCALENDAR', - 'M-SEARCH', - 'foo.bar', - '!#$%&', - "!#$%&'*+-.^_`|~", - 'a`b', - ]) { - expect(isHttpToken(v)).toBe(true); - } - }); - - it('should return false for non-tokens (separators, spaces, CTL, non-ASCII)', () => { - for (const v of [ - '', - ' ', - 'X Foo', - 'X-Foo(bar)', - 'X-Foo:Bar', - 'X-Foo,Bar', - 'X-Foo;', - 'X-Foo<>', - 'X-Foo\t', - 'X-Foo\n', - 'X-Foo\r', - 'GET\n', - '\nGET', - 'X-한글', - ]) { - expect(isHttpToken(v)).toBe(false); - } - }); - - it('should generate regex test code when calling emit() and have ruleName isHttpToken', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isHttpToken.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toContain('re[0]'); - expect(failMock).toHaveBeenCalledWith('isHttpToken'); - expect(isHttpToken.ruleName).toBe('isHttpToken'); - }); -}); - -describe('isOrigin', () => { - // RFC 6454 §6.2 serialized origin — WHATWG URL `.origin` byte-equality. - it('should return true for canonical serialized origins and the opaque "null" literal', () => { - for (const v of [ - 'https://a.com', - 'https://a.com:8080', - 'http://localhost', - 'http://localhost:3000', - 'https://[::1]', - 'https://[::1]:8443', - 'https://xn--bj0bj06e.com', // punycode IDN - 'ws://a.com', // WebSocket origin (RFC 6455 §10.2) — tuple origin - 'wss://a.com:8443', - 'null', // RFC 6454 §6.2 opaque origin literal (does not parse via URL) - ]) { - expect(isOrigin(v)).toBe(true); - } - }); - - it('should return false for non-canonical forms, parse failures, and the CORS wildcard', () => { - for (const v of [ - '', - ' ', - 'https://a.com/', // trailing slash - 'https://a.com/path', // path - 'https://a.com?q=1', // query - 'https://a.com#h', // fragment - 'HTTPS://A.COM', // uppercase scheme + host - 'https://A.com', // mixed-case host - 'https://a.com:443', // explicit default port (https) - 'http://a.com:80', // explicit default port (http) - 'http://[::1]:80', // IPv6 explicit default port - 'https://user:pass@a.com', // userinfo — URL.origin strips credentials - 'https://user@a.com', // userinfo (user only) - ' https://a.com', // leading whitespace — URL trims, byte-mismatch - 'https://한글.com', // raw IDN unicode (punycode required) - 'not-a-url', // parse failure - 'file:///x', // opaque scheme → URL.origin === 'null' - 'data:text/plain,foo', // opaque scheme → URL.origin === 'null' - 'blob:https://a.com/uuid', // blob → URL.origin === 'https://a.com' ≠ input - '*', // CORS wildcard — rejected by general isOrigin - ]) { - expect(isOrigin(v)).toBe(false); - } - }); - - it('should return false for non-string input', () => { - expect(isOrigin(42 as unknown as string)).toBe(false); - expect(isOrigin(null as unknown as string)).toBe(false); - expect(isOrigin(undefined as unknown as string)).toBe(false); - }); - - it('should generate a refs[] predicate call when calling emit() and have ruleName isOrigin', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = isOrigin.emit('v', ctx); - expect(addRefMock).toHaveBeenCalledTimes(1); - expect(addRefMock.mock.calls[0]?.[0]).toBeInstanceOf(Function); - expect(code).toContain('refs[0](v)'); // must actually call the predicate, not just reference it - expect(failMock).toHaveBeenCalledWith('isOrigin'); - expect(isOrigin.ruleName).toBe('isOrigin'); - expect(isOrigin.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isCorsOrigin', () => { - // CORS-only superset of isOrigin: additionally accepts the '*' wildcard literal. - it('should return true for everything isOrigin accepts plus the "*" wildcard', () => { - for (const v of [ - 'https://a.com', - 'https://a.com:8080', // non-default port - 'http://localhost', - 'https://[::1]', - 'https://xn--bj0bj06e.com', - 'ws://a.com', // superset of isOrigin — WebSocket origin - 'wss://a.com:8443', - 'null', - '*', // CORS wildcard literal - ]) { - expect(isCorsOrigin(v)).toBe(true); - } - }); - - it('should return false for non-canonical forms and parse failures', () => { - for (const v of [ - '', - ' ', - 'https://a.com/', - 'HTTPS://A.COM', - 'https://a.com:443', - 'https://user:pass@a.com', // userinfo stripped → byte-mismatch - 'https://한글.com', - 'not-a-url', - 'file:///x', - '**', // not the bare wildcard - ]) { - expect(isCorsOrigin(v)).toBe(false); - } - }); - - it('should return false for non-string input', () => { - expect(isCorsOrigin(42 as unknown as string)).toBe(false); - expect(isCorsOrigin(null as unknown as string)).toBe(false); - }); - - it('should generate a refs[] predicate call when calling emit() and have ruleName isCorsOrigin', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = isCorsOrigin.emit('v', ctx); - expect(addRefMock).toHaveBeenCalledTimes(1); - expect(addRefMock.mock.calls[0]?.[0]).toBeInstanceOf(Function); - expect(code).toContain('refs[0](v)'); // must actually call the predicate, not just reference it - expect(failMock).toHaveBeenCalledWith('isCorsOrigin'); - expect(isCorsOrigin.ruleName).toBe('isCorsOrigin'); - expect(isCorsOrigin.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isAlpha', () => { - it('should return true for alphabetic-only string with default locale', () => { - expect(isAlpha('HelloWorld')).toBe(true); - }); - - it('should return false when string contains digit', () => { - expect(isAlpha('Hello1')).toBe(false); - }); - - it('should return false when string contains space', () => { - expect(isAlpha('Hello World')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isAlpha', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isAlpha.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toContain('re[0]'); - expect(failMock).toHaveBeenCalledWith('isAlpha'); - expect(isAlpha.ruleName).toBe('isAlpha'); - }); -}); - -describe('isAlphanumeric', () => { - it('should return true for alphanumeric string with default locale', () => { - expect(isAlphanumeric('Hello123')).toBe(true); - }); - - it('should return false when string contains special character', () => { - expect(isAlphanumeric('Hello!')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isAlphanumeric', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isAlphanumeric.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isAlphanumeric'); - expect(isAlphanumeric.ruleName).toBe('isAlphanumeric'); - }); - - it('should return false for empty string', () => { - expect(isAlphanumeric('')).toBe(false); - }); -}); - -describe('isBooleanString', () => { - it('should return true for "true"', () => { - expect(isBooleanString('true')).toBe(true); - }); - - it('should return true for "false"', () => { - expect(isBooleanString('false')).toBe(true); - }); - - it('should return true for "1"', () => { - expect(isBooleanString('1')).toBe(true); - }); - - it('should return true for "0"', () => { - expect(isBooleanString('0')).toBe(true); - }); - - it('should return false for arbitrary string', () => { - expect(isBooleanString('yes')).toBe(false); - }); - - it('should generate inline boolean check code when calling emit() and have ruleName isBooleanString', () => { - const { ctx, failMock } = makeCtx(); - const code = isBooleanString.emit('v', ctx); - expect(code).toContain('true'); - expect(code).toContain('false'); - expect(failMock).toHaveBeenCalledWith('isBooleanString'); - expect(isBooleanString.ruleName).toBe('isBooleanString'); - }); -}); - -describe('isNumberString', () => { - it('should return true for integer string', () => { - expect(isNumberString()('42')).toBe(true); - }); - - it('should return true for decimal string', () => { - expect(isNumberString()('3.14')).toBe(true); - }); - - it('should return false for non-numeric string', () => { - expect(isNumberString()('hello')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isNumberString()('')).toBe(false); - }); - - it('should return false for whitespace-only string', () => { - expect(isNumberString()(' ')).toBe(false); - }); - - it('should return false for a hex literal string', () => { - expect(isNumberString()('0x1A')).toBe(false); - }); - - it('should return false for a numeric value padded with whitespace', () => { - expect(isNumberString()(' 12 ')).toBe(false); - }); - - it('should return false for scientific notation', () => { - expect(isNumberString()('1e5')).toBe(false); - }); - - it('should return true for a leading-dot decimal', () => { - expect(isNumberString()('.5')).toBe(true); - }); - - it('should return false for a trailing-dot number', () => { - expect(isNumberString()('5.')).toBe(false); - }); - - it('should generate number check code when calling emit() and have ruleName isNumberString', () => { - const { ctx, failMock } = makeCtx(); - const code = isNumberString().emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isNumberString'); - expect(isNumberString().ruleName).toBe('isNumberString'); - }); - - it('should emit a regex test (not Number coercion)', () => { - const { ctx } = makeCtx(); - const code = isNumberString().emit('v', ctx); - expect(code).toContain('re['); - expect(code).not.toContain('Number('); - expect(code).not.toContain('isFinite'); - }); -}); - -describe('isNumberString — no_symbols option', () => { - it('should reject "+123" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('+123')).toBe(false); - }); - - it('should reject "-456" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('-456')).toBe(false); - }); - - it('should reject "1.5" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('1.5')).toBe(false); - }); - - it('should reject "1e5" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('1e5')).toBe(false); - }); - - it('should accept "123" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('123')).toBe(true); - }); - - it('should accept "0" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('0')).toBe(true); - }); - - it('should accept "+123" when no_symbols is false (default)', () => { - expect(isNumberString({ no_symbols: false })('+123')).toBe(true); - }); - - it('should accept "+123" when no options provided', () => { - expect(isNumberString()('+123')).toBe(true); - }); -}); - -describe('isDecimal', () => { - it('should return true for decimal number string', () => { - expect(isDecimal()('1.5')).toBe(true); - }); - - it('should return true for integer string (no decimal required)', () => { - expect(isDecimal()('42')).toBe(true); - }); - - it('should return false for non-numeric string', () => { - expect(isDecimal()('hello')).toBe(false); - }); - - it('should return false for a trailing-dot number', () => { - expect(isDecimal()('5.')).toBe(false); - }); - - it('should return true for a leading-dot decimal', () => { - expect(isDecimal()('.5')).toBe(true); - }); - - it('should generate regex check code when calling emit() and have ruleName isDecimal', () => { - const { ctx, failMock } = makeCtx(0); - const code = isDecimal().emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isDecimal'); - expect(isDecimal().ruleName).toBe('isDecimal'); - }); -}); - -describe('isFullWidth', () => { - it('should return true for string containing full-width character', () => { - expect(isFullWidth('A')).toBe(true); - }); - - it('should return false for ASCII-only string', () => { - expect(isFullWidth('A')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isFullWidth', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isFullWidth.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isFullWidth'); - expect(isFullWidth.ruleName).toBe('isFullWidth'); - }); - - it('should return false for empty string', () => { - expect(isFullWidth('')).toBe(false); - }); -}); - -describe('isHalfWidth', () => { - it('should return true for string containing half-width character', () => { - expect(isHalfWidth('abc123')).toBe(true); - }); - - it('should return false for all full-width string', () => { - expect(isHalfWidth('ABCD')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isHalfWidth', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isHalfWidth.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isHalfWidth'); - expect(isHalfWidth.ruleName).toBe('isHalfWidth'); - }); - - it('should return false for empty string', () => { - expect(isHalfWidth('')).toBe(false); - }); -}); - -describe('isVariableWidth', () => { - it('should return true for string containing both full-width and half-width characters', () => { - expect(isVariableWidth('Aabc')).toBe(true); - }); - - it('should return false for all half-width string', () => { - expect(isVariableWidth('abc')).toBe(false); - }); - - it('should return false for all full-width string', () => { - expect(isVariableWidth('ABC')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isVariableWidth', () => { - const { ctx, failMock } = makeCtx(0); - const code = isVariableWidth.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isVariableWidth'); - expect(isVariableWidth.ruleName).toBe('isVariableWidth'); - }); - - // E-4: empty string → false (runtime and emit) - it('should return false for empty string', () => { - expect(isVariableWidth('')).toBe(false); - }); - - it('should emit code that fails for empty string (via FULLWIDTH+HALFWIDTH regex returning false)', () => { - const { ctx } = makeCtx(0); - const code = isVariableWidth.emit('v', ctx); - // Both regexes return false on empty input, so the codegen relies on the regex semantics - // rather than an explicit `.length === 0` guard. - expect(code).toContain('!re['); - expect(code).toContain('.test(v)'); - }); -}); - -describe('isMultibyte', () => { - it('should return true for string containing multibyte character', () => { - expect(isMultibyte('日本語')).toBe(true); - }); - - it('should return false for ASCII-only string', () => { - expect(isMultibyte('hello')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isMultibyte', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isMultibyte.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isMultibyte'); - expect(isMultibyte.ruleName).toBe('isMultibyte'); - }); - - it('should return false for empty string', () => { - expect(isMultibyte('')).toBe(false); - }); -}); - -describe('isSurrogatePair', () => { - it('should return true for string containing surrogate pair', () => { - expect(isSurrogatePair('\uD83D\uDE00')).toBe(true); - }); - - it('should return false for ASCII-only string', () => { - expect(isSurrogatePair('hello')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isSurrogatePair', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isSurrogatePair.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isSurrogatePair'); - expect(isSurrogatePair.ruleName).toBe('isSurrogatePair'); - }); - - it('should return false for empty string', () => { - expect(isSurrogatePair('')).toBe(false); - }); -}); - -describe('isHexadecimal', () => { - it('should return true for hexadecimal string', () => { - expect(isHexadecimal('deadbeef')).toBe(true); - }); - - it('should return true for uppercase hex string', () => { - expect(isHexadecimal('DEADBEEF')).toBe(true); - }); - - it('should return false for non-hex character', () => { - expect(isHexadecimal('xyz')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isHexadecimal', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isHexadecimal.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isHexadecimal'); - expect(isHexadecimal.ruleName).toBe('isHexadecimal'); - expect(isHexadecimal.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isOctal', () => { - it('should return true for octal string', () => { - expect(isOctal('0755')).toBe(true); - }); - - it('should return false for string containing 8 or 9', () => { - expect(isOctal('089')).toBe(false); - }); - - it('should generate regex test code when calling emit() and have ruleName isOctal', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isOctal.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isOctal'); - expect(isOctal.ruleName).toBe('isOctal'); - }); - - it('should return false for empty string', () => { - expect(isOctal('')).toBe(false); - }); -}); - -// ─── Group C: Regex-based ───────────────────────────────────────────────────── - -describe('isEmail', () => { - it('should return true for valid email address', () => { - expect(isEmail()('user@example.com')).toBe(true); - }); - - it('should return true for email with subdomain', () => { - expect(isEmail()('user@mail.example.co.uk')).toBe(true); - }); - - it('should return true for email with plus sign in local part', () => { - expect(isEmail()('user+tag@example.com')).toBe(true); - }); - - it('should return false for email without at sign', () => { - expect(isEmail()('userexample.com')).toBe(false); - }); - - it('should return false for email without domain', () => { - expect(isEmail()('user@')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isEmail()('')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit()', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isEmail().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toContain('re[0]'); - expect(failMock).toHaveBeenCalledWith('isEmail'); - }); - - it('should have ruleName isEmail and requiresType string', () => { - expect(isEmail().ruleName).toBe('isEmail'); - expect(isEmail().requiresType).toBe(RequiredType.String); - }); -}); - -describe('isURL', () => { - it('should return true for valid http URL', () => { - expect(isURL()('http://example.com')).toBe(true); - }); - - it('should return true for valid https URL', () => { - expect(isURL()('https://example.com/path?q=1')).toBe(true); - }); - - it('should return false for URL without protocol', () => { - expect(isURL()('example.com')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isURL()('')).toBe(false); - }); - - it('should return true for URL with allowedProtocols option matching', () => { - expect(isURL({ protocols: ['ftp'] })('ftp://ftp.example.com')).toBe(true); - }); - - it('should return false for URL with protocol not in allowedProtocols', () => { - expect(isURL({ protocols: ['https'] })('http://example.com')).toBe(false); - }); - - it('should generate regex-based code when calling emit()', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isURL().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isURL'); - }); - - it('should have ruleName isURL and requiresType string', () => { - expect(isURL().ruleName).toBe('isURL'); - expect(isURL().requiresType).toBe(RequiredType.String); - }); -}); - -describe('isUUID', () => { - it('should return true for valid UUID v4 without version constraint', () => { - expect(isUUID()('550e8400-e29b-41d4-a716-446655440000')).toBe(true); - }); - - it('should return true for UUID v4 with version 4 constraint', () => { - expect(isUUID(4)('550e8400-e29b-41d4-a716-446655440000')).toBe(true); - }); - - it('should return false for invalid UUID format', () => { - expect(isUUID()('not-a-uuid')).toBe(false); - }); - - it('should return false for UUID v4 with version 3 constraint', () => { - expect(isUUID(3)('550e8400-e29b-41d4-a716-446655440000')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isUUID()('')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit()', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isUUID().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toContain('re[0]'); - expect(failMock).toHaveBeenCalledWith('isUUID'); - }); - - it('should have ruleName isUUID and requiresType string', () => { - expect(isUUID().ruleName).toBe('isUUID'); - expect(isUUID().requiresType).toBe(RequiredType.String); - }); -}); - -describe('isIP', () => { - it('should return true for valid IPv4 address', () => { - expect(isIP()('192.168.1.1')).toBe(true); - }); - - it('should return true for valid IPv6 address', () => { - expect(isIP()('2001:db8::1')).toBe(true); - }); - - it('should return true for IPv4 loopback', () => { - expect(isIP()('127.0.0.1')).toBe(true); - }); - - it('should return false for IP with octet out of range', () => { - expect(isIP()('999.999.999.999')).toBe(false); - }); - - it('should return true for valid IPv4 with version 4 constraint', () => { - expect(isIP(4)('192.168.1.1')).toBe(true); - }); - - it('should return false for IPv6 with version 4 constraint', () => { - expect(isIP(4)('2001:db8::1')).toBe(false); - }); - - it('should return true for IPv6 with version 6 constraint', () => { - expect(isIP(6)('::1')).toBe(true); - }); - - it('should call ctx.addRegex and generate test code when calling emit()', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isIP().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isIP'); - }); - - it('should generate IPv4-only check code when emit() is called with version 4', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isIP(4).emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isIP'); - }); - - it('should generate IPv6-only check code when emit() is called with version 6', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isIP(6).emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isIP'); - }); - - it('should have ruleName isIP and requiresType string', () => { - expect(isIP().ruleName).toBe('isIP'); - expect(isIP().requiresType).toBe(RequiredType.String); - }); -}); - -describe('isHexColor', () => { - it('should return true for valid 6-digit hex color', () => { - expect(isHexColor('#ff0000')).toBe(true); - }); - - it('should return true for valid 3-digit hex color', () => { - expect(isHexColor('#f00')).toBe(true); - }); - - it('should return false for hex color without hash', () => { - expect(isHexColor('ff0000')).toBe(false); - }); - - it('should return false for invalid hex color', () => { - expect(isHexColor('#xyz')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isHexColor', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isHexColor.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isHexColor'); - expect(isHexColor.ruleName).toBe('isHexColor'); - expect(isHexColor.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isRgbColor', () => { - it('should return true for valid rgb() color', () => { - expect(isRgbColor()('rgb(255,0,0)')).toBe(true); - }); - - it('should return true for valid rgba() color', () => { - expect(isRgbColor()('rgba(255,0,0,0.5)')).toBe(true); - }); - - it('should return false for invalid rgb color', () => { - expect(isRgbColor()('rgb(256,0,0)')).toBe(false); - }); - - it('should return true for rgb with percentage values when includePercentValues is true', () => { - expect(isRgbColor(true)('rgb(100%,0%,0%)')).toBe(true); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isRgbColor', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isRgbColor().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isRgbColor'); - expect(isRgbColor().ruleName).toBe('isRgbColor'); - }); - - it('should generate percent-regex check code when emit() is called with includePercentValues=true', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isRgbColor(true).emit('v', ctx); - // Percent mode registers 4 regex slots: rgb-percent, rgba-percent, rgb-int, rgba-int. - expect(addRegexMock).toHaveBeenCalledTimes(4); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isRgbColor'); - }); -}); - -describe('isHSL', () => { - it('should return true for valid hsl() color', () => { - expect(isHSL('hsl(360,100%,50%)')).toBe(true); - }); - - it('should return true for valid hsla() color', () => { - expect(isHSL('hsla(360,100%,50%,0.5)')).toBe(true); - }); - - it('should return false for invalid hsl color', () => { - expect(isHSL('hsl(400,100%,50%)')).toBe(false); - }); - - it('should return false for hsl() carrying an alpha channel (alpha is only valid on hsla())', () => { - expect(isHSL('hsl(120,50%,50%,0.5)')).toBe(false); - }); - - it('should return false for hsla() missing the alpha channel', () => { - expect(isHSL('hsla(120,50%,50%)')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isHSL', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isHSL.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isHSL'); - expect(isHSL.ruleName).toBe('isHSL'); - }); -}); - -describe('isMACAddress', () => { - it('should return true for valid colon-separated MAC address', () => { - expect(isMACAddress()('01:23:45:67:89:ab')).toBe(true); - }); - - it('should return true for valid hyphen-separated MAC address', () => { - expect(isMACAddress()('01-23-45-67-89-ab')).toBe(true); - }); - - it('should return false for invalid MAC address', () => { - expect(isMACAddress()('01:23:45:67:89')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMACAddress', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isMACAddress().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isMACAddress'); - expect(isMACAddress().ruleName).toBe('isMACAddress'); - }); - - it('should generate no-separator regex check code when emit() is called with no_separators:true', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isMACAddress({ no_separators: true }).emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isMACAddress'); - }); -}); - -describe('isISBN', () => { - it('should return true for valid ISBN-13', () => { - expect(isISBN()('978-3-16-148410-0')).toBe(true); - }); - - it('should return true for valid ISBN-10', () => { - expect(isISBN()('0-306-40615-2')).toBe(true); - }); - - it('should return false for invalid ISBN', () => { - expect(isISBN()('1234567890')).toBe(false); - }); - - it('should return true for ISBN-13 with version 13 constraint', () => { - expect(isISBN(13)('978-3-16-148410-0')).toBe(true); - }); - - it('should return false for ISBN-10 with version 13 constraint', () => { - expect(isISBN(13)('0-306-40615-2')).toBe(false); - }); - - it('should generate code when calling emit() and have ruleName isISBN', () => { - const { ctx, failMock } = makeCtx(0); - const code = isISBN().emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isISBN'); - expect(isISBN().ruleName).toBe('isISBN'); - expect(isISBN().requiresType).toBe(RequiredType.String); - }); -}); - -describe('isISIN', () => { - it('should return true for valid ISIN', () => { - expect(isISIN('US0378331005')).toBe(true); - }); - - it('should return false for invalid ISIN', () => { - expect(isISIN('US03783310')).toBe(false); - }); - - it('should return false for ISIN that passes regex but fails Luhn checksum', () => { - // US0378331006 matches ISIN_RE but has wrong Luhn check digit (valid: US0378331005) - expect(isISIN('US0378331006')).toBe(false); - }); - - it('should emit inline regex + Luhn checksum code (no addRef)', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = isISIN.emit('v', ctx); - expect(addRefMock).not.toHaveBeenCalled(); - expect(code).toContain('re['); - expect(code).toContain('isSum'); - expect(failMock).toHaveBeenCalledWith('isISIN'); - expect(isISIN.ruleName).toBe('isISIN'); - }); -}); - -describe('isISO8601', () => { - it('should return true for valid ISO 8601 date string', () => { - expect(isISO8601()('2023-01-01')).toBe(true); - }); - - it('should return true for valid ISO 8601 datetime string', () => { - expect(isISO8601()('2023-01-01T12:00:00Z')).toBe(true); - }); - - it('should return false for invalid date format', () => { - expect(isISO8601()('01-01-2023')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isISO8601', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isISO8601().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isISO8601'); - expect(isISO8601().ruleName).toBe('isISO8601'); - }); - - it('should return true for valid date with strict: true', () => { - expect(isISO8601({ strict: true })('2023-02-28')).toBe(true); - }); - - it('should return false for invalid month with strict: true', () => { - expect(isISO8601({ strict: true })('2023-13-01')).toBe(false); - }); - - it('should return false for invalid day with strict: true', () => { - expect(isISO8601({ strict: true })('2023-02-30')).toBe(false); - }); - - it('should reject an out-of-range month in a year-month string with strict: true', () => { - expect(isISO8601({ strict: true })('2021-13')).toBe(false); - expect(isISO8601({ strict: true })('2021-00')).toBe(false); - }); - - it('should accept a valid year-month string with strict: true', () => { - expect(isISO8601({ strict: true })('2021-12')).toBe(true); - }); - - it('strict: true emit uses inline regex + date validation (no addRef)', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = isISO8601({ strict: true }).emit('v', ctx); - expect(addRefMock).not.toHaveBeenCalled(); - expect(code).toContain('re['); - expect(code).toContain('mo'); - expect(code).toContain('da'); - expect(failMock).toHaveBeenCalledWith('isISO8601'); - }); - - it('strict: true ruleName is isISO8601', () => { - expect(isISO8601({ strict: true }).ruleName).toBe('isISO8601'); - }); -}); - -describe('isISRC', () => { - it('should return true for valid ISRC', () => { - expect(isISRC('USRC17607839')).toBe(true); - }); - - it('should return false for invalid ISRC', () => { - expect(isISRC('INVALID')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isISRC', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isISRC.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isISRC'); - expect(isISRC.ruleName).toBe('isISRC'); - }); -}); - -describe('isISSN', () => { - it('should return true for valid ISSN', () => { - expect(isISSN()('0378-5955')).toBe(true); - }); - - it('should return false for invalid ISSN', () => { - expect(isISSN()('1234-5678')).toBe(false); - }); - - it('should return true for ISSN without hyphen when requireHyphen is false', () => { - expect(isISSN({ requireHyphen: false })('03785955')).toBe(true); - }); - - it('should return false for ISSN that passes regex but fails mod-11 checksum', () => { - // 0378-5950 matches regex \\d{4}-\\d{3}[\\dX] but check-digit 0 is wrong (valid: 0378-5955) - expect(isISSN()('0378-5950')).toBe(false); - }); - - it('should emit inline regex + mod-11 checksum code (no addRef)', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - const code = isISSN().emit('v', ctx); - expect(addRefMock).not.toHaveBeenCalled(); - expect(code).toContain('re['); - expect(code).toContain('iss'); - expect(failMock).toHaveBeenCalledWith('isISSN'); - expect(isISSN().ruleName).toBe('isISSN'); - }); -}); - -describe('isJWT', () => { - it('should return true for valid JWT (3-part dot-separated base64url)', () => { - const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; - expect(isJWT(jwt)).toBe(true); - }); - - it('should return false for string without two dots', () => { - expect(isJWT('header.payload')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isJWT('')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isJWT', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isJWT.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isJWT'); - expect(isJWT.ruleName).toBe('isJWT'); - expect(isJWT.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isLatLong', () => { - it('should return true for valid lat,long pair', () => { - expect(isLatLong()('40.7128,-74.0060')).toBe(true); - }); - - it('should return false for out-of-range latitude', () => { - expect(isLatLong()('91.0000,0.0000')).toBe(false); - }); - - it('should return false for invalid format', () => { - expect(isLatLong()('not_a_coord')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isLatLong', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isLatLong().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isLatLong'); - expect(isLatLong().ruleName).toBe('isLatLong'); - }); -}); - -describe('isLocale', () => { - it('should return true for valid BCP 47 locale (en)', () => { - expect(isLocale('en')).toBe(true); - }); - - it('should return true for valid BCP 47 locale (en-US)', () => { - expect(isLocale('en-US')).toBe(true); - }); - - it('should return false for invalid locale', () => { - expect(isLocale('a')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isLocale', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isLocale.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isLocale'); - expect(isLocale.ruleName).toBe('isLocale'); - }); -}); - -describe('isDataURI', () => { - it('should return true for valid data URI', () => { - expect(isDataURI('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA')).toBe(true); - }); - - it('should return true for data URI with text content', () => { - expect(isDataURI('data:text/plain;charset=utf-8,Hello')).toBe(true); - }); - - it('should return false for non-data URI', () => { - expect(isDataURI('http://example.com')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isDataURI', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isDataURI.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isDataURI'); - expect(isDataURI.ruleName).toBe('isDataURI'); - }); -}); - -describe('isFQDN', () => { - it('should return true for valid FQDN', () => { - expect(isFQDN()('example.com')).toBe(true); - }); - - it('should return true for subdomain FQDN', () => { - expect(isFQDN()('sub.example.co.uk')).toBe(true); - }); - - it('should return false for IP address', () => { - expect(isFQDN()('192.168.1.1')).toBe(false); - }); - - it('should return false for localhost', () => { - expect(isFQDN()('localhost')).toBe(false); - }); - - it('should generate code when calling emit() and have ruleName isFQDN', () => { - const { ctx, failMock } = makeCtx(0); - const code = isFQDN().emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isFQDN'); - expect(isFQDN().ruleName).toBe('isFQDN'); - }); -}); - -describe('isPort', () => { - it('should return true for port 80', () => { - expect(isPort('80')).toBe(true); - }); - - it('should return true for port 0', () => { - expect(isPort('0')).toBe(true); - }); - - it('should return true for port 65535', () => { - expect(isPort('65535')).toBe(true); - }); - - it('should return false for port 65536', () => { - expect(isPort('65536')).toBe(false); - }); - - it('should return false for negative port', () => { - expect(isPort('-1')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isPort', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isPort.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isPort'); - expect(isPort.ruleName).toBe('isPort'); - expect(isPort.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isEAN', () => { - it('should return true for valid EAN-13', () => { - expect(isEAN('5901234123457')).toBe(true); - }); - - it('should return true for valid EAN-8', () => { - expect(isEAN('96385074')).toBe(true); - }); - - it('should return false for invalid EAN', () => { - expect(isEAN('1234567890123')).toBe(false); - }); - - it('should generate code when calling emit() and have ruleName isEAN', () => { - const { ctx, failMock } = makeCtx(0); - const code = isEAN.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isEAN'); - expect(isEAN.ruleName).toBe('isEAN'); - }); -}); - -describe('isISO31661Alpha2', () => { - it('should return true for valid ISO 3166-1 alpha-2 code', () => { - expect(isISO31661Alpha2('US')).toBe(true); - }); - - it('should return true for lowercase valid code', () => { - expect(isISO31661Alpha2('us')).toBe(true); - }); - - it('should return false for invalid 2-letter code', () => { - expect(isISO31661Alpha2('XX')).toBe(false); - }); - - it('should call ctx.addRef and generate test code when calling emit() and have ruleName isISO31661Alpha2', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - isISO31661Alpha2.emit('v', ctx); - expect(addRefMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isISO31661Alpha2'); - expect(isISO31661Alpha2.ruleName).toBe('isISO31661Alpha2'); - }); -}); - -describe('isISO31661Alpha3', () => { - it('should return true for valid ISO 3166-1 alpha-3 code', () => { - expect(isISO31661Alpha3('USA')).toBe(true); - }); - - it('should return false for invalid 3-letter code', () => { - expect(isISO31661Alpha3('XXX')).toBe(false); - }); - - it('should call ctx.addRef and generate test code when calling emit() and have ruleName isISO31661Alpha3', () => { - const { ctx, addRefMock, failMock } = makeCtx(0); - isISO31661Alpha3.emit('v', ctx); - expect(addRefMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isISO31661Alpha3'); - expect(isISO31661Alpha3.ruleName).toBe('isISO31661Alpha3'); - }); -}); - -describe('isBIC', () => { - it('should return true for valid BIC/SWIFT code (8 chars)', () => { - expect(isBIC('DEUTDEDB')).toBe(true); - }); - - it('should return true for valid BIC/SWIFT code (11 chars)', () => { - expect(isBIC('DEUTDEDBFRA')).toBe(true); - }); - - it('should return false for invalid BIC', () => { - expect(isBIC('INVALID')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBIC', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isBIC.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isBIC'); - expect(isBIC.ruleName).toBe('isBIC'); - }); -}); - -describe('isFirebasePushId', () => { - it('should return true for valid Firebase Push ID (20 chars, base64url charset)', () => { - expect(isFirebasePushId('-KkI7fTh9VD5V7FTB5sl')).toBe(true); - }); - - it('should return false for ID with wrong length', () => { - expect(isFirebasePushId('abc')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isFirebasePushId', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isFirebasePushId.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isFirebasePushId'); - expect(isFirebasePushId.ruleName).toBe('isFirebasePushId'); - }); -}); - -describe('isSemVer', () => { - it('should return true for valid semantic version', () => { - expect(isSemVer('1.2.3')).toBe(true); - }); - - it('should return true for version with pre-release tag', () => { - expect(isSemVer('1.0.0-alpha.1')).toBe(true); - }); - - it('should return false for non-semver string', () => { - expect(isSemVer('1.2')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isSemVer', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isSemVer.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isSemVer'); - expect(isSemVer.ruleName).toBe('isSemVer'); - }); -}); - -describe('isMongoId', () => { - it('should return true for valid MongoDB ObjectId (24-char hex)', () => { - expect(isMongoId('507f1f77bcf86cd799439011')).toBe(true); - }); - - it('should return false for non-hex string', () => { - expect(isMongoId('507f1f77bcf86cd79943901g')).toBe(false); - }); - - it('should return false for wrong-length hex string', () => { - expect(isMongoId('507f1f77bcf86cd')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMongoId', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isMongoId.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isMongoId'); - expect(isMongoId.ruleName).toBe('isMongoId'); - }); -}); - -describe('isJSON', () => { - it('should return true for valid JSON object string', () => { - expect(isJSON('{"key":"value"}')).toBe(true); - }); - - it('should return true for valid JSON array string', () => { - expect(isJSON('[1,2,3]')).toBe(true); - }); - - it('should return false for invalid JSON string', () => { - expect(isJSON('{invalid}')).toBe(false); - }); - - it('should return false for non-string value', () => { - expect(isJSON(42 as never)).toBe(false); - }); - - it('should generate try-catch or ref-based code when calling emit() and have ruleName isJSON', () => { - const { ctx, failMock } = makeCtx(0); - const code = isJSON.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isJSON'); - expect(isJSON.ruleName).toBe('isJSON'); - }); - - it('should emit inline try/catch JSON.parse code (no addRef)', () => { - const { ctx, addRefMock } = makeCtx(0); - const code = isJSON.emit('v', ctx); - expect(addRefMock).not.toHaveBeenCalled(); - expect(code).toContain('JSON.parse'); - expect(code).toContain('catch'); - }); -}); - -describe('isBase32', () => { - it('should return true for valid Base32 string', () => { - expect(isBase32()('JBSWY3DPEB3W64TMMQQQ====')).toBe(true); - }); - - it('should return false for invalid Base32 string', () => { - expect(isBase32()('Not!Valid')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBase32', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isBase32().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isBase32'); - expect(isBase32().ruleName).toBe('isBase32'); - }); -}); - -describe('isBase58', () => { - it('should return true for valid Base58 string', () => { - expect(isBase58('3yZe7d')).toBe(true); - }); - - it('should return false for Base58 string containing 0, O, I, l', () => { - expect(isBase58('0OIl')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBase58', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isBase58.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isBase58'); - expect(isBase58.ruleName).toBe('isBase58'); - }); -}); - -describe('isBase64', () => { - it('should return true for valid standard Base64 string', () => { - expect(isBase64()('SGVsbG8gV29ybGQ=')).toBe(true); - }); - - it('should return false for invalid Base64 string', () => { - expect(isBase64()('Not!base64')).toBe(false); - }); - - it('should return true for URL-safe Base64 when urlSafe option is true', () => { - expect(isBase64({ urlSafe: true })('SGVsbG8gV29ybGQ')).toBe(true); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isBase64', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isBase64().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isBase64'); - expect(isBase64().ruleName).toBe('isBase64'); - }); -}); - -describe('isDateString', () => { - it('should return true for valid ISO date string', () => { - expect(isDateString()('2023-01-15')).toBe(true); - }); - - it('should return false for invalid date string format', () => { - expect(isDateString()('15/01/2023')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isDateString', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isDateString().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isDateString'); - expect(isDateString().ruleName).toBe('isDateString'); - }); -}); - -describe('isMimeType', () => { - it('should return true for valid MIME type', () => { - expect(isMimeType('application/json')).toBe(true); - }); - - it('should return true for valid MIME type with subtype', () => { - expect(isMimeType('image/png')).toBe(true); - }); - - it('should return false for invalid MIME type', () => { - expect(isMimeType('not-a-mime')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMimeType', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isMimeType.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isMimeType'); - expect(isMimeType.ruleName).toBe('isMimeType'); - }); -}); - -describe('isCurrency', () => { - it('should return true for valid currency amount', () => { - expect(isCurrency()('$10.50')).toBe(true); - }); - - it('should return true for amount without symbol', () => { - expect(isCurrency()('100.00')).toBe(true); - }); - - it('should return false for invalid currency format', () => { - expect(isCurrency()('abc')).toBe(false); - }); - - it('should return false for double sign', () => { - expect(isCurrency()('+-5')).toBe(false); - expect(isCurrency()('-$-5')).toBe(false); - expect(isCurrency()('+$-5')).toBe(false); - }); - - it('should return true for a single sign before or after the currency symbol', () => { - expect(isCurrency()('-5')).toBe(true); - expect(isCurrency()('-$5')).toBe(true); - expect(isCurrency()('$-5')).toBe(true); - expect(isCurrency()('+$5')).toBe(true); - expect(isCurrency()('$5')).toBe(true); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isCurrency', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isCurrency().emit('v', ctx); - expect(addRegexMock).toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledWith('isCurrency'); - expect(isCurrency().ruleName).toBe('isCurrency'); - }); -}); - -describe('isMagnetURI', () => { - it('should return true for valid magnet URI', () => { - expect(isMagnetURI('magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a')).toBe(true); - }); - - it('should return false for non-magnet URI', () => { - expect(isMagnetURI('http://example.com')).toBe(false); - }); - - it('should call ctx.addRegex and generate test code when calling emit() and have ruleName isMagnetURI', () => { - const { ctx, addRegexMock, failMock } = makeCtx(0); - isMagnetURI.emit('v', ctx); - expect(addRegexMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith('isMagnetURI'); - expect(isMagnetURI.ruleName).toBe('isMagnetURI'); - }); -}); - -// ─── Group D: Algorithm-based ───────────────────────────────────────────────── - -describe('isCreditCard', () => { - it('should return true for valid Visa test number (Luhn pass)', () => { - expect(isCreditCard('4111111111111111')).toBe(true); - }); - - it('should return true for valid Mastercard test number', () => { - expect(isCreditCard('5500005555555559')).toBe(true); - }); - - it('should return true for valid Amex test number', () => { - expect(isCreditCard('378282246310005')).toBe(true); - }); - - it('should return true for number with dashes stripped', () => { - expect(isCreditCard('4111-1111-1111-1111')).toBe(true); - }); - - it('should return true for number with spaces stripped', () => { - expect(isCreditCard('4111 1111 1111 1111')).toBe(true); - }); - - it('should return false for number failing Luhn check', () => { - expect(isCreditCard('1234567890123456')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isCreditCard('')).toBe(false); - }); - - it('should generate Luhn algorithm inline code when calling emit() and have ruleName isCreditCard', () => { - const { ctx, failMock } = makeCtx(); - const code = isCreditCard.emit('v', ctx); - expect(code).toContain('%'); - expect(failMock).toHaveBeenCalledWith('isCreditCard'); - expect(isCreditCard.ruleName).toBe('isCreditCard'); - expect(isCreditCard.requiresType).toBe(RequiredType.String); - }); -}); - -describe('isIBAN', () => { - it('should return true for valid IBAN (GB)', () => { - expect(isIBAN()('GB82WEST12345698765432')).toBe(true); - }); - - it('should return true for valid IBAN with spaces when allowSpaces is true', () => { - expect(isIBAN({ allowSpaces: true })('GB82 WEST 1234 5698 7654 32')).toBe(true); - }); - - it('should return false for invalid IBAN checksum', () => { - expect(isIBAN()('GB00WEST12345698765432')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isIBAN()('')).toBe(false); - }); - - it('should generate mod-97 algorithm code when calling emit() and have ruleName isIBAN', () => { - const { ctx, failMock } = makeCtx(); - const code = isIBAN().emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isIBAN'); - expect(isIBAN().ruleName).toBe('isIBAN'); - expect(isIBAN().requiresType).toBe(RequiredType.String); - }); - - it('should return independent rule objects on multiple factory calls', () => { - const r1 = isIBAN(); - const r2 = isIBAN(); - expect(r1).not.toBe(r2); - }); -}); - -describe('isByteLength', () => { - it('should return true when byte length is within range', () => { - const rule = isByteLength(1, 10); - expect(rule('hello')).toBe(true); - }); - - it('should return true for multibyte string within range', () => { - const rule = isByteLength(1, 100); - expect(rule('日本語')).toBe(true); - }); - - it('should return false when byte length is below minimum', () => { - const rule = isByteLength(5, 10); - expect(rule('hi')).toBe(false); - }); - - it('should return false when byte length exceeds maximum', () => { - const rule = isByteLength(1, 3); - expect(rule('hello')).toBe(false); - }); - - it('should return true for empty string when minimum is 0', () => { - const rule = isByteLength(0); - expect(rule('')).toBe(true); - }); - - it('should count multibyte characters by byte length not char count', () => { - const rule = isByteLength(1, 3); - // '日' is 3 bytes in UTF-8, so within [1,3] - expect(rule('日')).toBe(true); - // '日本' is 6 bytes, exceeds max=3 - expect(rule('日本')).toBe(false); - }); - - it('should generate byte length check code when calling emit() and have ruleName isByteLength', () => { - const rule = isByteLength(1, 10); - const { ctx, failMock } = makeCtx(); - const code = rule.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isByteLength'); - expect(rule.ruleName).toBe('isByteLength'); - expect(rule.requiresType).toBe(RequiredType.String); - }); - - it('should emit inline Buffer.byteLength check (no addRef)', () => { - const rule = isByteLength(2, 5); - const { ctx, addRefMock } = makeCtx(0); - const code = rule.emit('v', ctx); - expect(addRefMock).not.toHaveBeenCalled(); - expect(code).toContain('bl'); - expect(code).toContain('2'); - expect(code).toContain('5'); - }); - - it('should return independent rule objects on multiple factory calls', () => { - const r1 = isByteLength(1, 10); - const r2 = isByteLength(1, 10); - expect(r1).not.toBe(r2); - }); -}); - -// ─── isHash ────────────────────────────────────────────────────────────────── - -describe('isHash', () => { - it('should return true for a valid md5 hash', () => { - expect(isHash('md5')('d41d8cd98f00b204e9800998ecf8427e')).toBe(true); - }); - - it('should return false for a non-hex md5-length string', () => { - expect(isHash('md5')('z41d8cd98f00b204e9800998ecf8427e')).toBe(false); - }); - - it('should return true for a valid sha1 hash', () => { - expect(isHash('sha1')('da39a3ee5e6b4b0d3255bfef95601890afd80709')).toBe(true); - }); - - it('should return false for sha1 with wrong length', () => { - expect(isHash('sha1')('da39a3ee5e6b4b0d3255bfef95601890afd8070')).toBe(false); - }); - - it('should return true for a valid sha256 hash', () => { - expect(isHash('sha256')('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')).toBe(true); - }); - - it('should return false for sha256 with non-hex character', () => { - expect(isHash('sha256')('g3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')).toBe(false); - }); - - it('should return true for valid sha384 hash', () => { - // sha384 of empty string = 96 hex chars - expect( - isHash('sha384')('38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b'), - ).toBe(true); - }); - - it('should return true for a valid sha512 hash', () => { - // sha512 of empty string = 128 hex chars (exact) - const sha512 = - 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e'; - expect(isHash('sha512')(sha512)).toBe(true); - }); - - it('should return true for valid ripemd128 hash', () => { - expect(isHash('ripemd128')('cdf26213a150dc3ecb610f18f6b38b46')).toBe(true); - }); - - it('should return false for ripemd128 with wrong length', () => { - expect(isHash('ripemd128')('cdf26213a150dc3ecb610f18f6b38')).toBe(false); - }); - - it('should return true for valid ripemd160 hash', () => { - expect(isHash('ripemd160')('9c1185a5c5e9fc54612808977ee8f548b2258d31')).toBe(true); - }); - - it('should return true for valid crc32 hash', () => { - expect(isHash('crc32')('90abcdef')).toBe(true); - }); - - it('should return false for non-string input', () => { - expect(isHash('md5')(42 as never)).toBe(false); - }); - - it('should have requiresType string', () => { - expect(isHash('md5').requiresType).toBe(RequiredType.String); - }); - - it('should have ruleName isHash', () => { - expect(isHash('md5').ruleName).toBe('isHash'); - }); - - it('should generate emit code with regex check', () => { - const { ctx, failMock } = makeCtx(); - const code = isHash('md5').emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isHash'); - }); - - it('should generate immediate fail code for unknown algorithm emit', () => { - const { ctx, failMock } = makeCtx(); - const code = isHash('unknownAlgo' as never).emit('v', ctx); - expect(code).toContain('isHash'); - expect(failMock).toHaveBeenCalledWith('isHash'); - }); -}); - -// ─── isRFC3339 ──────────────────────────────────────────────────────────────── - -describe('isRFC3339', () => { - it('should return true for UTC datetime', () => { - expect(isRFC3339('2021-01-01T00:00:00Z')).toBe(true); - }); - - it('should return true for datetime with timezone offset', () => { - expect(isRFC3339('2021-12-31T23:59:59+09:00')).toBe(true); - }); - - it('should return true for datetime with milliseconds', () => { - expect(isRFC3339('2021-06-15T12:30:45.123Z')).toBe(true); - }); - - it('should return false for date-only string', () => { - expect(isRFC3339('2021-01-01')).toBe(false); - }); - - it('should return false for a plain string', () => { - expect(isRFC3339('not-a-date')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isRFC3339('')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isRFC3339(12345 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isRFC3339', () => { - expect(isRFC3339.requiresType).toBe(RequiredType.String); - expect(isRFC3339.ruleName).toBe('isRFC3339'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isRFC3339.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isRFC3339'); - }); -}); - -// ─── isMilitaryTime ─────────────────────────────────────────────────────────── - -describe('isMilitaryTime', () => { - it('should return true for 00:00', () => { - expect(isMilitaryTime('00:00')).toBe(true); - }); - - it('should return true for 23:59', () => { - expect(isMilitaryTime('23:59')).toBe(true); - }); - - it('should return true for 12:30', () => { - expect(isMilitaryTime('12:30')).toBe(true); - }); - - it('should return false for 24:00', () => { - expect(isMilitaryTime('24:00')).toBe(false); - }); - - it('should return false for 12:60', () => { - expect(isMilitaryTime('12:60')).toBe(false); - }); - - it('should return false for single-digit hour', () => { - expect(isMilitaryTime('1:30')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isMilitaryTime(1230 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isMilitaryTime', () => { - expect(isMilitaryTime.requiresType).toBe(RequiredType.String); - expect(isMilitaryTime.ruleName).toBe('isMilitaryTime'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isMilitaryTime.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isMilitaryTime'); - }); -}); - -// ─── isLatitude ─────────────────────────────────────────────────────────────── - -describe('isLatitude', () => { - it('should return true for string "0"', () => { - expect(isLatitude('0')).toBe(true); - }); - - it('should return true for string "-90"', () => { - expect(isLatitude('-90')).toBe(true); - }); - - it('should return true for string "90"', () => { - expect(isLatitude('90')).toBe(true); - }); - - it('should return true for string "45.1234"', () => { - expect(isLatitude('45.1234')).toBe(true); - }); - - it('should return true for number 0', () => { - expect(isLatitude(0)).toBe(true); - }); - - it('should return true for number 45.123', () => { - expect(isLatitude(45.123)).toBe(true); - }); - - it('should return false for "-90.001"', () => { - expect(isLatitude('-90.001')).toBe(false); - }); - - it('should return false for "90.001"', () => { - expect(isLatitude('90.001')).toBe(false); - }); - - it('should return false for "abc"', () => { - expect(isLatitude('abc')).toBe(false); - }); - - it('should return false for string with extra chars like "90abc"', () => { - expect(isLatitude('90abc')).toBe(false); - }); - - it('should return false for non-string non-number input', () => { - expect(isLatitude(null as never)).toBe(false); - expect(isLatitude({} as never)).toBe(false); - }); - - it('should have ruleName isLatitude and requiresType undefined', () => { - expect(isLatitude.ruleName).toBe('isLatitude'); - expect(isLatitude.requiresType).toBeUndefined(); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isLatitude.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isLatitude'); - }); -}); - -// ─── isLongitude ────────────────────────────────────────────────────────────── - -describe('isLongitude', () => { - it('should return true for string "0"', () => { - expect(isLongitude('0')).toBe(true); - }); - - it('should return true for string "-180"', () => { - expect(isLongitude('-180')).toBe(true); - }); - - it('should return true for string "180"', () => { - expect(isLongitude('180')).toBe(true); - }); - - it('should return true for number 90.5', () => { - expect(isLongitude(90.5)).toBe(true); - }); - - it('should return false for "-180.001"', () => { - expect(isLongitude('-180.001')).toBe(false); - }); - - it('should return false for "180.001"', () => { - expect(isLongitude('180.001')).toBe(false); - }); - - it('should return false for "abc"', () => { - expect(isLongitude('abc')).toBe(false); - }); - - it('should return false for string with extra chars like "180abc"', () => { - expect(isLongitude('180abc')).toBe(false); - }); - - it('should return false for non-string non-number input', () => { - expect(isLongitude(null as never)).toBe(false); - expect(isLongitude({} as never)).toBe(false); - }); - - it('should have ruleName isLongitude and requiresType undefined', () => { - expect(isLongitude.ruleName).toBe('isLongitude'); - expect(isLongitude.requiresType).toBeUndefined(); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isLongitude.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isLongitude'); - }); -}); - -// ─── isEthereumAddress ──────────────────────────────────────────────────────── - -describe('isEthereumAddress', () => { - it('should return true for a valid lowercase ethereum address', () => { - expect(isEthereumAddress('0x742d35cc6634c0532925a3b8d4c9db96590c6af5')).toBe(true); - }); - - it('should return true for a valid mixed-case ethereum address', () => { - expect(isEthereumAddress('0x742d35Cc6634C0532925a3b8D4C9Db96590c7aEB')).toBe(true); - }); - - it('should return false for address without 0x prefix', () => { - expect(isEthereumAddress('742d35cc6634c0532925a3b8d4c9db96590c6af5')).toBe(false); - }); - - it('should return false for too short address', () => { - expect(isEthereumAddress('0x742d35')).toBe(false); - }); - - it('should return false for non-hex chars', () => { - expect(isEthereumAddress('0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isEthereumAddress(123 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isEthereumAddress', () => { - expect(isEthereumAddress.requiresType).toBe(RequiredType.String); - expect(isEthereumAddress.ruleName).toBe('isEthereumAddress'); - }); - - it('should generate emit code with regex', () => { - const { ctx, failMock } = makeCtx(); - const code = isEthereumAddress.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isEthereumAddress'); - }); -}); - -// ─── isBtcAddress ───────────────────────────────────────────────────────────── - -describe('isBtcAddress', () => { - it('should return true for a valid P2PKH address (starts with 1)', () => { - expect(isBtcAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf Na')).toBe(false); // has space - }); - - it('should return true for a valid P2PKH address', () => { - expect(isBtcAddress('1BpEi6DfDAUFd153wiGrvkiKW1iHENGLyQ')).toBe(true); - }); - - it('should return true for a valid P2SH address (starts with 3)', () => { - expect(isBtcAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy')).toBe(true); - }); - - it('should return true for a valid bech32 address', () => { - expect(isBtcAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(true); - }); - - it('should return false for clearly invalid address', () => { - expect(isBtcAddress('0invalidaddress')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isBtcAddress('')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isBtcAddress(123 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isBtcAddress', () => { - expect(isBtcAddress.requiresType).toBe(RequiredType.String); - expect(isBtcAddress.ruleName).toBe('isBtcAddress'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isBtcAddress.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isBtcAddress'); - }); -}); - -// ─── isISO4217CurrencyCode ──────────────────────────────────────────────────── - -describe('isISO4217CurrencyCode', () => { - it('should return true for USD', () => { - expect(isISO4217CurrencyCode('USD')).toBe(true); - }); - - it('should return true for EUR', () => { - expect(isISO4217CurrencyCode('EUR')).toBe(true); - }); - - it('should return true for KRW', () => { - expect(isISO4217CurrencyCode('KRW')).toBe(true); - }); - - it('should return false for lowercase usd', () => { - expect(isISO4217CurrencyCode('usd')).toBe(false); - }); - - it('should return false for non-existent code XXX', () => { - expect(isISO4217CurrencyCode('XXX')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(isISO4217CurrencyCode('')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isISO4217CurrencyCode(123 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isISO4217CurrencyCode', () => { - expect(isISO4217CurrencyCode.requiresType).toBe(RequiredType.String); - expect(isISO4217CurrencyCode.ruleName).toBe('isISO4217CurrencyCode'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isISO4217CurrencyCode.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isISO4217CurrencyCode'); - }); -}); - -// ─── isPhoneNumber ──────────────────────────────────────────────────────────── - -describe('isPhoneNumber', () => { - it('should return true for valid E.164 US number', () => { - expect(isPhoneNumber('+14155552671')).toBe(true); - }); - - it('should return true for valid E.164 KR number', () => { - expect(isPhoneNumber('+821012345678')).toBe(true); - }); - - it('should return true for valid E.164 UK number', () => { - expect(isPhoneNumber('+447700900077')).toBe(true); - }); - - it('should return false for number without + prefix', () => { - expect(isPhoneNumber('00821012345678')).toBe(false); - }); - - it('should return false for too short number', () => { - expect(isPhoneNumber('+123')).toBe(false); - }); - - it('should return false for +0 leading digit after +', () => { - expect(isPhoneNumber('+0123456789')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isPhoneNumber(123 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isPhoneNumber', () => { - expect(isPhoneNumber.requiresType).toBe(RequiredType.String); - expect(isPhoneNumber.ruleName).toBe('isPhoneNumber'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isPhoneNumber.emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isPhoneNumber'); - }); -}); - -// ─── isStrongPassword ───────────────────────────────────────────────────────── - -describe('isStrongPassword', () => { - it('should return true for a valid strong password with defaults', () => { - expect(isStrongPassword()('Passw0rd!')).toBe(true); - }); - - it('should return true for complex password', () => { - expect(isStrongPassword()('MyP@ssw0rd123')).toBe(true); - }); - - it('should return false for too short password (< 8 chars)', () => { - expect(isStrongPassword()('Pass0!')).toBe(false); - }); - - it('should return false for password with no uppercase', () => { - expect(isStrongPassword()('password1!')).toBe(false); - }); - - it('should return false for password with no lowercase', () => { - expect(isStrongPassword()('PASSWORD1!')).toBe(false); - }); - - it('should return false for password with no numbers', () => { - expect(isStrongPassword()('Password!')).toBe(false); - }); - - it('should return false for password with no symbols', () => { - expect(isStrongPassword()('Password1')).toBe(false); - }); - - it('should respect custom minLength option', () => { - expect(isStrongPassword({ minLength: 4, minSymbols: 0 })('Pa1')).toBe(false); - expect(isStrongPassword({ minLength: 4, minSymbols: 0 })('Pa1x')).toBe(true); - }); - - it('should return false for non-string input', () => { - expect(isStrongPassword()(12345678 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isStrongPassword', () => { - expect(isStrongPassword().requiresType).toBe(RequiredType.String); - expect(isStrongPassword().ruleName).toBe('isStrongPassword'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isStrongPassword().emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isStrongPassword'); - }); - - it('should return independent rule objects on multiple factory calls', () => { - const r1 = isStrongPassword(); - const r2 = isStrongPassword(); - expect(r1).not.toBe(r2); - }); -}); - -// ─── isTaxId ────────────────────────────────────────────────────────────────── - -describe('isTaxId', () => { - it('should return true for valid US EIN', () => { - expect(isTaxId('US')('12-3456789')).toBe(true); - }); - - it('should return false for invalid US format', () => { - expect(isTaxId('US')('1234567')).toBe(false); - }); - - it('should return true for valid KR business registration number', () => { - expect(isTaxId('KR')('123-45-67890')).toBe(true); - }); - - it('should return false for invalid KR format', () => { - expect(isTaxId('KR')('12345')).toBe(false); - }); - - it('should return true for valid DE tax id', () => { - expect(isTaxId('DE')('12345678901')).toBe(true); - }); - - it('should return false for invalid DE format', () => { - expect(isTaxId('DE')('1234567890')).toBe(false); - }); - - it('should return true for valid GB UTR', () => { - expect(isTaxId('GB')('1234567890')).toBe(true); - }); - - it('should return false for unsupported locale', () => { - expect(isTaxId('XX')('123')).toBe(false); - }); - - it('should return false for non-string input', () => { - expect(isTaxId('US')(123 as never)).toBe(false); - }); - - it('should have requiresType string and ruleName isTaxId', () => { - expect(isTaxId('US').requiresType).toBe(RequiredType.String); - expect(isTaxId('US').ruleName).toBe('isTaxId'); - }); - - it('should generate emit code', () => { - const { ctx, failMock } = makeCtx(); - const code = isTaxId('US').emit('v', ctx); - expect(code).toBeTruthy(); - expect(failMock).toHaveBeenCalledWith('isTaxId'); - }); - - it('should emit fail-only code for unknown locale (covers L1464 !re branch)', () => { - const { ctx, failMock } = makeCtx(); - const code = isTaxId('XX-UNKNOWN').emit('v', ctx); - expect(code).toContain('isTaxId'); - expect(failMock).toHaveBeenCalledWith('isTaxId'); - }); - - it('should return independent rule objects on multiple factory calls', () => { - const r1 = isTaxId('US'); - const r2 = isTaxId('US'); - expect(r1).not.toBe(r2); - }); -}); diff --git a/src/rules/string.ts b/src/rules/string.ts index eaa9b59..441ab17 100644 --- a/src/rules/string.ts +++ b/src/rules/string.ts @@ -1,2441 +1,6 @@ -import type { EmitContext, EmittableRule } from '../types'; +// Barrel: re-exports the string-rule factories split across cohesive concern modules. +// All previously-exported value and type names resolve unchanged via `from './string'`. -import { CacheKey, RequiredType, RuleOp } from '../enums'; -import { makePlannedRule, makeRule, planCompare, planLength, planOr } from '../rule-plan'; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -function makeStringRule( - name: string, - validate: (v: string) => boolean, - buildEmit: (varName: string, ctx: EmitContext) => string, - requiresType: RequiredType | undefined = RequiredType.String, - constraints: Record = {}, -): EmittableRule { - return makeRule({ - name, - requiresType, - constraints, - validate: value => typeof value === 'string' && validate(value), - emit: buildEmit, - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Group A: Length / Range -// ───────────────────────────────────────────────────────────────────────────── - -function minLength(min: number): EmittableRule { - const plan = { cacheKey: CacheKey.Length, failure: planCompare(planLength(), RuleOp.Lt, min) } as const; - return makePlannedRule({ - name: 'minLength', - requiresType: RequiredType.String, - constraints: { min }, - plan, - validate: value => typeof value === 'string' && value.length >= min, - }); -} - -function maxLength(max: number): EmittableRule { - const plan = { cacheKey: CacheKey.Length, failure: planCompare(planLength(), RuleOp.Gt, max) } as const; - return makePlannedRule({ - name: 'maxLength', - requiresType: RequiredType.String, - constraints: { max }, - plan, - validate: value => typeof value === 'string' && value.length <= max, - }); -} - -function length(minLen: number, maxLen: number): EmittableRule { - const plan = { - cacheKey: CacheKey.Length, - failure: planOr(planCompare(planLength(), RuleOp.Lt, minLen), planCompare(planLength(), RuleOp.Gt, maxLen)), - } as const; - return makePlannedRule({ - name: 'length', - requiresType: RequiredType.String, - constraints: { min: minLen, max: maxLen }, - plan, - validate: value => typeof value === 'string' && value.length >= minLen && value.length <= maxLen, - }); -} - -function contains(seed: string): EmittableRule { - return makeRule({ - name: 'contains', - requiresType: RequiredType.String, - constraints: { seed }, - validate: value => typeof value === 'string' && value.includes(seed), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRef(seed); - return `if (!${varName}.includes(refs[${i}])) ${ctx.fail('contains')};`; - }, - }); -} - -function notContains(seed: string): EmittableRule { - return makeRule({ - name: 'notContains', - requiresType: RequiredType.String, - constraints: { seed }, - validate: value => typeof value === 'string' && !value.includes(seed), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRef(seed); - return `if (${varName}.includes(refs[${i}])) ${ctx.fail('notContains')};`; - }, - }); -} - -function matches(pattern: string | RegExp, modifiers?: string): EmittableRule { - const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, modifiers); - return makeRule({ - name: 'matches', - requiresType: RequiredType.String, - constraints: { pattern: re.source }, - validate: value => typeof value === 'string' && re.test(value), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('matches')};`; - }, - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Group B: Simple Boolean Checks -// ───────────────────────────────────────────────────────────────────────────── - -const isLowercase = makeRule({ - name: 'isLowercase', - requiresType: RequiredType.String, - constraints: {}, - validate: value => typeof value === 'string' && value === value.toLowerCase(), - emit: (varName: string, ctx: EmitContext): string => `if (${varName} !== ${varName}.toLowerCase()) ${ctx.fail('isLowercase')};`, -}); - -const isUppercase = makeRule({ - name: 'isUppercase', - requiresType: RequiredType.String, - constraints: {}, - validate: value => typeof value === 'string' && value === value.toUpperCase(), - emit: (varName: string, ctx: EmitContext): string => `if (${varName} !== ${varName}.toUpperCase()) ${ctx.fail('isUppercase')};`, -}); - -// ASCII: all code points in [0x00, 0x7F] -const ASCII_RE = new RegExp(`^[${String.fromCharCode(0)}-${String.fromCharCode(0x7f)}]*$`); -const isAscii = makeStringRule( - 'isAscii', - v => ASCII_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ASCII_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isAscii')};`; - }, -); - -// Alpha — [a-zA-Z]+ singleton -const ALPHA_DEFAULT_RE = /^[a-zA-Z]+$/; -// length > 0 guard is dead — `+` quantifier requires ≥1 char so the regex returns false on empty. -const isAlpha = makeStringRule( - 'isAlpha', - v => ALPHA_DEFAULT_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ALPHA_DEFAULT_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isAlpha')};`; - }, -); - -// Alphanumeric — [a-zA-Z0-9]+ singleton (same empty-input rationale as isAlpha) -const ALNUM_DEFAULT_RE = /^[a-zA-Z0-9]+$/; -const isAlphanumeric = makeStringRule( - 'isAlphanumeric', - v => ALNUM_DEFAULT_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ALNUM_DEFAULT_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isAlphanumeric')};`; - }, -); - -// HTTP token — RFC 9110 §5.6.2: token = 1*tchar. -// tchar = "!"/"#"/"$"/"%"/"&"/"'"/"*"/"+"/"-"/"."/"^"/"_"/"`"/"|"/"~" / DIGIT / ALPHA. -// Used for HTTP method names and header field-names (not field-values). The hyphen is -// escaped so it stays literal — an unescaped `+-.` would form a range that admits ",". -const HTTP_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; -const isHttpToken = makeStringRule( - 'isHttpToken', - v => HTTP_TOKEN_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(HTTP_TOKEN_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isHttpToken')};`; - }, -); - -// RFC 6454 §6.2 serialized origin — a string equal to WHATWG URL `.origin`. -// The opaque-origin literal 'null' is matched explicitly because `new URL('null')` throws. -// '*' (CORS wildcard) is rejected here; use isCorsOrigin for the CORS superset. -const isOriginValue = (value: string): boolean => { - if (value === 'null') { - return true; - } - try { - return new URL(value).origin === value; - } catch { - return false; - } -}; -const isOrigin = makeRule({ - name: 'isOrigin', - requiresType: RequiredType.String, - constraints: { format: 'origin' }, - validate: value => typeof value === 'string' && isOriginValue(value), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRef(isOriginValue); - return `if (!(refs[${i}](${varName}))) ${ctx.fail('isOrigin')};`; - }, -}); - -// CORS superset of isOrigin: additionally accepts the '*' wildcard literal -// (Access-Control-Allow-Origin). Keep '*' out of the general isOrigin. -const isCorsOriginValue = (value: string): boolean => value === '*' || isOriginValue(value); -const isCorsOrigin = makeRule({ - name: 'isCorsOrigin', - requiresType: RequiredType.String, - constraints: { format: 'origin', allowWildcard: true }, - validate: value => typeof value === 'string' && isCorsOriginValue(value), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRef(isCorsOriginValue); - return `if (!(refs[${i}](${varName}))) ${ctx.fail('isCorsOrigin')};`; - }, -}); - -// BooleanString: 'true' | 'false' | '1' | '0' -const isBooleanString = makeRule({ - name: 'isBooleanString', - requiresType: RequiredType.String, - constraints: {}, - validate: value => value === 'true' || value === 'false' || value === '1' || value === '0', - emit: (varName: string, ctx: EmitContext): string => - `if (${varName} !== 'true' && ${varName} !== 'false' && ${varName} !== '1' && ${varName} !== '0') ${ctx.fail('isBooleanString')};`, -}); - -interface IsNumberStringOptions { - no_symbols?: boolean; -} - -const NO_SYMBOLS_RE = /^[0-9]+$/; -// A numeric string: optional sign, integer/decimal/leading-dot form. No whitespace, hex, or -// exponent — `Number()` coercion accepted all of those (e.g. " ", "0x1A", "1e5"), which is far -// looser than "is this string a number". Matches validator.js's default isNumeric behavior. -const NUMERIC_STRING_RE = /^[+-]?(?:[0-9]*\.)?[0-9]+$/; - -function isNumberString(options?: IsNumberStringOptions): EmittableRule { - const noSymbols = options?.no_symbols ?? false; - const re = noSymbols ? NO_SYMBOLS_RE : NUMERIC_STRING_RE; - - return makeStringRule( - 'isNumberString', - (s: string): boolean => re.test(s), - (varName, ctx) => { - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isNumberString')};`; - }, - RequiredType.String, - { no_symbols: noSymbols }, - ); -} - -function isDecimal(): EmittableRule { - // Require a digit after the dot — `\d+(?:\.\d*)?` accepted a dangling "5.". - const decimalRe = /^[-+]?(?:\d+(?:\.\d+)?|\.\d+)$/; - return makeStringRule( - 'isDecimal', - v => decimalRe.test(v), - (varName, ctx) => { - const i = ctx.addRegex(decimalRe); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isDecimal')};`; - }, - ); -} - -// Full-width characters (Unicode fullwidth forms) -const FULLWIDTH_RE = /[^\u0020-\u007E\uFF61-\uFF9F]/; -// Empty-string guard is redundant — non-anchored char-class regex returns false on empty input. -const isFullWidth = makeStringRule( - 'isFullWidth', - v => FULLWIDTH_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(FULLWIDTH_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isFullWidth')};`; - }, -); - -// Half-width characters -const HALFWIDTH_RE = /[\u0020-\u007E\uFF61-\uFF9F]/; -const isHalfWidth = makeStringRule( - 'isHalfWidth', - v => HALFWIDTH_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(HALFWIDTH_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isHalfWidth')};`; - }, -); - -// Variable-width: must contain both full-width AND half-width -const isVariableWidth = makeStringRule( - 'isVariableWidth', - v => FULLWIDTH_RE.test(v) && HALFWIDTH_RE.test(v), - (varName, ctx) => { - const i1 = ctx.addRegex(FULLWIDTH_RE); - const i2 = ctx.addRegex(HALFWIDTH_RE); - return `if (!re[${i1}].test(${varName}) || !re[${i2}].test(${varName})) ${ctx.fail('isVariableWidth')};`; - }, -); - -// Multibyte: any character outside Latin-1 / half-width range -const MULTIBYTE_RE = new RegExp(`[^${String.fromCharCode(0)}-${String.fromCharCode(0xff)}]`); -const isMultibyte = makeStringRule( - 'isMultibyte', - v => MULTIBYTE_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(MULTIBYTE_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isMultibyte')};`; - }, -); - -// Surrogate pairs -const SURROGATE_RE = /[\uD800-\uDBFF][\uDC00-\uDFFF]/; -const isSurrogatePair = makeStringRule( - 'isSurrogatePair', - v => SURROGATE_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(SURROGATE_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isSurrogatePair')};`; - }, -); - -// Hexadecimal -const HEX_RE = /^[0-9a-fA-F]+$/; -const isHexadecimal = makeStringRule( - 'isHexadecimal', - v => HEX_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(HEX_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isHexadecimal')};`; - }, -); - -// Octal -const OCTAL_RE = /^(0[oO])?[0-7]+$/; -const isOctal = makeStringRule( - 'isOctal', - v => OCTAL_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(OCTAL_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isOctal')};`; - }, -); - -// ───────────────────────────────────────────────────────────────────────────── -// Group C: Regex-based -// ───────────────────────────────────────────────────────────────────────────── - -// Email — RFC 5322 simplified -const EMAIL_RE = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; - -function isEmail(): EmittableRule { - return makeStringRule( - 'isEmail', - v => EMAIL_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(EMAIL_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isEmail')};`; - }, - RequiredType.String, - { format: 'email' }, - ); -} - -// URL — RFC 3986 simplified -interface IsURLOptions { - protocols?: string[]; -} - -const URL_PROTOCOLS_DEFAULT = ['http', 'https', 'ftp']; - -function isURL(options?: IsURLOptions): EmittableRule { - const protocols = options?.protocols ?? URL_PROTOCOLS_DEFAULT; - const protocolPattern = protocols.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); - const re = new RegExp( - `^(?:${protocolPattern}):\\/\\/(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?::(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]\\d{4}|[1-9]\\d{0,3}|0))?(?:\\/[^\\s]*)?$`, - ); - return makeRule({ - name: 'isURL', - requiresType: RequiredType.String, - constraints: { format: 'uri', protocols }, - validate: value => typeof value === 'string' && re.test(value), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isURL')};`; - }, - }); -} - -// UUID -const UUID_RE = { - all: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - 1: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-1[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, - 2: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-2[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, - 3: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-3[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, - 4: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, - 5: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-5[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, -} as const; - -function isUUID(version?: 1 | 2 | 3 | 4 | 5 | 'all'): EmittableRule { - const re = version != null ? UUID_RE[version] : UUID_RE.all; - return makeStringRule( - 'isUUID', - v => re.test(v), - (varName, ctx) => { - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isUUID')};`; - }, - RequiredType.String, - { format: 'uuid', version }, - ); -} - -// IP -const IPV4_RE = - /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/; -const IPV6_RE = - /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}$|^::$|^::1$|^::(?:ffff(?::0{1,4})?:)?(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/; - -function isIP(version?: 4 | 6): EmittableRule { - return makeRule({ - name: 'isIP', - requiresType: RequiredType.String, - constraints: { version }, - validate: value => { - if (typeof value !== 'string') { - return false; - } - if (version === 4) { - return IPV4_RE.test(value); - } - if (version === 6) { - return IPV6_RE.test(value); - } - return IPV4_RE.test(value) || IPV6_RE.test(value); - }, - emit: (varName: string, ctx: EmitContext): string => { - if (version === 4) { - const i = ctx.addRegex(IPV4_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isIP')};`; - } - if (version === 6) { - const i = ctx.addRegex(IPV6_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isIP')};`; - } - const i4 = ctx.addRegex(IPV4_RE); - const i6 = ctx.addRegex(IPV6_RE); - return `if (!re[${i4}].test(${varName}) && !re[${i6}].test(${varName})) ${ctx.fail('isIP')};`; - }, - }); -} - -// HexColor: #RGB or #RRGGBB -const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; -const isHexColor = makeStringRule( - 'isHexColor', - v => HEX_COLOR_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(HEX_COLOR_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isHexColor')};`; - }, -); - -// RgbColor -const RGB_RE = - /^rgb\(\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*\)$/; -const RGBA_RE = - /^rgba\(\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\s*,\s*(0|0?\.\d+|1(\.0+)?)\s*\)$/; -// Percent forms: rgb(...) must NOT have alpha; rgba(...) MUST have alpha. -const RGB_PERCENT_NOALPHA_RE = /^rgb\(\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*\)$/; -const RGBA_PERCENT_RE = /^rgba\(\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*,\s*(\d{1,2}|100)%\s*,\s*(0|0?\.\d+|1(?:\.0+)?)\s*\)$/; - -function isRgbColor(includePercentValues: boolean = false): EmittableRule { - return makeRule({ - name: 'isRgbColor', - requiresType: RequiredType.String, - constraints: { includePercentValues }, - validate: value => { - if (typeof value !== 'string') { - return false; - } - if (includePercentValues) { - return RGB_PERCENT_NOALPHA_RE.test(value) || RGBA_PERCENT_RE.test(value) || RGB_RE.test(value) || RGBA_RE.test(value); - } - return RGB_RE.test(value) || RGBA_RE.test(value); - }, - emit: (varName: string, ctx: EmitContext): string => { - if (includePercentValues) { - const ip1 = ctx.addRegex(RGB_PERCENT_NOALPHA_RE); - const ip2 = ctx.addRegex(RGBA_PERCENT_RE); - const ip3 = ctx.addRegex(RGB_RE); - const ip4 = ctx.addRegex(RGBA_RE); - return `if (!re[${ip1}].test(${varName}) && !re[${ip2}].test(${varName}) && !re[${ip3}].test(${varName}) && !re[${ip4}].test(${varName})) ${ctx.fail('isRgbColor')};`; - } - const i1 = ctx.addRegex(RGB_RE); - const i2 = ctx.addRegex(RGBA_RE); - return `if (!re[${i1}].test(${varName}) && !re[${i2}].test(${varName})) ${ctx.fail('isRgbColor')};`; - }, - }); -} - -// HSL: hsl(H, S%, L%) or hsla(H, S%, L%, A) -// Alpha belongs to hsla() only — `hsla?(...)?` previously let hsl() carry alpha and hsla() omit it. -const HSL_RE = - /^(?:hsl\(\s*(?:360|3[0-5]\d|[12]\d{2}|[1-9]\d|\d)\s*,\s*(?:100|[1-9]\d|\d)%\s*,\s*(?:100|[1-9]\d|\d)%\s*\)|hsla\(\s*(?:360|3[0-5]\d|[12]\d{2}|[1-9]\d|\d)\s*,\s*(?:100|[1-9]\d|\d)%\s*,\s*(?:100|[1-9]\d|\d)%\s*,\s*(?:0|0?\.\d+|1(?:\.0+)?)\s*\))$/; -const isHSL = makeStringRule( - 'isHSL', - v => HSL_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(HSL_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isHSL')};`; - }, -); - -// MAC Address -interface IsMACAddressOptions { - no_separators?: boolean; -} - -const MAC_COLON_RE = /^[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}$/; -const MAC_HYPHEN_RE = /^[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}$/; -const MAC_NO_SEP_RE = /^[0-9a-fA-F]{12}$/; - -function isMACAddress(options?: IsMACAddressOptions): EmittableRule { - return makeRule({ - name: 'isMACAddress', - requiresType: RequiredType.String, - constraints: { no_separators: options?.no_separators }, - validate: value => { - if (typeof value !== 'string') { - return false; - } - if (options?.no_separators) { - return MAC_NO_SEP_RE.test(value); - } - return MAC_COLON_RE.test(value) || MAC_HYPHEN_RE.test(value); - }, - emit: (varName: string, ctx: EmitContext): string => { - if (options?.no_separators) { - const i = ctx.addRegex(MAC_NO_SEP_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isMACAddress')};`; - } - const i1 = ctx.addRegex(MAC_COLON_RE); - const i2 = ctx.addRegex(MAC_HYPHEN_RE); - return `if (!re[${i1}].test(${varName}) && !re[${i2}].test(${varName})) ${ctx.fail('isMACAddress')};`; - }, - }); -} - -// ISBN -function validateISBN10(str: string): boolean { - const s = str.replace(/[-\s]/g, ''); - if (!/^\d{9}[\dX]$/.test(s)) { - return false; - } - let sum = 0; - for (let i = 0; i < 9; i++) { - sum += (10 - i) * (s.charCodeAt(i) - 48); - } - const last = s[9] === 'X' ? 10 : s.charCodeAt(9) - 48; - sum += last; - return sum % 11 === 0; -} - -function validateISBN13(str: string): boolean { - const s = str.replace(/[-\s]/g, ''); - if (!/^\d{13}$/.test(s)) { - return false; - } - let sum = 0; - for (let i = 0; i < 12; i++) { - sum += (s.charCodeAt(i) - 48) * (i % 2 === 0 ? 1 : 3); - } - const check = (10 - (sum % 10)) % 10; - return check === s.charCodeAt(12) - 48; -} - -function isISBN(version?: 10 | 13): EmittableRule { - const validateFn = (value: unknown): boolean => { - if (typeof value !== 'string') { - return false; - } - if (version === 10) { - return validateISBN10(value); - } - if (version === 13) { - return validateISBN13(value); - } - return validateISBN10(value) || validateISBN13(value); - }; - - const emitISBN10 = (v: string): string => - `{var s=${v}.replace(/[-\\s]/g,'');` + - `if(!/^\\d{9}[\\dX]$/.test(s)){%%FAIL%%}` + - `else{var sm=0;for(var i=0;i<9;i++)sm+=(10-i)*(s.charCodeAt(i)-48);` + - `var l=s[9]==='X'?10:(s.charCodeAt(9)-48);sm+=l;` + - `if(sm%11!==0){%%FAIL%%}}}`; - - const emitISBN13 = (v: string): string => - `{var s=${v}.replace(/[-\\s]/g,'');` + - `if(!/^\\d{13}$/.test(s)){%%FAIL%%}` + - `else{var sm=0;for(var i=0;i<12;i++)sm+=(s.charCodeAt(i)-48)*(i%2===0?1:3);` + - `var ck=(10-(sm%10))%10;` + - `if(ck!==(s.charCodeAt(12)-48)){%%FAIL%%}}}`; - - return makeRule({ - name: 'isISBN', - requiresType: RequiredType.String, - constraints: { version }, - validate: validateFn, - emit: (varName: string, ctx: EmitContext): string => { - const fail = ctx.fail('isISBN'); - if (version === 10) { - return emitISBN10(varName).replace(/%%FAIL%%/g, fail); - } - if (version === 13) { - return emitISBN13(varName).replace(/%%FAIL%%/g, fail); - } - const emit10 = emitISBN10(varName).replace(/%%FAIL%%/g, '__isbn_ok=false'); - const emit13 = emitISBN13(varName).replace(/%%FAIL%%/g, '__isbn_ok=false'); - return `{var __isbn_ok=true;${emit10} if(!__isbn_ok){__isbn_ok=true;${emit13}} if(!__isbn_ok)${fail};}`; - }, - }); -} - -// ISIN — ISO 6166 -const ISIN_RE = /^[A-Z]{2}[A-Z0-9]{9}[0-9]$/; - -function validateISINStr(v: string): boolean { - if (!ISIN_RE.test(v)) { - return false; - } - // Luhn mod10 on expanded digits — walk right-to-left, expanding letters as A=10..Z=35 on the fly. - // No intermediate string/array allocations. - let sum = 0; - let alternate = false; - for (let i = v.length - 1; i >= 0; i--) { - const code = v.charCodeAt(i); - if (code <= 57) { - // ASCII digit '0'..'9' - let n = code - 48; - if (alternate) { - n *= 2; - if (n > 9) { - n -= 9; - } - } - sum += n; - alternate = !alternate; - } else { - // ASCII letter 'A'..'Z' → two-digit value, ones first when walking right-to-left - const value = code - 55; - const ones = value % 10; - let n = ones; - if (alternate) { - n *= 2; - if (n > 9) { - n -= 9; - } - } - sum += n; - alternate = !alternate; - n = (value - ones) / 10; - if (alternate) { - n *= 2; - if (n > 9) { - n -= 9; - } - } - sum += n; - alternate = !alternate; - } - } - return sum % 10 === 0; -} - -const isISIN = makeStringRule('isISIN', validateISINStr, (varName, ctx) => { - const i = ctx.addRegex(ISIN_RE); - return ( - `if (!re[${i}].test(${varName})) ${ctx.fail('isISIN')};\n` + - `else { var isSum=0,isAlt=false;\n` + - `for(var isI=${varName}.length-1;isI>=0;isI--){var isCd=${varName}.charCodeAt(isI);` + - `if(isCd<=57){var isN=isCd-48;if(isAlt){isN*=2;if(isN>9)isN-=9;}isSum+=isN;isAlt=!isAlt;}` + - `else{var isVal=isCd-55;var isO=isVal%10;var isN=isO;if(isAlt){isN*=2;if(isN>9)isN-=9;}isSum+=isN;isAlt=!isAlt;` + - `isN=(isVal-isO)/10;if(isAlt){isN*=2;if(isN>9)isN-=9;}isSum+=isN;isAlt=!isAlt;}}\n` + - `if(isSum%10!==0)${ctx.fail('isISIN')}; }` - ); -}); - -// ISO 8601 -const ISO8601_RE = /^\d{4}(?:-\d{2}(?:-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?)?)?$/; - -interface IsISO8601Options { - strict?: boolean; -} - -// Strict ISO8601: requires month/day AND hour/minute/second to be valid values -function validateISO8601Strict(v: string): boolean { - if (!ISO8601_RE.test(v)) { - return false; - } - const m = v.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?/); - if (!m) { - return true; - } // year-only — no month/day to range-check - const month = Number(m[2]); - if (month < 1 || month > 12) { - return false; - } - if (m[3] !== undefined) { - const day = Number(m[3]); - const maxDay = new Date(Number(m[1]), month, 0).getDate(); - if (day < 1 || day > maxDay) { - return false; - } - } - // Time component check: hour 0-23, minute 0-59, second 0-60 (leap second). - const tm = v.match(/T(\d{2}):(\d{2}):(\d{2})/); - if (!tm) { - return true; - } - const hh = Number(tm[1]); - const mm = Number(tm[2]); - const ss = Number(tm[3]); - return hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59 && ss >= 0 && ss <= 60; -} - -function isISO8601(options?: IsISO8601Options): EmittableRule { - if (options?.strict) { - const validateStrict = (v: unknown): boolean => typeof v === 'string' && validateISO8601Strict(v); - return makeRule({ - name: 'isISO8601', - requiresType: RequiredType.String, - constraints: { format: 'date-time', strict: true }, - validate: validateStrict, - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRegex(ISO8601_RE); - return ( - `if (!re[${i}].test(${varName})) ${ctx.fail('isISO8601')};\n` + - `else { var dm=${varName}.match(/^(\\d{4})-(\\d{2})(?:-(\\d{2}))?/);` + - `if(dm){var mo=Number(dm[2]);` + - `if(mo<1||mo>12){${ctx.fail('isISO8601')}}` + - `else if(dm[3]!==undefined){var da=Number(dm[3]),md=new Date(Number(dm[1]),mo,0).getDate();` + - `if(da<1||da>md){${ctx.fail('isISO8601')}}}}` + - `var tm=${varName}.match(/T(\\d{2}):(\\d{2}):(\\d{2})/);` + - `if(tm){var hh=Number(tm[1]),mm=Number(tm[2]),ss=Number(tm[3]);` + - `if(hh<0||hh>23||mm<0||mm>59||ss<0||ss>60)${ctx.fail('isISO8601')};} }` - ); - }, - }); - } - // non-strict: both validate and emit use same ISO8601_RE - return makeStringRule( - 'isISO8601', - v => ISO8601_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ISO8601_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isISO8601')};`; - }, - RequiredType.String, - { format: 'date-time', strict: false }, - ); -} - -// ISRC — ISO 3901 -const ISRC_RE = /^[A-Z]{2}-[A-Z0-9]{3}-\d{2}-\d{5}$|^[A-Z]{2}[A-Z0-9]{3}\d{7}$/; -const isISRC = makeStringRule( - 'isISRC', - v => ISRC_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ISRC_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isISRC')};`; - }, -); - -// ISSN -interface IsISSNOptions { - requireHyphen?: boolean; -} - -function validateISSN(value: string, options?: IsISSNOptions): boolean { - const requireHyphen = options?.requireHyphen !== false; - const s = requireHyphen ? value : value.replace(/-/g, ''); - // Format with hyphen: NNNN-NNNX, without: NNNNNNXX - const re = requireHyphen ? /^\d{4}-\d{3}[\dX]$/ : /^\d{7}[\dX]$/; - if (!re.test(s)) { - return false; - } - const digits = s.replace(/-/g, ''); - let sum = 0; - for (let i = 0; i < 7; i++) { - sum += (8 - i) * (digits.charCodeAt(i) - 48); - } - const last = digits[7] === 'X' ? 10 : digits.charCodeAt(7) - 48; - sum += last; - return sum % 11 === 0; -} - -function isISSN(options?: IsISSNOptions): EmittableRule { - const requireHyphen = options?.requireHyphen !== false; - const validateIssn = (value: unknown): boolean => typeof value === 'string' && validateISSN(value, options); - - const formatRe = requireHyphen ? /^\d{4}-\d{3}[\dX]$/ : /^\d{7}[\dX]$/; - - return makeRule({ - name: 'isISSN', - requiresType: RequiredType.String, - constraints: { requireHyphen: options?.requireHyphen }, - validate: validateIssn, - emit: (varName: string, ctx: EmitContext): string => { - const ri = ctx.addRegex(formatRe); - const strip = requireHyphen ? varName : `${varName}.replace(/-/g,'')`; - return ( - `{var issn=${strip};` + - `if(!re[${ri}].test(issn)){${ctx.fail('isISSN')}}` + - `else{var id=issn.replace(/-/g,''),iss=0;` + - `for(var ii=0;ii<7;ii++)iss+=(8-ii)*(id.charCodeAt(ii)-48);` + - `var il=id[7]==='X'?10:(id.charCodeAt(7)-48);iss+=il;` + - `if(iss%11!==0)${ctx.fail('isISSN')};}}` - ); - }, - }); -} - -// JWT — 3-part dot-separated base64url -const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; -const isJWT = makeStringRule( - 'isJWT', - v => JWT_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(JWT_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isJWT')};`; - }, -); - -// LatLong -const LAT_LONG_RE = /^[-+]?([1-8]?\d(?:\.\d+)?|90(?:\.0+)?),\s*[-+]?(180(?:\.0+)?|1[0-7]\d(?:\.\d+)?|\d{1,2}(?:\.\d+)?)$/; - -function isLatLong(): EmittableRule { - return makeStringRule( - 'isLatLong', - v => LAT_LONG_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(LAT_LONG_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isLatLong')};`; - }, - ); -} - -// Locale — BCP 47 simplified -const LOCALE_RE = /^[a-zA-Z]{2,3}(?:-[a-zA-Z]{4})?(?:-(?:[a-zA-Z]{2}|\d{3}))?(?:-[a-zA-Z\d]{5,8})*$/; -const isLocale = makeStringRule( - 'isLocale', - v => LOCALE_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(LOCALE_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isLocale')};`; - }, -); - -// DataURI -const DATA_URI_RE = /^data:([a-zA-Z0-9!#$&\-^_]+\/[a-zA-Z0-9!#$&\-^_]+)(?:;[a-zA-Z0-9-]+=[a-zA-Z0-9-]+)*(?:;base64)?,[\s\S]*$/; -const isDataURI = makeStringRule( - 'isDataURI', - v => DATA_URI_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(DATA_URI_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isDataURI')};`; - }, -); - -// FQDN -interface IsFQDNOptions { - require_tld?: boolean; - allow_underscores?: boolean; - allow_trailing_dot?: boolean; -} - -function isFQDN(options?: IsFQDNOptions): EmittableRule { - const requireTld = options?.require_tld !== false; - const allowUnderscores = options?.allow_underscores ?? false; - const allowTrailingDot = options?.allow_trailing_dot ?? false; - - const partRe = allowUnderscores ? /^[a-zA-Z0-9_-]+$/ : /^[a-zA-Z0-9-]+$/; - - const validateFqdn = (value: unknown): boolean => { - if (typeof value !== 'string') { - return false; - } - let str = value; - if (allowTrailingDot && str.endsWith('.')) { - str = str.slice(0, -1); - } - if (str.length === 0) { - return false; - } - const parts = str.split('.'); - if (requireTld && parts.length < 2) { - return false; - } - if (requireTld) { - const tld = parts[parts.length - 1]; - if (!tld || tld.length < 2 || !/^[a-zA-Z]{2,}$/.test(tld)) { - return false; - } - } - return parts.every(part => { - if (part.length === 0 || part.length > 63) { - return false; - } - if (!partRe.test(part)) { - return false; - } - if (!allowUnderscores && (part.startsWith('-') || part.endsWith('-'))) { - return false; - } - return true; - }); - }; - - return makeRule({ - name: 'isFQDN', - requiresType: RequiredType.String, - constraints: { - require_tld: options?.require_tld, - allow_underscores: options?.allow_underscores, - allow_trailing_dot: options?.allow_trailing_dot, - }, - validate: validateFqdn, - emit: (varName: string, ctx: EmitContext): string => { - const ri = ctx.addRegex(partRe); - const tldRi = requireTld ? ctx.addRegex(/^[a-zA-Z]{2,}$/) : -1; - // Inline for-loop instead of fp.every(function(p){...}) — avoids per-call closure - // allocation inside the JIT executor. - const partCheck = - `if(p.length===0||p.length>63){fqOk=false;break;}` + - `if(!re[${ri}].test(p)){fqOk=false;break;}` + - (allowUnderscores ? '' : `if(p[0]==='-'||p[p.length-1]==='-'){fqOk=false;break;}`); - const loopBlock = `var fqOk=true;for(var fi=0;fi PORT_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(PORT_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isPort')};`; - }, -); - -// EAN (EAN-8 and EAN-13 with checksum) -function validateEAN(value: string): boolean { - if (!/^\d{8}$/.test(value) && !/^\d{13}$/.test(value)) { - return false; - } - // Walk via charCodeAt — no split/map array allocations - const len = value.length; - let sum = 0; - for (let i = 0; i < len - 1; i++) { - const d = value.charCodeAt(i) - 48; - sum += d * (len === 8 ? (i % 2 === 0 ? 3 : 1) : i % 2 === 0 ? 1 : 3); - } - const check = (10 - (sum % 10)) % 10; - return check === value.charCodeAt(len - 1) - 48; -} - -const isEAN = makeStringRule('isEAN', validateEAN, (varName, ctx) => { - const re8 = ctx.addRegex(/^\d{8}$/); - const re13 = ctx.addRegex(/^\d{13}$/); - return ( - `{var ev=${varName};` + - `if(!re[${re8}].test(ev)&&!re[${re13}].test(ev)){${ctx.fail('isEAN')}}` + - `else{var el=ev.length,es=0;` + - `for(var ei=0;ei typeof value === 'string' && ISO31661A2_CODES.has(value.toUpperCase()), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRef(ISO31661A2_CODES); - return `if (!refs[${i}].has(${varName}.toUpperCase())) ${ctx.fail('isISO31661Alpha2')};`; - }, -}); - -// ISO 3166-1 Alpha-3 -const ISO31661A3_CODES = new Set([ - 'ABW', - 'AFG', - 'AGO', - 'AIA', - 'ALA', - 'ALB', - 'AND', - 'ANT', - 'ARE', - 'ARG', - 'ARM', - 'ASM', - 'ATA', - 'ATF', - 'ATG', - 'AUS', - 'AUT', - 'AZE', - 'BDI', - 'BEL', - 'BEN', - 'BES', - 'BFA', - 'BGD', - 'BGR', - 'BHR', - 'BHS', - 'BIH', - 'BLM', - 'BLR', - 'BLZ', - 'BMU', - 'BOL', - 'BRA', - 'BRB', - 'BRN', - 'BTN', - 'BVT', - 'BWA', - 'CAF', - 'CAN', - 'CCK', - 'CHE', - 'CHL', - 'CHN', - 'CIV', - 'CMR', - 'COD', - 'COG', - 'COK', - 'COL', - 'COM', - 'CPV', - 'CRI', - 'CUB', - 'CUW', - 'CXR', - 'CYM', - 'CYP', - 'CZE', - 'DEU', - 'DJI', - 'DMA', - 'DNK', - 'DOM', - 'DZA', - 'ECU', - 'EGY', - 'ERI', - 'ESH', - 'ESP', - 'EST', - 'ETH', - 'FIN', - 'FJI', - 'FLK', - 'FRA', - 'FRO', - 'FSM', - 'GAB', - 'GBR', - 'GEO', - 'GGY', - 'GHA', - 'GIB', - 'GIN', - 'GLP', - 'GMB', - 'GNB', - 'GNQ', - 'GRC', - 'GRD', - 'GRL', - 'GTM', - 'GUF', - 'GUM', - 'GUY', - 'HKG', - 'HMD', - 'HND', - 'HRV', - 'HTI', - 'HUN', - 'IDN', - 'IMN', - 'IND', - 'IOT', - 'IRL', - 'IRN', - 'IRQ', - 'ISL', - 'ISR', - 'ITA', - 'JAM', - 'JEY', - 'JOR', - 'JPN', - 'KAZ', - 'KEN', - 'KGZ', - 'KHM', - 'KIR', - 'KNA', - 'KOR', - 'KWT', - 'LAO', - 'LBN', - 'LBR', - 'LBY', - 'LCA', - 'LIE', - 'LKA', - 'LSO', - 'LTU', - 'LUX', - 'LVA', - 'MAC', - 'MAF', - 'MAR', - 'MCO', - 'MDA', - 'MDG', - 'MDV', - 'MEX', - 'MHL', - 'MKD', - 'MLI', - 'MLT', - 'MMR', - 'MNE', - 'MNG', - 'MNP', - 'MOZ', - 'MRT', - 'MSR', - 'MTQ', - 'MUS', - 'MWI', - 'MYS', - 'MYT', - 'NAM', - 'NCL', - 'NER', - 'NFK', - 'NGA', - 'NIC', - 'NIU', - 'NLD', - 'NOR', - 'NPL', - 'NRU', - 'NZL', - 'OMN', - 'PAK', - 'PAN', - 'PCN', - 'PER', - 'PHL', - 'PLW', - 'PNG', - 'POL', - 'PRI', - 'PRK', - 'PRT', - 'PRY', - 'PSE', - 'PYF', - 'QAT', - 'REU', - 'ROU', - 'RUS', - 'RWA', - 'SAU', - 'SDN', - 'SEN', - 'SGP', - 'SGS', - 'SHN', - 'SJM', - 'SLB', - 'SLE', - 'SLV', - 'SMR', - 'SOM', - 'SPM', - 'SRB', - 'SSD', - 'STP', - 'SUR', - 'SVK', - 'SVN', - 'SWE', - 'SWZ', - 'SXM', - 'SYC', - 'SYR', - 'TCA', - 'TCD', - 'TGO', - 'THA', - 'TJK', - 'TKL', - 'TKM', - 'TLS', - 'TON', - 'TTO', - 'TUN', - 'TUR', - 'TUV', - 'TWN', - 'TZA', - 'UGA', - 'UKR', - 'UMI', - 'URY', - 'USA', - 'UZB', - 'VAT', - 'VCT', - 'VEN', - 'VGB', - 'VIR', - 'VNM', - 'VUT', - 'WLF', - 'WSM', - 'YEM', - 'ZAF', - 'ZMB', - 'ZWE', -]); - -const isISO31661Alpha3 = makeRule({ - name: 'isISO31661Alpha3', - requiresType: RequiredType.String, - constraints: {}, - validate: value => typeof value === 'string' && ISO31661A3_CODES.has(value.toUpperCase()), - emit: (varName: string, ctx: EmitContext): string => { - const i = ctx.addRef(ISO31661A3_CODES); - return `if (!refs[${i}].has(${varName}.toUpperCase())) ${ctx.fail('isISO31661Alpha3')};`; - }, -}); - -// BIC / SWIFT code — case-insensitive via /i flag avoids per-call .toUpperCase() string allocation -const BIC_RE = /^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/i; -const isBIC = makeStringRule( - 'isBIC', - v => BIC_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(BIC_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isBIC')};`; - }, -); - -// Firebase Push ID — 20 chars, base64url charset (-0-9A-Za-z_) -const FIREBASE_RE = /^[a-zA-Z0-9_-]{20}$/; -const isFirebasePushId = makeStringRule( - 'isFirebasePushId', - v => FIREBASE_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(FIREBASE_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isFirebasePushId')};`; - }, -); - -// SemVer — Semantic Versioning 2.0 -const SEMVER_RE = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; -const isSemVer = makeStringRule( - 'isSemVer', - v => SEMVER_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(SEMVER_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isSemVer')};`; - }, -); - -// MongoDB ObjectId — 24-char hex -const MONGO_ID_RE = /^[0-9a-fA-F]{24}$/; -const isMongoId = makeStringRule( - 'isMongoId', - v => MONGO_ID_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(MONGO_ID_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isMongoId')};`; - }, -); - -// JSON -const validateJsonString = (value: unknown): boolean => { - if (typeof value !== 'string') { - return false; - } - try { - JSON.parse(value); - return true; - } catch { - return false; - } -}; - -const isJSON = makeRule({ - name: 'isJSON', - requiresType: RequiredType.String, - constraints: {}, - validate: validateJsonString, - emit: (varName: string, ctx: EmitContext): string => `try { JSON.parse(${varName}); } catch { ${ctx.fail('isJSON')}; }`, -}); - -// Base32 -const BASE32_RE = /^[A-Z2-7]+=*$/i; -// Empty-string fails the `+`-quantified regex anyway, so the explicit length===0 check is dead. -function isBase32(): EmittableRule { - return makeStringRule( - 'isBase32', - v => v.length % 8 === 0 && BASE32_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(BASE32_RE); - return `if (${varName}.length % 8 !== 0 || !re[${i}].test(${varName})) ${ctx.fail('isBase32')};`; - }, - ); -} - -// Base58 -const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; -const isBase58 = makeStringRule( - 'isBase58', - v => BASE58_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(BASE58_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isBase58')};`; - }, -); - -// Base64 -const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; -const BASE64_URL_RE = /^[A-Za-z0-9_-]+={0,2}$/; - -interface IsBase64Options { - urlSafe?: boolean; -} - -function isBase64(options?: IsBase64Options): EmittableRule { - const re = options?.urlSafe ? BASE64_URL_RE : BASE64_RE; - // Empty-string check is redundant — both base64 regexes require ≥1 char and fail on empty input. - return makeStringRule( - 'isBase64', - v => re.test(v), - (varName, ctx) => { - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isBase64')};`; - }, - RequiredType.String, - { urlSafe: options?.urlSafe }, - ); -} - -// DateString — ISO 8601 date only (YYYY-MM-DD) with calendar validity (day must exist in month/year). -const DATE_STRING_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; - -function isCalendarValidDate(v: string): boolean { - if (!DATE_STRING_RE.test(v)) { - return false; - } - const y = Number(v.slice(0, 4)); - const m = Number(v.slice(5, 7)); - const d = Number(v.slice(8, 10)); - const maxDay = new Date(y, m, 0).getDate(); - return d >= 1 && d <= maxDay; -} - -function isDateString(): EmittableRule { - return makeStringRule('isDateString', isCalendarValidDate, (varName, ctx) => { - const i = ctx.addRegex(DATE_STRING_RE); - return ( - `if (!re[${i}].test(${varName})) ${ctx.fail('isDateString')};\n` + - `else { var y=Number(${varName}.slice(0,4)),m=Number(${varName}.slice(5,7)),d=Number(${varName}.slice(8,10));` + - `var md=new Date(y,m,0).getDate(); if(d<1||d>md)${ctx.fail('isDateString')}; }` - ); - }); -} - -// MimeType -const MIME_TYPE_RE = - /^(application|audio|font|image|message|model|multipart|text|video)\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*(?:;.+)?$/; -const isMimeType = makeStringRule( - 'isMimeType', - v => MIME_TYPE_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(MIME_TYPE_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isMimeType')};`; - }, -); - -// Currency -// A single optional sign, either before the `$` (`-$5`, `+5`) or after it (`$-5`, `$+5`) — never -// both. The previous `[-+]?\$?-?` allowed two signs (e.g. `+-5`, `-$-5`). -const CURRENCY_RE = /^(?:[-+]?\$?|\$[-+]?)(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?$/; - -function isCurrency(): EmittableRule { - // Currency regex requires at least one digit; empty input fails the regex by itself. - return makeStringRule( - 'isCurrency', - v => CURRENCY_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(CURRENCY_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isCurrency')};`; - }, - ); -} - -// Magnet URI -const MAGNET_URI_RE = /^magnet:\?xt=urn:[a-z0-9]+:[a-z0-9]{32,40}(?:&[a-z][a-z0-9.]*=[^&\s]*)*$/i; -const isMagnetURI = makeStringRule( - 'isMagnetURI', - v => MAGNET_URI_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(MAGNET_URI_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isMagnetURI')};`; - }, -); - -// ───────────────────────────────────────────────────────────────────────────── -// Group D: Algorithm-based -// ───────────────────────────────────────────────────────────────────────────── - -// Credit Card — Luhn algorithm (§4.8 C) -function luhn(str: string): boolean { - const s = str.replace(/[\s-]/g, ''); - if (s.length === 0 || !/^\d+$/.test(s)) { - return false; - } - let sum = 0; - let alternate = false; - for (let i = s.length - 1; i >= 0; i--) { - let n = s.charCodeAt(i) - 48; - if (alternate) { - n *= 2; - if (n > 9) { - n -= 9; - } - } - sum += n; - alternate = !alternate; - } - return sum % 10 === 0; -} - -const isCreditCard = makeRule({ - name: 'isCreditCard', - requiresType: RequiredType.String, - constraints: {}, - validate: value => typeof value === 'string' && luhn(value), - emit: (varName: string, ctx: EmitContext): string => `{ - var cs=${varName}.replace(/[\\s-]/g,''); - if(cs.length===0||!/^\\d+$/.test(cs)){${ctx.fail('isCreditCard')}} - else{var sum=0,alt=false; - for(var ci=cs.length-1;ci>=0;ci--){var cn=cs.charCodeAt(ci)-48;if(alt){cn*=2;if(cn>9)cn-=9;}sum+=cn;alt=!alt;} - if(sum%10!==0)${ctx.fail('isCreditCard')};} -}`, -}); - -// IBAN — ISO 13616 mod-97 -interface IsIBANOptions { - allowSpaces?: boolean; -} - -const IBAN_COUNTRY_LENGTH: Record = { - AD: 24, - AE: 23, - AL: 28, - AT: 20, - AZ: 28, - BA: 20, - BE: 16, - BG: 22, - BH: 22, - BR: 29, - CH: 21, - CR: 22, - CY: 28, - CZ: 24, - DE: 22, - DK: 18, - DO: 28, - EE: 20, - ES: 24, - FI: 18, - FO: 18, - FR: 27, - GB: 22, - GE: 22, - GI: 23, - GL: 18, - GR: 27, - GT: 28, - HR: 21, - HU: 28, - IE: 22, - IL: 23, - IS: 26, - IT: 27, - JO: 30, - KW: 30, - KZ: 20, - LB: 28, - LC: 32, - LI: 21, - LT: 20, - LU: 20, - LV: 21, - MC: 27, - MD: 24, - ME: 22, - MK: 19, - MR: 27, - MT: 31, - MU: 30, - NL: 18, - NO: 15, - PK: 24, - PL: 28, - PS: 29, - PT: 25, - QA: 29, - RO: 24, - RS: 22, - SA: 24, - SC: 31, - SE: 24, - SI: 19, - SK: 24, - SM: 27, - ST: 25, - SV: 28, - TL: 23, - TN: 24, - TR: 26, - UA: 29, - VA: 22, - VG: 24, - XK: 20, -}; - -function validateIBAN(value: string, options?: IsIBANOptions): boolean { - let s = options?.allowSpaces ? value.replace(/\s/g, '') : value; - s = s.toUpperCase(); - if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(s)) { - return false; - } - const country = s.slice(0, 2); - const expectedLength = IBAN_COUNTRY_LENGTH[country]; - if (expectedLength !== undefined && s.length !== expectedLength) { - return false; - } - // Rearrange: move first 4 chars to end - const rearranged = s.slice(4) + s.slice(0, 4); - // Walk char-by-char accumulating mod 97 — no .replace/closure, no String() coercion, - // no parseInt() allocations. - let remainder = 0; - for (let i = 0; i < rearranged.length; i++) { - const code = rearranged.charCodeAt(i); - if (code <= 57) { - // digit - remainder = (remainder * 10 + (code - 48)) % 97; - } else { - // letter A-Z → two digits (value = code - 55) - const value = code - 55; - remainder = (remainder * 100 + value) % 97; - } - } - return remainder === 1; -} - -function isIBAN(options?: IsIBANOptions): EmittableRule { - const allowSpaces = options?.allowSpaces ?? false; - const validateIban = (value: unknown): boolean => typeof value === 'string' && validateIBAN(value, options); - return makeRule({ - name: 'isIBAN', - requiresType: RequiredType.String, - constraints: { allowSpaces: options?.allowSpaces }, - validate: validateIban, - emit: (varName: string, ctx: EmitContext): string => { - const baseRi = ctx.addRegex(/^[A-Z]{2}\d{2}[A-Z0-9]+$/); - const tableIdx = ctx.addRef(IBAN_COUNTRY_LENGTH); - let code = '{'; - code += `var ib=${allowSpaces ? `${varName}.replace(/\\s/g,'')` : varName}.toUpperCase();`; - code += `if(!re[${baseRi}].test(ib)){${ctx.fail('isIBAN')}}`; - code += `else{var ic=ib.slice(0,2),il=refs[${tableIdx}][ic];`; - code += `if(il!==undefined&&ib.length!==il){${ctx.fail('isIBAN')}}`; - code += `else{var ir=ib.slice(4)+ib.slice(0,4);`; - // Walk char-by-char for mod 97 — no .replace closure, no parseInt allocation - code += `var im=0;for(var ii=0;ii { - if (typeof value !== 'string') { - return false; - } - const byteLen = Buffer.byteLength(value, 'utf8'); - if (byteLen < min) { - return false; - } - if (max !== undefined && byteLen > max) { - return false; - } - return true; - }; - return makeRule({ - name: 'isByteLength', - requiresType: RequiredType.String, - constraints: { min, max }, - validate: validateByteLength, - emit: (varName: string, ctx: EmitContext): string => { - let code = `{var bl=Buffer.byteLength(${varName},'utf8');`; - code += `if(bl<${min})${ctx.fail('isByteLength')};`; - if (max !== undefined) { - code += `else if(bl>${max})${ctx.fail('isByteLength')};`; - } - code += '}'; - return code; - }, - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Group E: New Validators -// ───────────────────────────────────────────────────────────────────────────── - -// isHash — per-algorithm hex regex (§4.8 B: regex inline) - -const HASH_REGEXES: Record = { - md5: /^[a-f0-9]{32}$/i, - md4: /^[a-f0-9]{32}$/i, - md2: /^[a-f0-9]{32}$/i, - sha1: /^[a-f0-9]{40}$/i, - sha256: /^[a-f0-9]{64}$/i, - sha384: /^[a-f0-9]{96}$/i, - sha512: /^[a-f0-9]{128}$/i, - ripemd128: /^[a-f0-9]{32}$/i, - ripemd160: /^[a-f0-9]{40}$/i, - 'tiger128,3': /^[a-f0-9]{32}$/i, - 'tiger128,4': /^[a-f0-9]{32}$/i, - 'tiger160,3': /^[a-f0-9]{40}$/i, - 'tiger160,4': /^[a-f0-9]{40}$/i, - 'tiger192,3': /^[a-f0-9]{48}$/i, - 'tiger192,4': /^[a-f0-9]{48}$/i, - crc32: /^[a-f0-9]{8}$/i, - crc32b: /^[a-f0-9]{8}$/i, -}; - -function isHash(algorithm: string): EmittableRule { - const re = HASH_REGEXES[algorithm]; - return makeRule({ - name: 'isHash', - requiresType: RequiredType.String, - constraints: { algorithm }, - validate: value => typeof value === 'string' && !!re && re.test(value), - emit: (varName: string, ctx: EmitContext): string => { - if (!re) { - return ctx.fail('isHash') + ';'; - } - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isHash')};`; - }, - }); -} - -// isRFC3339 — RFC 3339 datetime (§4.8 B) - -const RFC3339_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i; - -const isRFC3339 = makeStringRule( - 'isRFC3339', - v => RFC3339_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(RFC3339_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isRFC3339')};`; - }, -); - -// isMilitaryTime — HH:MM 24-hour format (§4.8 B) - -const MILITARY_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; - -const isMilitaryTime = makeStringRule( - 'isMilitaryTime', - v => MILITARY_TIME_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(MILITARY_TIME_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isMilitaryTime')};`; - }, -); - -// isLatitude — string or number, -90 to 90 (requiresType none) - -function checkLatitude(value: unknown): boolean { - if (typeof value === 'number') { - return value >= -90 && value <= 90; - } - if (typeof value === 'string') { - const n = parseFloat(value); - if (isNaN(n)) { - return false; - } - // parseFloat('90abc') = 90 — strict regex check rejects trailing garbage - if (!/^-?\d+(\.\d+)?$/.test(value)) { - return false; - } - return n >= -90 && n <= 90; - } - return false; -} - -const isLatitude = makeRule({ - name: 'isLatitude', - constraints: {}, - validate: checkLatitude, - emit: (varName: string, ctx: EmitContext): string => { - const ri = ctx.addRegex(/^-?\d+(\.\d+)?$/); - return ( - `if(typeof ${varName}==='number'){if(${varName}<-90||${varName}>90)${ctx.fail('isLatitude')};}` + - `else if(typeof ${varName}==='string'){` + - // Regex catches non-numeric strings; if it matches, parseFloat is guaranteed valid (no isNaN check needed) - `if(!re[${ri}].test(${varName})){${ctx.fail('isLatitude')}}` + - `else{var lt=parseFloat(${varName});if(lt<-90||lt>90)${ctx.fail('isLatitude')};}}` + - `else{${ctx.fail('isLatitude')};}` - ); - }, -}); - -// isLongitude — string or number, -180 to 180 (requiresType none) - -function checkLongitude(value: unknown): boolean { - if (typeof value === 'number') { - return value >= -180 && value <= 180; - } - if (typeof value === 'string') { - const n = parseFloat(value); - if (isNaN(n)) { - return false; - } - if (!/^-?\d+(\.\d+)?$/.test(value)) { - return false; - } - return n >= -180 && n <= 180; - } - return false; -} - -const isLongitude = makeRule({ - name: 'isLongitude', - constraints: {}, - validate: checkLongitude, - emit: (varName: string, ctx: EmitContext): string => { - const ri = ctx.addRegex(/^-?\d+(\.\d+)?$/); - return ( - `if(typeof ${varName}==='number'){if(${varName}<-180||${varName}>180)${ctx.fail('isLongitude')};}` + - `else if(typeof ${varName}==='string'){` + - `if(!re[${ri}].test(${varName})){${ctx.fail('isLongitude')}}` + - `else{var ln=parseFloat(${varName});if(ln<-180||ln>180)${ctx.fail('isLongitude')};}}` + - `else{${ctx.fail('isLongitude')};}` - ); - }, -}); - -// isEthereumAddress — 0x + 40 hex chars (§4.8 B) - -const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; - -const isEthereumAddress = makeStringRule( - 'isEthereumAddress', - v => ETH_ADDRESS_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ETH_ADDRESS_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isEthereumAddress')};`; - }, -); - -// isBtcAddress — P2PKH (1...), P2SH (3...), bech32 (bc1...) (§4.8 B) - -const BTC_P2PKH_RE = /^1[a-km-zA-HJ-NP-Z1-9]{25,34}$/; -const BTC_P2SH_RE = /^3[a-km-zA-HJ-NP-Z1-9]{25,34}$/; -const BTC_BECH32_RE = /^(bc1)[a-z0-9]{6,87}$/; - -const isBtcAddress = makeStringRule( - 'isBtcAddress', - v => BTC_P2PKH_RE.test(v) || BTC_P2SH_RE.test(v) || BTC_BECH32_RE.test(v), - (varName, ctx) => { - const i1 = ctx.addRegex(BTC_P2PKH_RE); - const i2 = ctx.addRegex(BTC_P2SH_RE); - const i3 = ctx.addRegex(BTC_BECH32_RE); - return `if (!re[${i1}].test(${varName}) && !re[${i2}].test(${varName}) && !re[${i3}].test(${varName})) ${ctx.fail('isBtcAddress')};`; - }, -); - -// isISO4217CurrencyCode — ISO 4217 currency code set (§4.8 C: ref-based) - -const ISO4217_CODES = new Set([ - 'AED', - 'AFN', - 'ALL', - 'AMD', - 'ANG', - 'AOA', - 'ARS', - 'AUD', - 'AWG', - 'AZN', - 'BAM', - 'BBD', - 'BDT', - 'BGN', - 'BHD', - 'BIF', - 'BMD', - 'BND', - 'BOB', - 'BOV', - 'BRL', - 'BSD', - 'BTN', - 'BWP', - 'BYN', - 'BZD', - 'CAD', - 'CDF', - 'CHE', - 'CHF', - 'CHW', - 'CLF', - 'CLP', - 'CNY', - 'COP', - 'COU', - 'CRC', - 'CUC', - 'CUP', - 'CVE', - 'CZK', - 'DJF', - 'DKK', - 'DOP', - 'DZD', - 'EGP', - 'ERN', - 'ETB', - 'EUR', - 'FJD', - 'FKP', - 'GBP', - 'GEL', - 'GHS', - 'GIP', - 'GMD', - 'GNF', - 'GTQ', - 'GYD', - 'HKD', - 'HNL', - 'HRK', - 'HTG', - 'HUF', - 'IDR', - 'ILS', - 'INR', - 'IQD', - 'IRR', - 'ISK', - 'JMD', - 'JOD', - 'JPY', - 'KES', - 'KGS', - 'KHR', - 'KMF', - 'KPW', - 'KRW', - 'KWD', - 'KYD', - 'KZT', - 'LAK', - 'LBP', - 'LKR', - 'LRD', - 'LSL', - 'LYD', - 'MAD', - 'MDL', - 'MGA', - 'MKD', - 'MMK', - 'MNT', - 'MOP', - 'MRU', - 'MUR', - 'MVR', - 'MWK', - 'MXN', - 'MXV', - 'MYR', - 'MZN', - 'NAD', - 'NGN', - 'NIO', - 'NOK', - 'NPR', - 'NZD', - 'OMR', - 'PAB', - 'PEN', - 'PGK', - 'PHP', - 'PKR', - 'PLN', - 'PYG', - 'QAR', - 'RON', - 'RSD', - 'RUB', - 'RWF', - 'SAR', - 'SBD', - 'SCR', - 'SDG', - 'SEK', - 'SGD', - 'SHP', - 'SLE', - 'SLL', - 'SOS', - 'SRD', - 'SSP', - 'STN', - 'SVC', - 'SYP', - 'SZL', - 'THB', - 'TJS', - 'TMT', - 'TND', - 'TOP', - 'TRY', - 'TTD', - 'TWD', - 'TZS', - 'UAH', - 'UGX', - 'USD', - 'USN', - 'UYI', - 'UYU', - 'UYW', - 'UZS', - 'VED', - 'VES', - 'VND', - 'VUV', - 'WST', - 'XAF', - 'XAG', - 'XAU', - 'XBA', - 'XBB', - 'XBC', - 'XBD', - 'XCD', - 'XDR', - 'XOF', - 'XPD', - 'XPF', - 'XPT', - 'XSU', - 'XTS', - 'XUA', - 'YER', - 'ZAR', - 'ZMW', - 'ZWL', -]); - -const isISO4217CurrencyCode = makeStringRule( - 'isISO4217CurrencyCode', - v => ISO4217_CODES.has(v), - (varName, ctx) => { - const i = ctx.addRef(ISO4217_CODES); - return `if (!refs[${i}].has(${varName})) ${ctx.fail('isISO4217CurrencyCode')};`; - }, -); - -// isPhoneNumber — E.164 international phone number (§4.8 B) - -const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/; - -const isPhoneNumber = makeStringRule( - 'isPhoneNumber', - v => PHONE_E164_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(PHONE_E164_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isPhoneNumber')};`; - }, -); - -// isStrongPassword — strong password check (§4.8 C: factory) - -interface IsStrongPasswordOptions { - minLength?: number; - minLowercase?: number; - minUppercase?: number; - minNumbers?: number; - minSymbols?: number; -} - -function isStrongPassword(options?: IsStrongPasswordOptions): EmittableRule { - const minLength = options?.minLength ?? 8; - const minLower = options?.minLowercase ?? 1; - const minUpper = options?.minUppercase ?? 1; - const minNums = options?.minNumbers ?? 1; - const minSymbols = options?.minSymbols ?? 1; - - // Single-pass character classification — counts all categories in one scan. - // Replaces 4× v.match(/.../g) which allocates 4 result arrays per call. - const validate = (v: string): boolean => { - if (v.length < minLength) { - return false; - } - let lower = 0; - let upper = 0; - let nums = 0; - let symbols = 0; - for (let i = 0; i < v.length; i++) { - const c = v.charCodeAt(i); - if (c >= 97 && c <= 122) { - lower++; - } else if (c >= 65 && c <= 90) { - upper++; - } else if (c >= 48 && c <= 57) { - nums++; - } else { - symbols++; - } - } - return lower >= minLower && upper >= minUpper && nums >= minNums && symbols >= minSymbols; - }; - - return makeRule({ - name: 'isStrongPassword', - requiresType: RequiredType.String, - constraints: {}, - validate: value => typeof value === 'string' && validate(value), - emit: (varName: string, ctx: EmitContext): string => { - // Inline single-pass scan in the JIT executor — no regex match[] allocations - const failExpr = ctx.fail('isStrongPassword'); - const checks: string[] = []; - if (minLower > 0) { - checks.push(`spLo<${minLower}`); - } - if (minUpper > 0) { - checks.push(`spUp<${minUpper}`); - } - if (minNums > 0) { - checks.push(`spNum<${minNums}`); - } - if (minSymbols > 0) { - checks.push(`spSym<${minSymbols}`); - } - const guard = checks.length === 0 ? '' : `if(${checks.join('||')}){${failExpr}}`; - return ( - `if(${varName}.length<${minLength}){${failExpr}}else{` + - `var spLo=0,spUp=0,spNum=0,spSym=0;` + - `for(var spI=0;spI<${varName}.length;spI++){var spC=${varName}.charCodeAt(spI);` + - `if(spC>=97&&spC<=122)spLo++;else if(spC>=65&&spC<=90)spUp++;else if(spC>=48&&spC<=57)spNum++;else spSym++;}` + - guard + - `}` - ); - }, - }); -} - -// isTaxId — locale-specific tax identifier (§4.8 C: factory) - -const TAX_ID_REGEXES: Record = { - US: /^\d{2}-\d{7}$/, // EIN format: XX-XXXXXXX - KR: /^\d{3}-\d{2}-\d{5}$/, // Business Registration Number: XXX-XX-XXXXX - DE: /^\d{11}$/, // Steuernummer: 11 digits - FR: /^[0-9]{13}$/, // SIRET: 13 digits - GB: /^\d{10}$/, // UTR: 10 digits - IT: /^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/i, // Codice Fiscale - ES: /^[0-9A-Z]\d{7}[0-9A-Z]$/i, // NIF/NIE/CIF - AU: /^\d{11}$/, // ABN: 11 digits - CA: /^\d{9}$/, // BN: 9 digits - IN: /^[A-Z]{5}\d{4}[A-Z]$/i, // PAN: XXXXX9999X -}; - -function isTaxId(locale: string): EmittableRule { - const re = TAX_ID_REGEXES[locale]; - return makeRule({ - name: 'isTaxId', - requiresType: RequiredType.String, - constraints: { locale }, - validate: value => typeof value === 'string' && !!re && re.test(value), - emit: (varName: string, ctx: EmitContext): string => { - if (!re) { - return ctx.fail('isTaxId') + ';'; - } - const i = ctx.addRegex(re); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isTaxId')};`; - }, - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// ULID -// ───────────────────────────────────────────────────────────────────────────── - -const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/; - -function isULID(): EmittableRule { - return makeStringRule( - 'isULID', - v => ULID_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(ULID_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isULID')};`; - }, - RequiredType.String, - { format: 'ulid' }, - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// CUID2 -// ───────────────────────────────────────────────────────────────────────────── - -// CUID2 spec: length 24-32, lowercase alphanum, starts with a-z. -const CUID2_RE = /^[a-z][0-9a-z]{23,31}$/; - -function isCUID2(): EmittableRule { - return makeStringRule( - 'isCUID2', - v => CUID2_RE.test(v), - (varName, ctx) => { - const i = ctx.addRegex(CUID2_RE); - return `if (!re[${i}].test(${varName})) ${ctx.fail('isCUID2')};`; - }, - RequiredType.String, - { format: 'cuid2' }, - ); -} export { minLength, maxLength, @@ -2454,72 +19,52 @@ export { isBooleanString, isNumberString, isDecimal, - isFullWidth, - isHalfWidth, - isVariableWidth, - isMultibyte, - isSurrogatePair, - isHexadecimal, - isOctal, +} from './string-basic'; +export type { IsNumberStringOptions } from './string-basic'; + +export { isFullWidth, isHalfWidth, isVariableWidth, isMultibyte, isSurrogatePair } from './string-width'; + +export { isHexadecimal, isOctal, isHexColor, isRgbColor, isHSL, isBase32, isBase58, isBase64 } from './string-encoding'; +export type { IsBase64Options } from './string-encoding'; + +export { isEmail, isURL, isUUID, isIP, - isHexColor, - isRgbColor, - isHSL, isMACAddress, - isISBN, - isISIN, - isISO8601, - isISRC, - isISSN, isJWT, - isLatLong, isLocale, isDataURI, isFQDN, isPort, - isEAN, - isISO31661Alpha2, - isISO31661Alpha3, - isBIC, - isFirebasePushId, - isSemVer, - isMongoId, isJSON, - isBase32, - isBase58, - isBase64, - isDateString, isMimeType, - isCurrency, isMagnetURI, - isCreditCard, - isIBAN, isByteLength, - isHash, - isRFC3339, - isMilitaryTime, - isLatitude, - isLongitude, - isEthereumAddress, - isBtcAddress, - isISO4217CurrencyCode, isPhoneNumber, isStrongPassword, isTaxId, +} from './string-format'; +export type { IsURLOptions, IsMACAddressOptions, IsFQDNOptions, IsStrongPasswordOptions } from './string-format'; + +export { isLatLong, isLatitude, isLongitude } from './string-geo'; +export { isEthereumAddress, isBtcAddress, isHash } from './string-crypto'; +export { isRFC3339, isMilitaryTime } from './string-datetime'; + +export { + isISO8601, + isISRC, + isISO31661Alpha2, + isISO31661Alpha3, + isFirebasePushId, + isSemVer, + isMongoId, + isDateString, isULID, isCUID2, -}; -export type { - IsNumberStringOptions, - IsURLOptions, - IsMACAddressOptions, - IsISO8601Options, - IsISSNOptions, - IsFQDNOptions, - IsBase64Options, - IsIBANOptions, - IsStrongPasswordOptions, -}; +} from './string-identifier'; +export type { IsISO8601Options } from './string-identifier'; + +export { isISBN, isISIN, isISSN, isEAN, isBIC, isCreditCard, isIBAN, isCurrency, isISO4217CurrencyCode } from './string-finance'; +export type { IsISSNOptions, IsIBANOptions } from './string-finance'; diff --git a/src/rules/typechecker.spec.ts b/src/rules/typechecker.spec.ts index 9b0c5b8..6530e99 100644 --- a/src/rules/typechecker.spec.ts +++ b/src/rules/typechecker.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './interfaces'; +import { RequiredType } from './enums'; import { isString, isNumber, @@ -292,6 +292,35 @@ describe('isEnum', () => { expect(rule(1)).toBe(true); }); + it('should return false for a numeric enum member NAME (reverse-mapping artifact)', () => { + // Arrange — TS numeric enums compile to a reverse-mapped object ({ 0:'Inactive', 1:'Active', + // Active:1, Inactive:0 }); the member-name strings must NOT count as valid values. + const rule = isEnum(Status); + // Act / Assert + expect(rule('Active')).toBe(false); + expect(rule('Inactive')).toBe(false); + }); + + it('should accept the zero value of a numeric enum', () => { + // Arrange — Inactive = 0 is falsy but a legitimate member value + const rule = isEnum(Status); + // Act / Assert + expect(rule(0)).toBe(true); + }); + + it('should handle heterogeneous (mixed numeric/string) enums', () => { + // Arrange — only the numeric member gets a reverse mapping + enum Mixed { + Num = 1, + Str = 'STR', + } + const rule = isEnum(Mixed); + // Act / Assert + expect(rule(1)).toBe(true); + expect(rule('STR')).toBe(true); + expect(rule('Num')).toBe(false); + }); + it('should return false when value is not in enum', () => { // Arrange const rule = isEnum(Direction); diff --git a/src/rules/typechecker.ts b/src/rules/typechecker.ts index f9a6c7a..91b5aa2 100644 --- a/src/rules/typechecker.ts +++ b/src/rules/typechecker.ts @@ -1,10 +1,16 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './interfaces'; -import { RequiredType } from '../enums'; -import { makeRule } from '../rule-plan'; +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; + +// Codegen for the isNumber maxDecimalPlaces check — `decimals = max(0, mantissaDigits - exponent)` +// via toExponential(). Single source for both the inside-gate and standalone emit branches. +function emitMaxDecimalCheck(varName: string, maxDecimalPlaces: number, ctx: EmitContext): string { + return `{ var exp=${varName}.toExponential().split('e'); var mant=(exp[0].split('.')[1]||'').length; var exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; +} // ───────────────────────────────────────────────────────────────────────────── -// isString — typeof check (§4.8 A: operator inline) +// isString — typeof check (operator inline) // ───────────────────────────────────────────────────────────────────────────── export const isString = makeRule({ @@ -15,7 +21,7 @@ export const isString = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isNumber — typeof + NaN/Infinity/maxDecimalPlaces options (§4.8 A) +// isNumber — typeof + NaN/Infinity/maxDecimalPlaces options // ───────────────────────────────────────────────────────────────────────────── export interface IsNumberOptions { @@ -29,6 +35,19 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { const allowInfinity = options?.allowInfinity ?? false; const maxDecimalPlaces = options?.maxDecimalPlaces; + // Expose only the options the caller actually set — omit undefined keys so `rule.constraints` + // has a consistent shape with the other factories (no phantom `allowNaN: undefined`). + const constraints: Record = {}; + if (options?.allowNaN !== undefined) { + constraints.allowNaN = options.allowNaN; + } + if (options?.allowInfinity !== undefined) { + constraints.allowInfinity = options.allowInfinity; + } + if (maxDecimalPlaces !== undefined) { + constraints.maxDecimalPlaces = maxDecimalPlaces; + } + const validate = (value: unknown): boolean => { if (typeof value !== 'number') { return false; @@ -54,11 +73,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { return makeRule({ name: 'isNumber', - constraints: { - allowNaN: options?.allowNaN, - allowInfinity: options?.allowInfinity, - maxDecimalPlaces: options?.maxDecimalPlaces, - }, + constraints, validate, emit: (varName: string, ctx: EmitContext): string => { if (ctx.insideTypeGate) { @@ -68,7 +83,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { code += `if (${varName} === Infinity || ${varName} === -Infinity) ${ctx.fail('isNumber')};`; } if (maxDecimalPlaces !== undefined) { - code += `${code ? '\nelse ' : ''}{ var exp=${varName}.toExponential().split('e'); var mant=(exp[0].split('.')[1]||'').length; var exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; + code += `${code ? '\nelse ' : ''}${emitMaxDecimalCheck(varName, maxDecimalPlaces, ctx)}`; } return code; } @@ -80,7 +95,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { code += `\nelse if (${varName} === Infinity || ${varName} === -Infinity) ${ctx.fail('isNumber')};`; } if (maxDecimalPlaces !== undefined) { - code += `\nelse { var exp=${varName}.toExponential().split('e'); var mant=(exp[0].split('.')[1]||'').length; var exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; + code += `\nelse ${emitMaxDecimalCheck(varName, maxDecimalPlaces, ctx)}`; } return code; }, @@ -88,7 +103,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// isBoolean — typeof check (§4.8 A) +// isBoolean — typeof check // ───────────────────────────────────────────────────────────────────────────── export const isBoolean = makeRule({ @@ -99,7 +114,7 @@ export const isBoolean = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isDate — instanceof Date + getTime() NaN check (§4.8 A) +// isDate — instanceof Date + getTime() NaN check // ───────────────────────────────────────────────────────────────────────────── export const isDate = makeRule({ @@ -111,11 +126,17 @@ export const isDate = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isEnum — factory: indexOf check using Object.values array (§4.8 C) +// isEnum — factory: indexOf check using Object.values array // ───────────────────────────────────────────────────────────────────────────── export function isEnum(entity: object): EmittableRule { - const values = Object.values(entity); + // TS numeric enums compile to a reverse-mapped object ({ 0: 'Inactive', 1: 'Active', Active: 1, + // Inactive: 0 }), so Object.values would also yield the member-name strings. Read values through the + // non-numeric keys instead — this drops the reverse-map entries while keeping every real member, + // and works for string, numeric, and heterogeneous enums. + const values = Object.keys(entity) + .filter(key => Number.isNaN(Number(key))) + .map(key => (entity as Record)[key]); // Set lookup is O(1); array indexOf is O(n). Measured (Bun/JSC): // - 4 items: indexOf 1.2 ns vs Set.has 2.2 ns (indexOf marginally faster) // - 50 items: indexOf 64 ns vs Set.has 8.4 ns (Set 7.5x faster) @@ -139,7 +160,7 @@ export function isEnum(entity: object): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// isInt — typeof + Number.isInteger check (§4.8 A) +// isInt — typeof + Number.isInteger check // ───────────────────────────────────────────────────────────────────────────── export const isInt = makeRule({ @@ -154,7 +175,7 @@ export const isInt = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isArray — Array.isArray check (§4.8 A: operator inline) +// isArray — Array.isArray check (operator inline) // ───────────────────────────────────────────────────────────────────────────── export const isArray = makeRule({ @@ -165,7 +186,7 @@ export const isArray = makeRule({ }); // ───────────────────────────────────────────────────────────────────────────── -// isObject — typeof object + non-null + non-array (§4.8 A) +// isObject — typeof object + non-null + non-array // ───────────────────────────────────────────────────────────────────────────── export const isObject = makeRule({ @@ -195,8 +216,7 @@ export const isFunction = makeRule({ name: 'isFunction', constraints: {}, validate: value => typeof value === 'function', - emit: (varName: string, ctx: EmitContext): string => - `if (typeof ${varName} !== 'function') ${ctx.fail('isFunction')};`, + emit: (varName: string, ctx: EmitContext): string => `if (typeof ${varName} !== 'function') ${ctx.fail('isFunction')};`, }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/types.ts b/src/rules/types.ts new file mode 100644 index 0000000..4172d73 --- /dev/null +++ b/src/rules/types.ts @@ -0,0 +1,21 @@ +import type { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; + +// Member/Call0 always operate on the field value itself (`value.length` / `value.getTime()`); there is +// no nested-object form, so the node carries only the operation descriptor — no recursive `object`. +export type RulePlanExpr = + | { kind: RulePlanExprKind.Value } + | { kind: RulePlanExprKind.Member; property: 'length' } + | { kind: RulePlanExprKind.Call0; method: 'getTime' } + | { kind: RulePlanExprKind.Literal; value: number }; + +export type RulePlanCheck = + | { kind: RulePlanCheckKind.Compare; left: RulePlanExpr; op: RuleOp; right: RulePlanExpr } + | { kind: RulePlanCheckKind.And | RulePlanCheckKind.Or; checks: RulePlanCheck[] }; + +// Accessor-cache the plan emit shares with its caller: when a field hoists `value.length` / +// `value.getTime()` into a local, the emitter reuses that local instead of re-reading. The seal +// builder constructs this and passes it to `emitRulePlan`, so the shape lives here as the contract. +export type RulePlanCache = { + length?: string; + time?: string; +}; diff --git a/src/functions/check-call-options.ts b/src/runtime/check-call-options.ts similarity index 77% rename from src/functions/check-call-options.ts rename to src/runtime/check-call-options.ts index de6c3ab..96e33ad 100644 --- a/src/functions/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -1,20 +1,7 @@ -import type { RuntimeOptions } from '../interfaces'; +import type { RuntimeOptions } from '../common'; -import { BakerError } from '../errors'; - -const CALL_OPTION_KEYS = new Set(['groups']); -const SEAL_TIME_KEYS = new Set([ - // BakerConfig (public, configure-time) - 'autoConvert', - 'allowClassDefaults', - 'stopAtFirstError', - 'forbidUnknown', - 'debug', - // SealOptions (internal, legacy aliases — same set covered by public names) - 'enableImplicitConversion', - 'exposeDefaultValues', - 'whitelist', -]); +import { BakerError } from '../common'; +import { CALL_OPTION_KEYS, SEAL_TIME_KEYS } from './constants'; /** * @internal — validate per-call options object at public-API entry. @@ -40,7 +27,12 @@ export function checkCallOptions(opts: unknown): RuntimeOptions | undefined { throw new BakerError(`Call options must be a plain object literal. Received instance of ${ctorName}.`); } for (const key of Object.keys(opts)) { - if (CALL_OPTION_KEYS.has(key)) { + if (key === 'groups') { + const groups = (opts as RuntimeOptions).groups; + if (groups !== undefined && (!Array.isArray(groups) || groups.some(g => typeof g !== 'string'))) { + const received = Array.isArray(groups) ? 'an array with a non-string element' : typeof groups; + throw new BakerError(`Call option 'groups' must be a string[] of group names. Received: ${received}.`); + } continue; } if (SEAL_TIME_KEYS.has(key)) { diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts new file mode 100644 index 0000000..2ed8c69 --- /dev/null +++ b/src/runtime/constants.ts @@ -0,0 +1,11 @@ +import { BAKER_CONFIG_KEYS } from '../config'; +import { SEAL_OPTION_KEYS } from '../seal'; + +/** The only valid per-call option keys — single source for both validation and error messages. */ +export const CALL_OPTION_KEYS = new Set(['groups']); + +// Seal-time keys rejected per-call: the public BakerConfig names (single source: BAKER_CONFIG_KEYS) +// plus the internal SealOptions names they normalize to (single source: SEAL_OPTION_KEYS). Both are +// derived from their key sets, so a renamed/added option can never silently fall through to the +// generic "unknown option" message. +export const SEAL_TIME_KEYS = new Set([...BAKER_CONFIG_KEYS, ...SEAL_OPTION_KEYS]); diff --git a/src/functions/deserialize.spec.ts b/src/runtime/deserialize.spec.ts similarity index 86% rename from src/functions/deserialize.spec.ts rename to src/runtime/deserialize.spec.ts index 8be70b1..6a11b2a 100644 --- a/src/functions/deserialize.spec.ts +++ b/src/runtime/deserialize.spec.ts @@ -1,13 +1,13 @@ import { err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { SealedExecutors } from '../seal/interfaces'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { Baker } from '../baker'; +import { isBakerIssueSet, BakerError } from '../common/errors'; import { Field } from '../decorators/field'; -import { isBakerIssueSet, BakerError } from '../errors'; import { isString } from '../rules/typechecker'; import { runDeserialize } from './deserialize'; @@ -39,7 +39,10 @@ describe('runDeserialize', () => { it('should return T instance when deserialize returns valid value', async () => { const instance = { name: 'Alice' }; - const result = await runDeserialize(sealedFor(() => instance), { name: 'Alice' }); + const result = await runDeserialize( + sealedFor(() => instance), + { name: 'Alice' }, + ); expect(isBakerIssueSet(result)).toBe(false); expect(result).toBe(instance); }); @@ -75,7 +78,10 @@ describe('runDeserialize', () => { it('should return BakerIssueSet when deserialize returns Err', async () => { const errors = [{ path: 'name', code: 'isString' }]; - const result = await runDeserialize(sealedFor(() => err(errors)), { name: 42 }); + const result = await runDeserialize( + sealedFor(() => err(errors)), + { name: 42 }, + ); expect(isBakerIssueSet(result)).toBe(true); }); @@ -84,19 +90,28 @@ describe('runDeserialize', () => { { path: 'name', code: 'isString' }, { path: 'email', code: 'isEmail' }, ]; - const result = await runDeserialize(sealedFor(() => err(errors)), {}); + const result = await runDeserialize( + sealedFor(() => err(errors)), + {}, + ); assertBakerIssueSet(result); expect(result.errors).toEqual(errors); }); it('should return BakerIssueSet(code:invalidInput) when deserialize returns invalidInput error', async () => { - const result = await runDeserialize(sealedFor(() => err([{ path: '', code: 'invalidInput' }])), null); + const result = await runDeserialize( + sealedFor(() => err([{ path: '', code: 'invalidInput' }])), + null, + ); assertBakerIssueSet(result); expect(result.errors[0]!.code).toBe('invalidInput'); }); it('should return BakerIssueSet when deserialize returns Err for array input', async () => { - const result = await runDeserialize(sealedFor(() => err([{ path: '', code: 'invalidInput' }])), [1, 2, 3]); + const result = await runDeserialize( + sealedFor(() => err([{ path: '', code: 'invalidInput' }])), + [1, 2, 3], + ); expect(isBakerIssueSet(result)).toBe(true); }); @@ -104,7 +119,10 @@ describe('runDeserialize', () => { it('should return T when deserialize succeeds with empty {} input for class with no fields', async () => { const instance = {}; - const result = await runDeserialize(sealedFor(() => instance), {}); + const result = await runDeserialize( + sealedFor(() => instance), + {}, + ); expect(result).toBe(instance); }); @@ -127,18 +145,27 @@ describe('runDeserialize', () => { it('should return direct value when isAsync is false', () => { const instance = {}; - const result = runDeserialize(sealedFor(() => instance, { isAsync: false }), {}); + const result = runDeserialize( + sealedFor(() => instance, { isAsync: false }), + {}, + ); expect(result).toBe(instance); }); it('should use async path when isAsync is true', async () => { const instance = {}; - const result = await runDeserialize(sealedFor(() => Promise.resolve(instance), { isAsync: true }), {}); + const result = await runDeserialize( + sealedFor(() => Promise.resolve(instance), { isAsync: true }), + {}, + ); expect(result).toBe(instance); }); it('should return BakerIssueSet when sync executor returns Err', async () => { - const result = await runDeserialize(sealedFor(() => err([{ path: 'x', code: 'fail' }]), { isAsync: false }), {}); + const result = await runDeserialize( + sealedFor(() => err([{ path: 'x', code: 'fail' }]), { isAsync: false }), + {}, + ); expect(isBakerIssueSet(result)).toBe(true); }); diff --git a/src/functions/deserialize.ts b/src/runtime/deserialize.ts similarity index 53% rename from src/functions/deserialize.ts rename to src/runtime/deserialize.ts index a58a30b..f4d9e6e 100644 --- a/src/functions/deserialize.ts +++ b/src/runtime/deserialize.ts @@ -1,15 +1,26 @@ +import type { Result } from '@zipbul/result'; + import { isErr } from '@zipbul/result'; -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions, BakerIssue, BakerIssueSet } from '../common'; +import type { SealedExecutors } from '../seal'; -import { toBakerIssueSet, BakerError, type BakerIssue, type BakerIssueSet } from '../errors'; +import { toBakerIssueSet, BakerError } from '../common'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── -// run* helpers — post-resolution dispatch, shared by the global functions and Baker methods +// run* helpers — post-resolution dispatch, shared by the Baker deserialize methods // ───────────────────────────────────────────────────────────────────────────── +/** + * Map a deserialize Result to the public `T | BakerIssueSet` shape. `isErr` types the + * error payload, so no cast is needed on the error arm; the `unknown → T` assertion on the success arm + * is unavoidable — the sealed executor is generically typed `SealedExecutors` at this boundary. + */ +function unwrapDeserialize(result: Result): T | BakerIssueSet { + return isErr(result) ? toBakerIssueSet(result.data) : (result as T); +} + function runDeserialize( sealed: SealedExecutors, input: unknown, @@ -17,18 +28,9 @@ function runDeserialize( ): T | BakerIssueSet | Promise { const checkedOpts = checkCallOptions(options); if (sealed.isAsync) { - return (sealed.deserialize(input, checkedOpts) as Promise).then((result): T | BakerIssueSet => { - if (isErr(result)) { - return toBakerIssueSet(result.data as BakerIssue[]); - } - return result as T; - }); + return Promise.resolve(sealed.deserialize(input, checkedOpts)).then(r => unwrapDeserialize(r)); } - const result = sealed.deserialize(input, checkedOpts); - if (isErr(result)) { - return toBakerIssueSet(result.data as BakerIssue[]); - } - return result as T; + return unwrapDeserialize(sealed.deserialize(input, checkedOpts)); } function runDeserializeSync( @@ -41,11 +43,7 @@ function runDeserializeSync( if (sealed.isAsync) { throw new BakerError(`deserializeSync(${className}): DTO has async rules/transforms. Use deserializeAsync() instead.`); } - const result = sealed.deserialize(input, checkedOpts); - if (isErr(result)) { - return toBakerIssueSet(result.data as BakerIssue[]); - } - return result as T; + return unwrapDeserialize(sealed.deserialize(input, checkedOpts)); } function runDeserializeAsync( @@ -55,18 +53,9 @@ function runDeserializeAsync( ): Promise { const checkedOpts = checkCallOptions(options); if (sealed.isAsync) { - return (sealed.deserialize(input, checkedOpts) as Promise).then((result): T | BakerIssueSet => { - if (isErr(result)) { - return toBakerIssueSet(result.data as BakerIssue[]); - } - return result as T; - }); - } - const result = sealed.deserialize(input, checkedOpts); - if (isErr(result)) { - return Promise.resolve(toBakerIssueSet(result.data as BakerIssue[])); + return Promise.resolve(sealed.deserialize(input, checkedOpts)).then(r => unwrapDeserialize(r)); } - return Promise.resolve(result as T); + return Promise.resolve(unwrapDeserialize(sealed.deserialize(input, checkedOpts))); } export { runDeserialize, runDeserializeSync, runDeserializeAsync }; diff --git a/src/runtime/index.ts b/src/runtime/index.ts new file mode 100644 index 0000000..202831f --- /dev/null +++ b/src/runtime/index.ts @@ -0,0 +1,4 @@ +// Directory barrel — the run stage's per-call executor drivers. +export { runDeserialize, runDeserializeSync, runDeserializeAsync } from './deserialize'; +export { resolveSerializeClass, runSerialize, runSerializeSync, runSerializeAsync } from './serialize'; +export { runValidate, runValidateSync, runValidateAsync } from './validate'; diff --git a/src/functions/serialize.spec.ts b/src/runtime/serialize.spec.ts similarity index 92% rename from src/functions/serialize.spec.ts rename to src/runtime/serialize.spec.ts index 6a082ca..298efed 100644 --- a/src/functions/serialize.spec.ts +++ b/src/runtime/serialize.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'bun:test'; -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { SealedExecutors } from '../seal/interfaces'; import { Baker } from '../baker'; +import { BakerError } from '../common/errors'; import { Field } from '../decorators/field'; -import { BakerError } from '../errors'; import { isString } from '../rules/typechecker'; import { resolveSerializeClass, runSerialize } from './serialize'; @@ -37,7 +37,10 @@ describe('runSerialize', () => { it('should return Record when serialize returns plain object', async () => { const record = { name: 'Alice' }; - const result = await runSerialize(sealedFor(() => record), {}); + const result = await runSerialize( + sealedFor(() => record), + {}, + ); expect(result).toBe(record); }); @@ -62,7 +65,10 @@ describe('runSerialize', () => { // ── Edge ────────────────────────────────────────────────────────────────── it('should return empty object when serialize returns {} for instance with no registered fields', async () => { - const result = await runSerialize(sealedFor(() => ({})), {}); + const result = await runSerialize( + sealedFor(() => ({})), + {}, + ); expect(result).toEqual({}); }); @@ -83,13 +89,19 @@ describe('runSerialize', () => { it('should return direct value when isSerializeAsync is false', () => { const record = { x: 1 }; - const result = runSerialize(sealedFor(() => record, { isSerializeAsync: false }), {}); + const result = runSerialize( + sealedFor(() => record, { isSerializeAsync: false }), + {}, + ); expect(result).toBe(record); }); it('should use async path when isSerializeAsync is true', async () => { const record = { y: 2 }; - const result = await runSerialize(sealedFor(() => Promise.resolve(record), { isSerializeAsync: true }), {}); + const result = await runSerialize( + sealedFor(() => Promise.resolve(record), { isSerializeAsync: true }), + {}, + ); expect(result).toBe(record); }); }); diff --git a/src/functions/serialize.ts b/src/runtime/serialize.ts similarity index 81% rename from src/functions/serialize.ts rename to src/runtime/serialize.ts index 221f9bf..8d4d714 100644 --- a/src/functions/serialize.ts +++ b/src/runtime/serialize.ts @@ -1,11 +1,11 @@ -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common'; +import type { SealedExecutors } from '../seal'; -import { BakerError } from '../errors'; +import { BakerError } from '../common'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── -// serialize — Public API (§5.2) +// resolveSerializeClass — derive the (forgery-checked) constructor from an instance // ───────────────────────────────────────────────────────────────────────────── /** @@ -30,7 +30,7 @@ function resolveSerializeClass(instance: unknown, fnName: string): Function { } // ───────────────────────────────────────────────────────────────────────────── -// run* helpers — post-resolution dispatch, shared by the global functions and Baker methods +// run* helpers — post-resolution dispatch, shared by the Baker serialize methods // ───────────────────────────────────────────────────────────────────────────── function runSerialize( @@ -39,9 +39,8 @@ function runSerialize( options?: RuntimeOptions, ): Record | Promise> { const checkedOpts = checkCallOptions(options); - return sealed.isSerializeAsync - ? (sealed.serialize(instance, checkedOpts) as Promise>) - : (sealed.serialize(instance, checkedOpts) as Record); + // `sealed.serialize` already returns the sync|async union — return it as-is, no cast. + return sealed.serialize(instance, checkedOpts); } function runSerializeSync( @@ -54,6 +53,7 @@ function runSerializeSync( if (sealed.isSerializeAsync) { throw new BakerError(`serializeSync(${className}): DTO has async serialize transforms. Use serializeAsync() instead.`); } + // Sync branch: `isSerializeAsync` false guarantees the sync arm; the cast only drops the Promise arm. return sealed.serialize(instance, checkedOpts) as Record; } @@ -63,9 +63,8 @@ function runSerializeAsync( options?: RuntimeOptions, ): Promise> { const checkedOpts = checkCallOptions(options); - return sealed.isSerializeAsync - ? (sealed.serialize(instance, checkedOpts) as Promise>) - : Promise.resolve(sealed.serialize(instance, checkedOpts) as Record); + // `Promise.resolve` unifies both arms of the sync|async union — no cast needed. + return Promise.resolve(sealed.serialize(instance, checkedOpts)); } export { resolveSerializeClass, runSerialize, runSerializeSync, runSerializeAsync }; diff --git a/src/functions/validate.ts b/src/runtime/validate.ts similarity index 58% rename from src/functions/validate.ts rename to src/runtime/validate.ts index c605af6..954c1b8 100644 --- a/src/functions/validate.ts +++ b/src/runtime/validate.ts @@ -1,14 +1,18 @@ -import type { BakerIssue, BakerIssueSet } from '../errors'; -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { BakerIssue, BakerIssueSet, RuntimeOptions } from '../common'; +import type { SealedExecutors } from '../seal'; -import { toBakerIssueSet, BakerError } from '../errors'; +import { toBakerIssueSet, BakerError } from '../common'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── -// run* helpers — post-resolution dispatch, shared by the global functions and Baker methods +// run* helpers — post-resolution dispatch, shared by the Baker validate methods // ───────────────────────────────────────────────────────────────────────────── +/** Map a validate result (`BakerIssue[] | null`) to the public `true | BakerIssueSet` shape. */ +function unwrapValidate(result: BakerIssue[] | null): true | BakerIssueSet { + return result === null ? true : toBakerIssueSet(result); +} + function runValidate( sealed: SealedExecutors, input: unknown, @@ -16,12 +20,10 @@ function runValidate( ): true | BakerIssueSet | Promise { const checkedOpts = checkCallOptions(options); if (sealed.isAsync) { - return (sealed.validate(input, checkedOpts) as Promise).then((result): true | BakerIssueSet => - result === null ? true : toBakerIssueSet(result), - ); + return Promise.resolve(sealed.validate(input, checkedOpts)).then(unwrapValidate); } - const result = sealed.validate(input, checkedOpts) as BakerIssue[] | null; - return result === null ? true : toBakerIssueSet(result); + // Sync branch: `isAsync` false guarantees the sync arm; the cast only drops the unreachable Promise arm. + return unwrapValidate(sealed.validate(input, checkedOpts) as BakerIssue[] | null); } function runValidateSync( @@ -34,8 +36,7 @@ function runValidateSync( if (sealed.isAsync) { throw new BakerError(`validateSync(${className}): DTO has async rules/transforms. Use validateAsync() instead.`); } - const result = sealed.validate(input, checkedOpts) as BakerIssue[] | null; - return result === null ? true : toBakerIssueSet(result); + return unwrapValidate(sealed.validate(input, checkedOpts) as BakerIssue[] | null); } function runValidateAsync( @@ -45,12 +46,9 @@ function runValidateAsync( ): Promise { const checkedOpts = checkCallOptions(options); if (sealed.isAsync) { - return (sealed.validate(input, checkedOpts) as Promise).then((r): true | BakerIssueSet => - r === null ? true : toBakerIssueSet(r), - ); + return Promise.resolve(sealed.validate(input, checkedOpts)).then(unwrapValidate); } - const result = sealed.validate(input, checkedOpts) as BakerIssue[] | null; - return Promise.resolve(result === null ? true : toBakerIssueSet(result)); + return Promise.resolve(unwrapValidate(sealed.validate(input, checkedOpts) as BakerIssue[] | null)); } export { runValidate, runValidateSync, runValidateAsync }; diff --git a/src/seal/async-analyzer.spec.ts b/src/seal/async-analyzer.spec.ts new file mode 100644 index 0000000..19a632a --- /dev/null +++ b/src/seal/async-analyzer.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'bun:test'; + +import type { RawClassMeta, RawPropertyMeta } from '../metadata'; +import type { SealedExecutors } from './interfaces'; + +import { Direction } from '../common'; +import { metaStore } from '../metadata'; +import { isString } from '../rules/typechecker'; +import { AsyncAnalyzer } from './async-analyzer'; +import { InheritanceMerger } from './inheritance-merger'; + +const merger = new InheritanceMerger(metaStore); +const noResolve = (): SealedExecutors | undefined => undefined; +const analyzer = new AsyncAnalyzer(noResolve, merger); + +function prop(over: Partial = {}): RawPropertyMeta { + return { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {}, ...over }; +} + +const asyncRule = { rule: { ruleName: 'asyncStub', isAsync: true } } as never; + +describe('AsyncAnalyzer.analyze', () => { + it('returns false for sync-only metadata', () => { + expect(analyzer.analyze({ f: prop({ validation: [{ rule: isString }] }) }, Direction.Deserialize)).toBe(false); + }); + + it('detects an async validation rule (deserialize direction)', () => { + expect(analyzer.analyze({ f: prop({ validation: [asyncRule] }) }, Direction.Deserialize)).toBe(true); + }); + + it('ignores async validation rules in the serialize direction', () => { + expect(analyzer.analyze({ f: prop({ validation: [asyncRule] }) }, Direction.Serialize)).toBe(false); + }); + + it('detects an async transform via the isAsync flag', () => { + const merged = { f: prop({ transform: [{ fn: v => v, isAsync: true }] }) }; + expect(analyzer.analyze(merged, Direction.Deserialize)).toBe(true); + }); + + it('detects an async transform via isAsyncFunction(fn) when no flag is set', () => { + const merged = { f: prop({ transform: [{ fn: async v => await v }] }) }; + expect(analyzer.analyze(merged, Direction.Deserialize)).toBe(true); + }); + + it('skips a serializeOnly transform in the deserialize direction', () => { + const merged = { f: prop({ transform: [{ fn: v => v, isAsync: true, options: { serializeOnly: true } }] }) }; + expect(analyzer.analyze(merged, Direction.Deserialize)).toBe(false); + }); + + it('propagates async from a nested DTO through the resolver flag', () => { + class Nested {} + const asyncSealed = { isAsync: true, isSerializeAsync: false, merged: {} } as unknown as SealedExecutors; + const resolved = new Map>([[Nested, asyncSealed]]); + const a = new AsyncAnalyzer(cls => resolved.get(cls), merger); + const merged: RawClassMeta = { child: prop({ type: { fn: () => Nested, resolvedClass: Nested } }) }; + expect(a.analyze(merged, Direction.Deserialize)).toBe(true); + }); +}); + +describe('AsyncAnalyzer.nestedClassesOf', () => { + it('collects resolvedClass, resolvedCollectionValue, and discriminator subtypes', () => { + class C1 {} + class C2 {} + class C3 {} + const meta = prop({ + type: { + fn: () => C1, + resolvedClass: C1, + resolvedCollectionValue: C2, + discriminator: { property: 't', subTypes: [{ value: C3, name: 'c3' }] }, + }, + }); + expect(analyzer.nestedClassesOf(meta)).toEqual([C1, C2, C3]); + }); + + it('returns [] for a field with no type', () => { + expect(analyzer.nestedClassesOf(prop())).toEqual([]); + }); + + it('falls back to the fn() thunk for a Set value class, excluding primitives', () => { + class Item {} + const meta = prop({ type: { fn: () => Set, collectionValue: () => Item } }); + expect(analyzer.nestedClassesOf(meta)).toEqual([Item]); + }); +}); diff --git a/src/seal/async-analyzer.ts b/src/seal/async-analyzer.ts new file mode 100644 index 0000000..d0df863 --- /dev/null +++ b/src/seal/async-analyzer.ts @@ -0,0 +1,102 @@ +import type { RawClassMeta, RawPropertyMeta } from '../metadata'; +import type { InheritanceMerger } from './inheritance-merger'; +import type { SealedExecutors } from './interfaces'; + +import { Direction, isAsyncFunction } from '../common'; +import { PRIMITIVE_CTORS } from './constants'; +import { classifyTypeResult } from './type-resolver'; + +/** + * Static analysis to determine if a sealed DTO requires an async executor (C1). Holds the executor + * resolver (the Baker's per-instance map reader) and the {@link InheritanceMerger} as injected + * collaborators. + */ +export class AsyncAnalyzer { + readonly #resolve: (cls: Function) => SealedExecutors | undefined; + readonly #merger: InheritanceMerger; + + constructor(resolve: (cls: Function) => SealedExecutors | undefined, merger: InheritanceMerger) { + this.#resolve = resolve; + this.#merger = merger; + } + + analyze(merged: RawClassMeta, direction: Direction, visited?: Set): boolean { + const flag = direction === Direction.Deserialize ? 'isAsync' : 'isSerializeAsync'; + const seen = visited ?? new Set(); + + // sealOne seals every nested DTO (step 4) before this runs (step 5). For a fully-sealed nested + // class its `isAsync`/`isSerializeAsync` flag is authoritative and already accounts for ITS nested + // classes — so trusting the flag propagates async through any nesting depth (re-deriving from + // metadata would lose `resolvedClass` past depth 1). A class still being sealed carries a + // placeholder executor (no `merged`); that only happens on a circular back-edge, where the flag + // is not yet known — there we recurse into the class's own metadata, guarded by `seen`. + const nestedIsAsync = (cls: Function): boolean => { + if (seen.has(cls)) { + return false; + } + seen.add(cls); + const sealed = this.#resolve(cls); + if (sealed?.merged) { + return sealed[flag] === true; + } + return this.analyze(this.#merger.merge(cls), direction, seen); + }; + + for (const meta of Object.values(merged)) { + // 1. createRule may return Promise even without `async` syntax (deserialize only). + if (direction === Direction.Deserialize && meta.validation.some(rd => rd.rule.isAsync)) { + return true; + } + // 2. @Transform async — single-pass scan, avoids intermediate filter[] allocation + for (const td of meta.transform) { + if (direction === Direction.Deserialize ? td.options?.serializeOnly : td.options?.deserializeOnly) { + continue; + } + if (td.isAsync ?? isAsyncFunction(td.fn)) { + return true; + } + } + // 3. nested DTOs (direct, Set/Map value, discriminator subtypes) + if (this.nestedClassesOf(meta).some(nestedIsAsync)) { + return true; + } + } + return false; + } + + /** + * Nested DTO classes referenced by a field's type. Prefers normalized `resolved*` slots, but falls + * back to resolving the raw `type.fn()` thunk — needed when {@link analyze} recurses into a + * still-being-sealed class on a circular back-edge whose metadata was never normalized. + */ + nestedClassesOf(meta: RawPropertyMeta): Function[] { + const t = meta.type; + if (!t) { + return []; + } + const out: Function[] = []; + if (t.resolvedClass) { + out.push(t.resolvedClass); + } + if (t.resolvedCollectionValue) { + out.push(t.resolvedCollectionValue); + } + if (t.discriminator) { + for (const sub of t.discriminator.subTypes) { + out.push(sub.value); + } + } + if (out.length === 0 && t.fn) { + const { collection, resolved } = classifyTypeResult(t.fn()); + if (collection !== undefined) { + const cv = t.collectionValue?.(); + if (typeof cv === 'function' && !PRIMITIVE_CTORS.has(cv)) { + out.push(cv); + } + } else if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { + out.push(resolved as Function); + } + } + return out; + } +} diff --git a/src/seal/circular-analyzer.spec.ts b/src/seal/circular-analyzer.spec.ts index 110dc87..fa5620f 100644 --- a/src/seal/circular-analyzer.spec.ts +++ b/src/seal/circular-analyzer.spec.ts @@ -1,9 +1,13 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import type { ClassCtor, RawClassMeta } from '../types'; +import type { ClassCtor } from '../common/types'; +import type { RawClassMeta } from '../metadata/interfaces'; -import { setRaw } from '../meta-access'; -import { analyzeCircular } from './circular-analyzer'; +import { metaStore } from '../metadata'; +import { CircularAnalyzer } from './circular-analyzer'; +import { InheritanceMerger } from './inheritance-merger'; + +const analyzer = new CircularAnalyzer(new InheritanceMerger(metaStore)); // ───────────────────────────────────────────────────────────────────────────── // Helpers — manual RAW meta setup @@ -22,7 +26,7 @@ function makeTypeMeta(fn: () => Function): RawClassMeta { }; } -function makeDiscriminatorMeta(subTypes: { value: Function; name: string }[]): RawClassMeta { +function makeDiscriminatorMeta(subTypes: { value: ClassCtor; name: string }[]): RawClassMeta { return { field: { validation: [], @@ -52,11 +56,11 @@ describe('analyzeCircular', () => { it('should return false when DTO has no @Type fields', () => { // Arrange class NoTypeDto {} - setRaw(NoTypeDto, { + metaStore.set(NoTypeDto, { name: { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const result = analyzeCircular(NoTypeDto); + const result = analyzer.analyze(NoTypeDto); // Assert expect(result).toBe(false); }); @@ -64,16 +68,16 @@ describe('analyzeCircular', () => { it('should return false for linear A -> B chain with no cycle', () => { // Arrange class BDto {} - setRaw(BDto, {}); + metaStore.set(BDto, {}); class ADto {} - setRaw( + metaStore.set( ADto, makeTypeMeta(() => BDto), ); // Act - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert expect(result).toBe(false); }); @@ -82,12 +86,12 @@ describe('analyzeCircular', () => { // Arrange — B has no [RAW] class BNoRaw {} class ADto {} - setRaw( + metaStore.set( ADto, makeTypeMeta(() => BNoRaw), ); // Act - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert expect(result).toBe(false); }); @@ -97,12 +101,12 @@ describe('analyzeCircular', () => { it('should return true when class references itself (self-loop)', () => { // Arrange class SelfRefDto {} - setRaw( + metaStore.set( SelfRefDto, makeTypeMeta(() => SelfRefDto), ); // Act - const result = analyzeCircular(SelfRefDto); + const result = analyzer.analyze(SelfRefDto); // Assert expect(result).toBe(true); }); @@ -112,33 +116,53 @@ describe('analyzeCircular', () => { class BDto2 {} class ADto2 {} - setRaw( + metaStore.set( ADto2, makeTypeMeta(() => BDto2), ); - setRaw( + metaStore.set( BDto2, makeTypeMeta(() => ADto2), ); // Act - const result = analyzeCircular(ADto2); + const result = analyzer.analyze(ADto2); // Assert expect(result).toBe(true); }); + it('should detect a cycle that exists only through an inherited @Type field', () => { + // Arrange — Base declares @Type(() => Derived); Derived extends Base and inherits that field, + // so Derived -> Derived is a cycle visible only in the inheritance-merged metadata (not in getRaw). + class Base {} + class Derived extends Base {} + metaStore.set( + Base, + makeTypeMeta(() => Derived), + ); + metaStore.set(Derived, { + label: { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }, + }); + + // Act + const result = analyzer.analyze(Derived); + + // Assert — the inherited @Type field forms a self-cycle + expect(result).toBe(true); + }); + it('should return true when discriminator subType cycles back', () => { // Arrange class ContentDto {} class ParentDto {} - setRaw( + metaStore.set( ContentDto, makeTypeMeta(() => ParentDto), ); - setRaw(ParentDto, makeDiscriminatorMeta([{ value: ContentDto, name: 'content' }])); + metaStore.set(ParentDto, makeDiscriminatorMeta([{ value: ContentDto, name: 'content' }])); // Act - const result = analyzeCircular(ParentDto); + const result = analyzer.analyze(ParentDto); // Assert expect(result).toBe(true); }); @@ -146,16 +170,16 @@ describe('analyzeCircular', () => { it('should detect cycle via second discriminator subType (covers discriminator loop body)', () => { // Arrange — A.fn → B (no cycle), A.discriminator.subTypes[1] → C → A (cycle) class BDto {} - setRaw(BDto, {}); // no @Type, no cycle + metaStore.set(BDto, {}); // no @Type, no cycle class CDto {} class ADto {} - setRaw( + metaStore.set( CDto, makeTypeMeta(() => ADto), ); // C → A (creates cycle) - setRaw(ADto, { + metaStore.set(ADto, { field: { validation: [], transform: [], @@ -176,7 +200,7 @@ describe('analyzeCircular', () => { }); // Act - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert expect(result).toBe(true); }); @@ -188,9 +212,9 @@ describe('analyzeCircular', () => { it('should return false when merged has no fields (empty object)', () => { // Arrange class EmptyDto {} - setRaw(EmptyDto, {}); + metaStore.set(EmptyDto, {}); // Act - const result = analyzeCircular(EmptyDto); + const result = analyzer.analyze(EmptyDto); // Assert expect(result).toBe(false); }); @@ -200,10 +224,10 @@ describe('analyzeCircular', () => { it('should return the same result on repeated calls (idempotent)', () => { // Arrange class IdemDto {} - setRaw(IdemDto, {}); + metaStore.set(IdemDto, {}); // Act - const first = analyzeCircular(IdemDto); - const second = analyzeCircular(IdemDto); + const first = analyzer.analyze(IdemDto); + const second = analyzer.analyze(IdemDto); // Assert expect(first).toBe(second); }); @@ -217,20 +241,20 @@ describe('analyzeCircular', () => { class CDto {} class DDto {} - setRaw( + metaStore.set( BDto, makeTypeMeta(() => ADto), ); - setRaw( + metaStore.set( CDto, makeTypeMeta(() => ADto), ); - setRaw( + metaStore.set( DDto, makeTypeMeta(() => ADto), ); - setRaw( + metaStore.set( ADto, makeDiscriminatorMeta([ { value: BDto, name: 'b' }, @@ -240,7 +264,7 @@ describe('analyzeCircular', () => { ); // Act — should terminate without stack overflow - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert — cycle exists (A → B → A) expect(result).toBe(true); @@ -253,11 +277,11 @@ describe('analyzeCircular', () => { class CDto {} class DDto {} - setRaw(BDto, {}); - setRaw(CDto, {}); - setRaw(DDto, {}); + metaStore.set(BDto, {}); + metaStore.set(CDto, {}); + metaStore.set(DDto, {}); - setRaw( + metaStore.set( ADto, makeDiscriminatorMeta([ { value: BDto, name: 'b' }, @@ -267,7 +291,7 @@ describe('analyzeCircular', () => { ); // Act - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert — no cycle expect(result).toBe(false); @@ -278,20 +302,20 @@ describe('analyzeCircular', () => { it('should throw BakerError when lazy type function throws', () => { // Arrange class LazyThrowDto {} - setRaw( + metaStore.set( LazyThrowDto, makeTypeMeta(() => { throw new Error('boom'); }), ); // Act / Assert - expect(() => analyzeCircular(LazyThrowDto)).toThrow('boom'); + expect(() => analyzer.analyze(LazyThrowDto)).toThrow('boom'); }); it('should include class name in BakerError when lazy type throws', () => { // Arrange class NamedThrowDto {} - setRaw( + metaStore.set( NamedThrowDto, makeTypeMeta(() => { throw new Error('broken ref'); @@ -299,7 +323,7 @@ describe('analyzeCircular', () => { ); // Act / Assert try { - analyzeCircular(NamedThrowDto); + analyzer.analyze(NamedThrowDto); expect.unreachable(); } catch (e) { expect((e as Error).message).toContain('NamedThrowDto'); diff --git a/src/seal/circular-analyzer.ts b/src/seal/circular-analyzer.ts index 1c18eca..cfaa44e 100644 --- a/src/seal/circular-analyzer.ts +++ b/src/seal/circular-analyzer.ts @@ -1,27 +1,43 @@ -import { BakerError } from '../errors'; -import { getRaw } from '../meta-access'; +import type { InheritanceMerger } from './inheritance-merger'; + +import { BakerError } from '../common'; +import { classifyTypeResult } from './type-resolver'; /** - * Static analysis for circular references (§4.6) - * - * Traverses the @Type reference graph via DFS to detect cycles. + * Static analysis for circular references. Traverses the @Type reference graph via DFS to detect + * cycles; holds the {@link InheritanceMerger} it reads the merged graph through as an injected collaborator. * - * Flat DTO without cycles → false (zero WeakSet overhead) - * DTO with cycles → true (WeakSet automatically inserted) + * Flat DTO without cycles → false (zero WeakSet overhead). DTO with cycles → true (WeakSet inserted). */ -export function analyzeCircular(Class: Function): boolean { - // @Type reference graph DFS — detect back-edges via visited set - const visited = new Set(); +export class CircularAnalyzer { + readonly #merger: InheritanceMerger; - function walk(cls: Function): boolean { - if (visited.has(cls)) { - return true; - } // back-edge → cycle detected + constructor(merger: InheritanceMerger) { + this.#merger = merger; + } - visited.add(cls); + analyze(Class: Function): boolean { + // Directed-graph cycle detection: `onPath` = gray (classes on the current DFS path → a back-edge + // to one is a cycle); `explored` = black (classes already proven acyclic → never re-walked, so a + // shared subtree reached by many paths is visited once instead of exponentially). + const onPath = new Set(); + const explored = new Set(); + const merger = this.#merger; - const raw = getRaw(cls); - if (raw) { + function walk(cls: Function): boolean { + if (onPath.has(cls)) { + return true; // back-edge → cycle detected + } + if (explored.has(cls)) { + return false; // already proven acyclic + } + + onPath.add(cls); + + // Use the inheritance-MERGED metadata, not own-level RAW: a cycle introduced through an + // INHERITED @Type field must be seen here, because codegen builds the circular guard from the + // same merged graph (mirrors AsyncAnalyzer). Missing it would omit the WeakSet → stack overflow. + const raw = merger.merge(cls); for (const meta of Object.values(raw)) { // Simple @Type if (meta.type?.fn) { @@ -29,10 +45,10 @@ export function analyzeCircular(Class: Function): boolean { try { typeResult = meta.type.fn(); } catch (e) { - throw new BakerError(`${cls.name}: type function threw: ${(e as Error).message}`, { cause: e }); + throw new BakerError(`${cls.name}: type function threw: ${e instanceof Error ? e.message : String(e)}`, { cause: e }); } - const nested = Array.isArray(typeResult) ? typeResult[0] : typeResult; - if (walk(nested as Function)) { + const { resolved } = classifyTypeResult(typeResult); + if (typeof resolved === 'function' && walk(resolved)) { return true; } } @@ -50,18 +66,21 @@ export function analyzeCircular(Class: Function): boolean { try { resolved = meta.type.collectionValue(); } catch (e) { - throw new BakerError(`${cls.name}: collectionValue function threw: ${(e as Error).message}`, { cause: e }); + throw new BakerError(`${cls.name}: collectionValue function threw: ${e instanceof Error ? e.message : String(e)}`, { + cause: e, + }); } - if (typeof resolved === 'function' && walk(resolved as Function)) { + if (typeof resolved === 'function' && walk(resolved)) { return true; } } } + + onPath.delete(cls); // leave the current path... + explored.add(cls); // ...and record as fully explored & acyclic + return false; } - visited.delete(cls); // Release tree edge — prevent false positives for diamond patterns - return false; + return walk(Class); } - - return walk(Class); } diff --git a/src/seal/circular-placeholder.spec.ts b/src/seal/circular-placeholder.spec.ts new file mode 100644 index 0000000..0b21ee6 --- /dev/null +++ b/src/seal/circular-placeholder.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'bun:test'; + +import { BakerError } from '../common'; +import { CircularPlaceholder } from './circular-placeholder'; + +describe('CircularPlaceholder', () => { + it('reports non-async flags', () => { + const p = new CircularPlaceholder('MyDto'); + expect(p.isAsync).toBe(false); + expect(p.isSerializeAsync).toBe(false); + }); + + it('throws a BakerError naming the still-sealing class from every executor member', () => { + const p = new CircularPlaceholder('MyDto'); + expect(() => p.deserialize({})).toThrow(BakerError); + expect(() => p.serialize({})).toThrow(/MyDto is still being sealed/); + expect(() => p.validate({})).toThrow(/MyDto/); + }); + + it('exposes executor members as writable own fields so seal can replace them in place', () => { + const p = new CircularPlaceholder('X'); + // Reference identity is load-bearing: nested refs already hold this object, so sealOne replaces the + // members via Object.assign rather than swapping the instance. + expect(Object.hasOwn(p, 'deserialize')).toBe(true); + Object.assign(p, { deserialize: () => 'ok' }); + expect((p.deserialize as unknown as () => string)()).toBe('ok'); + }); +}); diff --git a/src/seal/circular-placeholder.ts b/src/seal/circular-placeholder.ts new file mode 100644 index 0000000..f8190cd --- /dev/null +++ b/src/seal/circular-placeholder.ts @@ -0,0 +1,36 @@ +import type { RuntimeOptions } from '../common'; +import type { SealedExecutors } from './interfaces'; + +import { BakerError } from '../common'; + +/** + * @internal Placeholder executor parked in the baker's map while a class is mid-seal, to break circular + * references. The instance IS the placeholder: it holds the still-sealing class name as private state + * and its executor members throw if invoked before sealing completes. + * + * The three executor members are writable OWN (arrow) fields, not prototype methods, so sealOne can + * replace them in place via `Object.assign(placeholder, { deserialize, … })` once compilation finishes + * (reference identity is load-bearing — nested refs already hold this object). + */ +export class CircularPlaceholder implements SealedExecutors { + readonly #message: string; + + isAsync = false; + isSerializeAsync = false; + + constructor(className: string) { + this.#message = `Circular dependency during seal: ${className} is still being sealed`; + } + + deserialize = (_input: unknown, _options?: RuntimeOptions): never => { + throw new BakerError(this.#message); + }; + + serialize = (_instance: unknown, _options?: RuntimeOptions): never => { + throw new BakerError(this.#message); + }; + + validate = (_input: unknown, _options?: RuntimeOptions): never => { + throw new BakerError(this.#message); + }; +} diff --git a/src/seal/codegen-utils.ts b/src/seal/codegen-utils.ts index 651e809..e737175 100644 --- a/src/seal/codegen-utils.ts +++ b/src/seal/codegen-utils.ts @@ -2,11 +2,62 @@ // Shared code-generation utilities for deserialize/serialize builders // ───────────────────────────────────────────────────────────────────────────── +import type { RawPropertyMeta } from '../metadata'; + +import { Direction } from '../common'; + /** Convert key to a valid JS identifier suffix (encode non-alphanumeric chars via charCode to prevent collisions) */ export function sanitizeKey(key: string): string { return key.replace(/[^a-zA-Z0-9_]/g, ch => `$${ch.charCodeAt(0)}$`); } +/** + * Resolve the rename target for a field in `direction`: the @Expose `name` from a same-direction-only + * entry if present, else a bidirectional @Expose `name`, else the field key itself. The single source + * of truth for both deserialize extract-key and serialize output-key resolution (they are mirror images + * differing only in which directional flag they honour). + */ +export function resolveExposeName(fieldKey: string, exposeStack: RawPropertyMeta['expose'], direction: Direction): string { + const directional = + direction === Direction.Serialize + ? exposeStack.find(e => e.serializeOnly && e.name) + : exposeStack.find(e => e.deserializeOnly && e.name); + if (directional) { + return directional.name!; + } + // Non-directional @Expose with name → use for both directions + const biDef = exposeStack.find(e => !e.deserializeOnly && !e.serializeOnly && e.name); + if (biDef) { + return biDef.name!; + } + return fieldKey; +} + +/** + * Resolve a field's expose groups for `direction` — undefined (no restriction) if any unconditional + * expose entry exists. Single source of truth for both directions: serialize skips deserialize-only + * entries, deserialize skips serialize-only entries. + */ +export function resolveExposeGroups(exposeStack: RawPropertyMeta['expose'], direction: Direction): string[] | undefined { + // Single-pass: scan once, bail out as soon as we see an unconditional entry, lazily allocate the Set. + let all: Set | null = null; + for (const e of exposeStack) { + if (direction === Direction.Serialize ? e.deserializeOnly : e.serializeOnly) { + continue; + } + if (!e.groups || e.groups.length === 0) { + return undefined; + } + if (all === null) { + all = new Set(); + } + for (const g of e.groups) { + all.add(g); + } + } + return all === null ? undefined : [...all]; +} + /** * Generate a groups-has expression for the fast-path single-group / Set pattern. * Checks if any of the given groups match the runtime groups. diff --git a/src/seal/compile-cache.spec.ts b/src/seal/compile-cache.spec.ts index 52276ab..78f6de6 100644 --- a/src/seal/compile-cache.spec.ts +++ b/src/seal/compile-cache.spec.ts @@ -2,14 +2,14 @@ import { describe, it, expect } from 'bun:test'; import { Baker, Field } from '../../index'; import { isNumber, isString } from '../rules/index'; -import { getCached, configFingerprint, clearCached } from './seal'; +import { compileCache } from './compile-cache'; // The (class, config) cache: same-config bakers share one compiled executor; different-config // bakers get distinct entries. Sharing is invisible behaviourally — verified via the cache itself. describe('(class, config) executor cache', () => { it('two bakers with the SAME config reuse one cached executor (compile once)', () => { - const fp = configFingerprint({ stopAtFirstError: true }); + const fp = compileCache.fingerprint({ stopAtFirstError: true }); const a = new Baker({ stopAtFirstError: true }); @a.Recipe @@ -18,7 +18,7 @@ describe('(class, config) executor cache', () => { } a.seal(); - const first = getCached(C, fp); + const first = compileCache.get(C, fp); expect(first).toBeDefined(); // A second baker with the same config must HIT the cache and NOT recompile/overwrite the entry. @@ -26,7 +26,7 @@ describe('(class, config) executor cache', () => { (b.Recipe as (v: Function) => void)(C); b.seal(); - expect(getCached(C, fp)).toBe(first!); // same object reference → b reused a's executor + expect(compileCache.get(C, fp)).toBe(first!); // same object reference → b reused a's executor }); it('different config → distinct cache entries (isolation preserved)', () => { @@ -42,19 +42,19 @@ describe('(class, config) executor cache', () => { b.seal(); // SealOptions key (BakerConfig's `autoConvert` normalizes to `enableImplicitConversion`) - const fpStrict = configFingerprint({ enableImplicitConversion: false }); - const fpLoose = configFingerprint({ enableImplicitConversion: true }); + const fpStrict = compileCache.fingerprint({ enableImplicitConversion: false }); + const fpLoose = compileCache.fingerprint({ enableImplicitConversion: true }); expect(fpStrict).not.toBe(fpLoose); - expect(getCached(D, fpStrict)).toBeDefined(); - expect(getCached(D, fpLoose)).toBeDefined(); - expect(getCached(D, fpStrict)).not.toBe(getCached(D, fpLoose)); + expect(compileCache.get(D, fpStrict)).toBeDefined(); + expect(compileCache.get(D, fpLoose)).toBeDefined(); + expect(compileCache.get(D, fpStrict)).not.toBe(compileCache.get(D, fpLoose)); }); it('new Baker() and new Baker({}) share a fingerprint (all defaults → "00000")', () => { - expect(configFingerprint({})).toBe('00000'); + expect(compileCache.fingerprint({})).toBe('00000'); expect( - configFingerprint({ + compileCache.fingerprint({ enableImplicitConversion: false, exposeDefaultValues: false, stopAtFirstError: false, @@ -65,7 +65,7 @@ describe('(class, config) executor cache', () => { }); it('nested DTOs are cached too — a root cache-hit transitively reuses cached nested executors', () => { - const fp = configFingerprint({}); + const fp = compileCache.fingerprint({}); const a = new Baker(); class Inner { @Field(isNumber()) k!: number; @@ -76,8 +76,8 @@ describe('(class, config) executor cache', () => { } a.seal(); - const cachedOuter = getCached(Outer, fp); - const cachedInner = getCached(Inner, fp); + const cachedOuter = compileCache.get(Outer, fp); + const cachedInner = compileCache.get(Inner, fp); expect(cachedOuter).toBeDefined(); expect(cachedInner).toBeDefined(); // nested sealed into the same cache under the same fingerprint @@ -86,12 +86,12 @@ describe('(class, config) executor cache', () => { b.seal(); // root is a hit (entry unchanged) and the nested executor is the same shared object - expect(getCached(Outer, fp)).toBe(cachedOuter!); - expect(getCached(Inner, fp)).toBe(cachedInner!); + expect(compileCache.get(Outer, fp)).toBe(cachedOuter!); + expect(compileCache.get(Inner, fp)).toBe(cachedInner!); }); it('circular graph caches fully back-patched executors (not throwing placeholders)', () => { - const fp = configFingerprint({}); + const fp = compileCache.fingerprint({}); const a = new Baker(); @a.Recipe class Node { @@ -102,11 +102,11 @@ describe('(class, config) executor cache', () => { // A bare circularPlaceholder has no `merged`; a fully sealed executor does. Cached entry must be the // back-patched one, never a placeholder that throws "circular dependency during seal". - expect(getCached(Node, fp)?.merged).toBeDefined(); + expect(compileCache.get(Node, fp)?.merged).toBeDefined(); }); it('a failed seal does not pollute the cache (commit is post-success)', () => { - const fp = configFingerprint({}); + const fp = compileCache.fingerprint({}); const x = new Baker(); @x.Recipe class GoodOne { @@ -125,7 +125,7 @@ describe('(class, config) executor cache', () => { void BadOne; // registered via @x.Recipe; bound only to be sealed (and to fail the seal) expect(() => x.seal()).toThrow(); // GoodOne compiles before BadOne throws, but setCached runs only after the whole seal succeeds. - expect(getCached(GoodOne, fp)).toBeUndefined(); + expect(compileCache.get(GoodOne, fp)).toBeUndefined(); }); it('a cache-hit baker can resolve a nested-only DTO as a top-level argument (seeds the map)', () => { @@ -164,7 +164,7 @@ describe('(class, config) executor cache', () => { a.seal(); // Clear ONLY the nested entry; Root stays cached. - clearCached(Leaf); + compileCache.clear(Leaf); // b hits Root's cache; seeding finds Leaf uncached and recompiles it fresh into b's map. const b = new Baker(); diff --git a/src/seal/compile-cache.ts b/src/seal/compile-cache.ts new file mode 100644 index 0000000..7a79df4 --- /dev/null +++ b/src/seal/compile-cache.ts @@ -0,0 +1,72 @@ +import type { SealOptions, SealedExecutors } from './interfaces'; + +import { SEAL_OPTION_KEYS } from './constants'; + +// ───────────────────────────────────────────────────────────────────────────── +// (class, config) executor cache — content-addressed sharing across bakers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A class's generated executor is a pure function of (its RAW metadata, the seal config). So two + * bakers with the SAME config compile byte-identical executors — memoize globally by + * `(class, configFingerprint)` so they share one executor (compiled once) instead of N copies, while + * different-config bakers stay isolated (distinct fingerprint → distinct entry). Behaviour is + * unchanged either way: executors are pure (no per-call mutable state), so sharing is invisible. + * + * `WeakMap` so an entry is reclaimed when its class is GC'd. The inner `Map` retains one + * executor per (class, config) for the class's lifetime — bounded for a fixed DTO/config set (the + * intended "seal once at startup" usage); a program that dynamically generates classes/configs would + * grow it without eviction. + * + * The cache owns its WeakMap as a private field (no module-level mutable state) and is exposed as a + * single process-global instance, `compileCache` — the single source of truth for compiled executors. + */ +class CompileCache { + #cache: WeakMap>>; + + constructor() { + this.#cache = new WeakMap(); + } + + /** + * Canonical fingerprint of a SealOptions — the seal-affecting booleans in fixed order. `{}` and a + * fully-defaulted object both map to "00000", so `new Baker()` and `new Baker({})` share a cache key. + */ + fingerprint(o: SealOptions): string { + let fp = ''; + for (const key of SEAL_OPTION_KEYS) { + fp += o[key] ? '1' : '0'; + } + return fp; + } + + get(cls: Function, fp: string): SealedExecutors | undefined { + return this.#cache.get(cls)?.get(fp); + } + + set(cls: Function, fp: string, exec: SealedExecutors): void { + let m = this.#cache.get(cls); + if (m === undefined) { + m = new Map(); + this.#cache.set(cls, m); + } + m.set(fp, exec); + } + + /** Test-only: drop a single class's cached executors so a re-seal recompiles it. */ + clear(cls: Function): void { + this.#cache.delete(cls); + } + + /** + * Test-only: drop the ENTIRE cache. Used by `unseal()` so a test that re-seals classes starts from a + * clean slate — a whole-cache reset (vs per-class) is the only way to avoid the partial-clear state + * where a cached root still references a nested whose entry was dropped (a root + its nested are + * always compiled together, so they must be invalidated together). + */ + clearAll(): void { + this.#cache = new WeakMap(); + } +} + +export const compileCache = new CompileCache(); diff --git a/src/seal/constants.ts b/src/seal/constants.ts new file mode 100644 index 0000000..9b1d25f --- /dev/null +++ b/src/seal/constants.ts @@ -0,0 +1,94 @@ +import type { SealOptions } from './interfaces'; + +/** Built-in constructors that are NOT treated as nested DTOs during seal. */ +export const PRIMITIVE_CTORS = new Set([Number, String, Boolean, Date]); + +/** + * The runtime key list of {@link SealOptions}, in fixed order. Built from a `Record` so a new (or removed) SealOptions field is a COMPILE error here until covered. Single source + * for the compile-cache fingerprint (its bit order) and the runtime per-call seal-time-key rejection. + */ +export const SEAL_OPTION_KEYS = Object.keys({ + enableImplicitConversion: true, + exposeDefaultValues: true, + stopAtFirstError: true, + whitelist: true, + debug: true, +} satisfies Record) as (keyof SealOptions)[]; + +/** + * Property names that must never be used as a field key, an @Expose wire name, or a discriminator + * property — writing them onto the output object corrupts its prototype/shape (prototype pollution). + * Single source shared by every seal-time gate (seal.ts, expose-validator.ts, meta-validator.ts). + */ +export const RESERVED_PROPERTY_NAMES = new Set(['__proto__', 'constructor', 'prototype']); + +// ───────────────────────────────────────────────────────────────────────────── +// Generated variable-name prefixes — centralised to prevent typo-related bugs. The deserialize and +// serialize codegen use DISTINCT name tables (different generated locals); they must stay separate. +// ───────────────────────────────────────────────────────────────────────────── + +/** Deserialize codegen variable-name table (imported as `GEN` by the deserialize builder/codegen). */ +export const DES_GEN = { + field: '__bk$f_', + index: '__bk$i_', + setIdx: '__bk$si_', + setVal: '__bk$sv_', + mapIdx: '__bk$mi_', + mapVal: '__bk$mv_', + mark: '__bk$mark_', + skip: '__bk$skip_', + result: '__bk$r_', + errors: '__bk$re_', + arr: '__bk$arr_', + disc: '__bk$dt_', + nestedIdx: '__bk$j_', + out: '__bk$out', + errList: '__bk$errors', + groups: '__bk$groups', + group0: '__bk$group0', + groupsSet: '__bk$groupsSet', + key: '__bk$k', +} as const; + +/** Serialize codegen variable-name table (imported as `GEN` by the serialize builder). */ +export const SER_GEN = { + out: '__bk$out', + fieldVal: '__bk$fv_', + groups: '__bk$groups', + group0: '__bk$group0', + groupsSet: '__bk$groupsSet', + setArr: '__bk$sa', + setItem: '__bk$si', + mapObj: '__bk$m', + mapEntry: '__bk$me', + serResult: '__bk$sr', + outItem: '__bk$out_item', + discArr: '__bk$da', + discIdx: '__bk$di', + nestedArr: '__bk$na', + nestedIdx: '__bk$ni', + nestedItem: '__bk$nitem', +} as const; + +/** `@Type`() primitive builtin → target type mapping */ +export const PRIMITIVE_TYPE_HINTS: Record = { + Number: 'number', + Boolean: 'boolean', + String: 'string', + Date: 'date', +}; + +/** Asserter rule name → gate type mapping */ +export const ASSERTER_TO_GATE: Record = { + isString: 'string', + isNumber: 'number', + isBoolean: 'boolean', + isDate: 'date', + isInt: 'number', + isArray: 'array', + isObject: 'object', +}; + +/** Asserters whose gate check fully subsumes the rule (skip emit inside gate) */ +export const GATE_ONLY_ASSERTERS = new Set(['isString', 'isBoolean', 'isDate', 'isArray', 'isObject']); diff --git a/src/seal/deserialize-builder.spec.ts b/src/seal/deserialize-builder.spec.ts index 36af206..3b09b7e 100644 --- a/src/seal/deserialize-builder.spec.ts +++ b/src/seal/deserialize-builder.spec.ts @@ -1,14 +1,17 @@ import { isErr, err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; -import type { BakerIssue } from '../errors'; -import type { SealOptions } from '../interfaces'; -import type { RawClassMeta, SealedExecutors, EmittableRule } from '../types'; +import type { BakerIssue } from '../common/errors'; +import type { RawClassMeta } from '../metadata/interfaces'; +import type { EmittableRule } from '../rules/interfaces'; +import type { SealOptions, SealedExecutors } from './interfaces'; import { assertIsErr } from '../../test/integration/helpers/assert'; +import { CollectionType } from '../metadata/enums'; +import { arrayMinSize } from '../rules/array'; import { isNotEmpty } from '../rules/common'; import { min, max } from '../rules/number'; -import { minLength } from '../rules/string'; +import { minLength, maxLength } from '../rules/string'; import { isString, isNumber } from '../rules/typechecker'; import { buildDeserializeCode } from './deserialize-builder'; @@ -287,28 +290,6 @@ describe('buildDeserializeCode', () => { expect(errs.some((e: BakerIssue) => e.path === 'name')).toBe(true); }); - it('should treat @IsDefined as overriding @IsOptional (undefined still fails)', async () => { - // Arrange - class IsDef { - val!: string; - } - const merged: RawClassMeta = { - val: { - validation: [{ rule: isString }], - transform: [], - expose: [], - exclude: null, - type: null, - flags: { isOptional: true, isDefined: true }, // IsDefined wins - }, - }; - const exec = buildDeserializeCode(IsDef, merged, undefined, false, false, resolve); - // Act — undefined should fail (no optional guard when isDefined) - const result = await exec({}); - // Assert - expect(isErr(result)).toBe(true); - }); - // ── Corner ───────────────────────────────────────────────────────────────── it('should use optional guard only (not exposeDefault guard) when @IsOptional + exposeDefaultValues', async () => { @@ -842,7 +823,7 @@ it('should deserialize array of nested DTOs when each:true (hasEach path)', asyn // A no-op rule to trigger hasEach:true path without failing validation const alwaysPass: EmittableRule = Object.assign((_v: unknown): boolean => true, { - emit: (_varName: string, _ctx: import('../types').EmitContext): string => '', + emit: (_varName: string, _ctx: import('../rules/interfaces').EmitContext): string => '', ruleName: 'alwaysPass', }); @@ -959,7 +940,7 @@ it('should support rules that call addExecutor on EmitContext', async () => { }; const customRule: EmittableRule = Object.assign((value: unknown): boolean => typeof value === 'string', { - emit(varName: string, ctx: import('../types').EmitContext): string { + emit(varName: string, ctx: import('../rules/interfaces').EmitContext): string { // exercise addExecutor to cover L657-658 ctx.addExecutor(dummySealedExec); // return a simple validation check (always pass for string) @@ -1093,3 +1074,157 @@ it('whitelist: should use extract key (not field key) for @Expose', async () => const result = await run(WlDto4, merged, { whitelist: true }, { user_name: 'ok' }); expect(isErr(result)).toBe(false); }); + +// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Declared collections (@Type(() => Set/Map)) — element ('each') validation. +// Regression coverage: the declared-collection path (type.collection set) previously +// hand-rolled its element loop and (A) dropped every each-rule on a Map, (B) ignored +// the runtime `groups` filter on each-rules, (C) passed the whole collection — not the +// failing element — as the `value` to a function `message`. All four sites +// (Set/Map × deserialize/validate-only) now route through emitDeclaredEachRules. +// ───────────────────────────────────────────────────────────────────────────── + +type RuntimeOpts = { groups?: string[] }; +const setMeta = (validation: RawClassMeta[string]['validation']): RawClassMeta => ({ + names: { + validation, + transform: [], + expose: [], + exclude: null, + type: { fn: () => Set, collection: CollectionType.Set }, + flags: {}, + }, +}); +const mapMeta = (validation: RawClassMeta[string]['validation']): RawClassMeta => ({ + tags: { + validation, + transform: [], + expose: [], + exclude: null, + type: { fn: () => Map, collection: CollectionType.Map }, + flags: {}, + }, +}); +const runDes = (merged: RawClassMeta, input: unknown, opts?: RuntimeOpts) => + buildDeserializeCode(class {}, merged, undefined, false, false, resolve)(input, opts); +const runVal = (merged: RawClassMeta, input: unknown, opts?: RuntimeOpts) => + buildDeserializeCode(class {}, merged, undefined, false, false, resolve, true)(input, opts); + +describe('declared collection — element validation (deserialize)', () => { + // BUG A — declared Map dropped every each-rule + it('declared Map enforces each-rule on values (invalid)', async () => { + expect(isErr(await runDes(mapMeta([{ rule: isString, each: true }]), { tags: { k1: 'ok', k2: 42 } }))).toBe(true); + }); + it('declared Map passes when all values valid', async () => { + expect(isErr(await runDes(mapMeta([{ rule: isString, each: true }]), { tags: { k1: 'a', k2: 'b' } }))).toBe(false); + }); + it('declared Set still enforces each-rule (regression)', async () => { + expect(isErr(await runDes(setMeta([{ rule: isString, each: true }]), { names: ['ok', 42] }))).toBe(true); + }); + it('declared Set still enforces array-level rule arrayMinSize (regression)', async () => { + expect(isErr(await runDes(setMeta([{ rule: arrayMinSize(5) }]), { names: ['a'] }))).toBe(true); + }); + + // BUG B — declared collections ignored the runtime groups filter on each-rules + it('declared Set each-rule is skipped when its group is not active', async () => { + expect( + isErr( + await runDes(setMeta([{ rule: isString, each: true, groups: ['admin'] }]), { names: ['ok', 42] }, { groups: ['user'] }), + ), + ).toBe(false); + }); + it('declared Set each-rule runs when its group is active', async () => { + expect( + isErr( + await runDes(setMeta([{ rule: isString, each: true, groups: ['admin'] }]), { names: ['ok', 42] }, { groups: ['admin'] }), + ), + ).toBe(true); + }); + it('declared Map each-rule is skipped when its group is not active', async () => { + expect( + isErr( + await runDes(mapMeta([{ rule: isString, each: true, groups: ['admin'] }]), { tags: { k: 42 } }, { groups: ['user'] }), + ), + ).toBe(false); + }); + + // Issue ordering is rule-major (all elements for rule 1, then all for rule 2), matching the + // canonical emitEachRules path — not element-major. + it('declared Set multiple each-rules report in rule-major order (matches canonical)', async () => { + const r = await runDes( + setMeta([ + { rule: minLength(5), each: true }, + { rule: maxLength(2), each: true }, + ]), + { names: ['aaa', 'bbb'] }, + ); + assertIsErr(r); + expect(r.data.map(e => e.code)).toEqual(['minLength', 'minLength', 'maxLength', 'maxLength']); + }); + + // BUG C — function message received the whole collection instead of the failing element + it('declared Set each-rule function message receives the failing element', async () => { + const seen: unknown[] = []; + await runDes( + setMeta([ + { + rule: isString, + each: true, + message: a => { + seen.push(a.value); + return 'bad'; + }, + }, + ]), + { names: ['ok', 42] }, + ); + expect(seen).toEqual([42]); + }); + it('declared Map each-rule function message receives the failing element', async () => { + const seen: unknown[] = []; + await runDes( + mapMeta([ + { + rule: isString, + each: true, + message: a => { + seen.push(a.value); + return 'bad'; + }, + }, + ]), + { tags: { k: 42 } }, + ); + expect(seen).toEqual([42]); + }); +}); + +describe('declared collection — element validation (validate-only)', () => { + // validate-only executors return `BakerIssue[] | null` (null = valid), not a Result. + it('validate-only Map enforces each-rule on values (invalid)', () => { + expect(runVal(mapMeta([{ rule: isString, each: true }]), { tags: { k1: 'ok', k2: 42 } })).not.toBeNull(); + }); + it('validate-only Set each-rule is skipped when its group is not active', () => { + expect( + runVal(setMeta([{ rule: isString, each: true, groups: ['admin'] }]), { names: ['ok', 42] }, { groups: ['user'] }), + ).toBeNull(); + }); + it('validate-only Set each-rule function message receives the failing element', () => { + const seen: unknown[] = []; + runVal( + setMeta([ + { + rule: isString, + each: true, + message: a => { + seen.push(a.value); + return 'bad'; + }, + }, + ]), + { names: ['ok', 42] }, + ); + expect(seen).toEqual([42]); + }); +}); diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index c9c4861..00a9c3b 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -2,988 +2,683 @@ import type { Result, ResultAsync } from '@zipbul/result'; import { err as resultErr, isErr as resultIsErr } from '@zipbul/result'; -import type { SealOptions, RuntimeOptions } from '../interfaces'; -import type { RawClassMeta, RawPropertyMeta, EmitContext, SealedExecutors, RuleDef, MessageArgs } from '../types'; - -import { CacheKey, CollectionType } from '../enums'; -import { BakerError, type BakerIssue } from '../errors'; -import { emitRulePlan } from '../rule-plan'; -import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; -import { GuardKey } from './enums'; - -// ───────────────────────────────────────────────────────────────────────────── -// Generated variable name prefixes — centralised to prevent typo-related bugs -// ───────────────────────────────────────────────────────────────────────────── - -const GEN = { - field: '__bk$f_', - index: '__bk$i_', - setIdx: '__bk$si_', - setVal: '__bk$sv_', - mapIdx: '__bk$mi_', - mapVal: '__bk$mv_', - mark: '__bk$mark_', - skip: '__bk$skip_', - result: '__bk$r_', - errors: '__bk$re_', - arr: '__bk$arr_', - disc: '__bk$dt_', - nestedIdx: '__bk$j_', - out: '__bk$out', - errList: '__bk$errors', - groups: '__bk$groups', - group0: '__bk$group0', - groupsSet: '__bk$groupsSet', - key: '__bk$k', -} as const; +import type { RuntimeOptions, BakerIssue } from '../common'; +import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata'; +import type { EmitContext, RulePlanCache } from '../rules'; +import type { TypeGateConfig } from './deserialize-codegen'; +import type { SealOptions, SealedExecutors, ChildScope, CategorizedRules, ResolvedTypeGate } from './interfaces'; +import type { DeserializeExecutor, ValidateExecutor } from './types'; + +import { CacheKey, BakerError, Direction } from '../common'; +import { CollectionType } from '../metadata'; +import { emitRulePlan } from '../rules'; +import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; +import { DES_GEN as GEN, PRIMITIVE_TYPE_HINTS, ASSERTER_TO_GATE, GATE_ONLY_ASSERTERS } from './constants'; +import { + toVarName, + resolveGuardKey, + GUARD_STRATEGIES, + wrapGroupsGuard, + sameGroups, + generateConversionCode, + categorizeRules, + generateNestedResultCode, + generateNestedEachResultCode, + generateValidateNestedResult, + generateValidateNestedEachResultCode, +} from './deserialize-codegen'; // ───────────────────────────────────────────────────────────────────────────── -// Helpers — code generation utilities +// DeserializeBuilder — new Function-based executor generation // ───────────────────────────────────────────────────────────────────────────── -/** Generate nested error push code that propagates message/context fields */ -function nestedErrPush(errList: string, pathExpr: string, errItemExpr: string, tmpVar: string): string { - // Cache errItemExpr once — avoids repeated property reads in the generated body - const eVar = `${tmpVar}_e`; - return ( - `var ${eVar}=${errItemExpr};\n` + - ` if(${eVar}.message===undefined&&${eVar}.context===undefined){${errList}.push({path:${pathExpr},code:${eVar}.code});}\n` + - ` else{var ${tmpVar}={path:${pathExpr},code:${eVar}.code};\n` + - ` if(${eVar}.message!==undefined)${tmpVar}.message=${eVar}.message;\n` + - ` if(${eVar}.context!==undefined)${tmpVar}.context=${eVar}.context;\n` + - ` ${errList}.push(${tmpVar});}\n` - ); -} - -/** Generate nested error return code that propagates message/context fields */ -function nestedErrReturn(pathExpr: string, errItemExpr: string, tmpVar: string, validateOnly?: boolean): string { - const ret = (arr: string) => (validateOnly ? `return ${arr};\n` : `return err(${arr});\n`); - return ( - `if(${errItemExpr}.message===undefined&&${errItemExpr}.context===undefined)${ret(`[{path:${pathExpr},code:${errItemExpr}.code}]`)}` + - ` var ${tmpVar}={path:${pathExpr},code:${errItemExpr}.code};\n` + - ` if(${errItemExpr}.message!==undefined)${tmpVar}.message=${errItemExpr}.message;\n` + - ` if(${errItemExpr}.context!==undefined)${tmpVar}.context=${errItemExpr}.context;\n` + - ` ${ret(`[${tmpVar}]`)}` - ); -} - -/** Convert field name to a safe JS variable name (includes prefix to prevent internal variable collisions) */ -function toVarName(key: string, prefix?: string): string { - return GEN.field + (prefix || '') + sanitizeKey(key); -} - -/** Determine the extraction key for deserialization (§4.3 step 3) */ -function getDeserializeExtractKey(fieldKey: string, exposeStack: RawPropertyMeta['expose']): string { - // deserializeOnly @Expose with name → use that name - const desDef = exposeStack.find(e => e.deserializeOnly && e.name); - if (desDef) { - return desDef.name!; - } - // Non-directional @Expose with name → use for both directions - const biDef = exposeStack.find(e => !e.deserializeOnly && !e.serializeOnly && e.name); - if (biDef) { - return biDef.name!; - } - return fieldKey; -} - -/** Determine field expose groups — returns undefined (no restriction) if any unconditional expose entry exists */ -function getDeserializeExposeGroups(exposeStack: RawPropertyMeta['expose']): string[] | undefined { - // Single-pass: scan once, bail out as soon as we see an unconditional entry, - // lazily allocate the result Set. - let all: Set | null = null; - for (const e of exposeStack) { - if (e.serializeOnly) { - continue; - } - if (!e.groups || e.groups.length === 0) { - return undefined; - } - if (all === null) { - all = new Set(); - } - for (const g of e.groups) { - all.add(g); +/** + * Class-based deserialize/validate code generator. Instance fields are the single source of truth + * for the per-build state previously threaded through `FieldCodeContext`. Inline-nested DTOs are + * modelled as CHILD builders (see {@link createChild}) that SHARE the parent's reference arrays + * (`regexes`/`refs`/`execs`) and `resolve`/`options` while overriding `pathPrefix`/`varPrefix`/ + * `inputExpr`, so executor ref indices stay identical to the free-function implementation. + */ +class DeserializeBuilder { + readonly Class: Function; + readonly merged: RawClassMeta; + readonly options: SealOptions | undefined; + readonly needsCircularCheck: boolean; + readonly isAsync: boolean; + readonly resolve: (cls: Function) => SealedExecutors | undefined; + + readonly stopAtFirstError: boolean; + readonly collectErrors: boolean; + readonly exposeDefaultValues: boolean; + readonly validateOnly: boolean; + + // Reference arrays — injected into new Function closure. Shared with child builders. + readonly regexes: RegExp[]; + readonly refs: unknown[]; + readonly execs: SealedExecutors[]; + + /** + * Monotonic id source for inline-nested blocks, shared across child builders (boxed so children + * mutate the same counter). Each inline block stamps a unique id into its `varPrefix`, making every + * generated variable name globally unique within the function — so distinct nested scopes can never + * collide regardless of field-name shapes. Deterministic (fixed traversal order) → byte-identical + * code across re-seals, preserving compile-cache sharing. + */ + readonly inlineCounter: { n: number }; + + /** Track classes being inlined to detect circular references (shared across child builders). */ + inlineNestedClasses?: Set; + /** JS expression for path prefix (inline nested context) */ + readonly pathPrefix?: string; + /** Prefix for generated variable names (inline nested context) */ + readonly varPrefix?: string; + /** Input object expression — 'input' by default, custom for inline nested */ + readonly inputExpr?: string; + + constructor( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + needsCircularCheck: boolean, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, + validateOnly: boolean, + /** Inline-nested scope inherited from a parent builder; omit for a root builder. */ + scope?: ChildScope, + ) { + this.Class = Class; + this.merged = merged; + this.options = options; + this.needsCircularCheck = needsCircularCheck; + this.isAsync = isAsync; + this.resolve = resolve; + this.validateOnly = validateOnly; + + this.stopAtFirstError = options?.stopAtFirstError ?? false; + this.collectErrors = !this.stopAtFirstError; + + if (scope) { + // Child: share the parent's reference arrays + circular-tracking set (the single mutable + // accumulator — keeps executor ref indices identical) and inherit the inline-nested scope. + // Inline nested never uses exposeDefaultValues. + this.exposeDefaultValues = false; + this.regexes = scope.regexes; + this.refs = scope.refs; + this.execs = scope.execs; + this.inlineCounter = scope.inlineCounter; + if (scope.inlineNestedClasses) { + this.inlineNestedClasses = scope.inlineNestedClasses; + } + this.pathPrefix = scope.pathPrefix; + this.varPrefix = scope.varPrefix; + this.inputExpr = scope.inputExpr; + } else { + // Root: own a fresh accumulator. + this.exposeDefaultValues = options?.exposeDefaultValues ?? false; + this.regexes = []; + this.refs = []; + this.execs = []; + this.inlineCounter = { n: 0 }; } } - return all === null ? undefined : [...all]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// buildDeserializeCode — new Function-based executor generation (§4.9) -// ───────────────────────────────────────────────────────────────────────────── -type DeserializeExecutor = (input: unknown, opts?: RuntimeOptions) => Result | ResultAsync; -type ValidateExecutor = (input: unknown, opts?: RuntimeOptions) => BakerIssue[] | null | Promise; - -function buildDeserializeCode( - Class: Function, - merged: RawClassMeta, - options: SealOptions | undefined, - needsCircularCheck: boolean, - isAsync: boolean, - resolve: (cls: Function) => SealedExecutors | undefined, -): DeserializeExecutor; -function buildDeserializeCode( - Class: Function, - merged: RawClassMeta, - options: SealOptions | undefined, - needsCircularCheck: boolean, - isAsync: boolean, - resolve: (cls: Function) => SealedExecutors | undefined, - validateOnly: true, -): ValidateExecutor; -function buildDeserializeCode( - Class: Function, - merged: RawClassMeta, - options: SealOptions | undefined, - needsCircularCheck: boolean, - isAsync: boolean, - resolve: (cls: Function) => SealedExecutors | undefined, - validateOnly = false, -): DeserializeExecutor | ValidateExecutor { - const stopAtFirstError = options?.stopAtFirstError ?? false; - const collectErrors = !stopAtFirstError; - const exposeDefaultValues = options?.exposeDefaultValues ?? false; + /** + * Create a CHILD builder for an inline-nested DTO. The child shares the parent's reference arrays + * and circular-tracking set (the single mutable accumulator) via the constructor `scope` argument, + * and overrides `pathPrefix`/`varPrefix`/`inputExpr`. + */ + private createChild(pathPrefix: string, varPrefix: string, inputExpr: string): DeserializeBuilder { + return new DeserializeBuilder( + this.Class, + this.merged, + this.options, + this.needsCircularCheck, + this.isAsync, + this.resolve, + this.validateOnly, + { + regexes: this.regexes, + refs: this.refs, + execs: this.execs, + inlineCounter: this.inlineCounter, + inlineNestedClasses: this.inlineNestedClasses, + pathPrefix, + varPrefix, + inputExpr, + }, + ); + } - // Reference arrays — injected into new Function closure - const regexes: RegExp[] = []; - const refs: unknown[] = []; - const execs: SealedExecutors[] = []; + // ── Entry point ──────────────────────────────────────────────────────────── - // ── Code generation ──────────────────────────────────────────────────────── + build(): DeserializeExecutor | ValidateExecutor { + const { validateOnly, exposeDefaultValues, collectErrors, needsCircularCheck, isAsync, merged, options, Class } = this; + const { regexes, refs, execs } = this; - // Helper: wrap error array return — validate mode returns raw array, deserialize mode wraps in Result.err - const wrapErr = validateOnly ? (inner: string) => inner : (inner: string) => `err(${inner})`; + // Helper: wrap error array return — validate mode returns raw array, deserialize mode wraps in Result.err + const wrapErr = validateOnly ? (inner: string) => inner : (inner: string) => `err(${inner})`; - let body = "'use strict';\n"; + let body = "'use strict';\n"; - // Create instance — skip in validate mode (no object creation needed) - if (validateOnly) { - if (exposeDefaultValues) { - body += 'var __bk$defs = new _Cls();\n'; + // Create instance — skip in validate mode (no object creation needed) + if (validateOnly) { + if (exposeDefaultValues) { + body += 'var __bk$defs = new _Cls();\n'; + } + } else { + body += exposeDefaultValues ? `var ${GEN.out} = new _Cls();\n` : `var ${GEN.out} = Object.create(_Cls.prototype);\n`; } - } else { - body += exposeDefaultValues ? `var ${GEN.out} = new _Cls();\n` : `var ${GEN.out} = Object.create(_Cls.prototype);\n`; - } - // Error array (collectErrors mode) - if (collectErrors) { - body += `var ${GEN.errList} = [];\n`; - } - - // preamble: input type guard (§4.9) - body += `if (input == null || typeof input !== 'object' || Array.isArray(input)) return ${wrapErr("[{path:'',code:'invalidInput'}]")};\n`; - - // WeakSet guard (circular references) — N-3 fix: WeakSet lives per-call, threaded through - // `opts` via a Symbol-keyed slot so nested DTOs in the same call share it. Symbol keys are - // invisible to `Object.keys`/checkCallOptions, so this doesn't pollute the user's opts shape. - // The previous shared-ref WeakSet caused concurrent async deserialize() to false-positive. - if (needsCircularCheck) { - // __SEEN_KEY is hoisted out of the per-call body and captured via the closure - // arguments of `new Function(...)` below — eliminates Symbol.for() lookup on every call. - // Object literal spread is replaced with branched alloc — Bun/JSC optimizes literal-spread - // better than Object.assign({}, ...) (audit H4/H5). - body += `var __seen = (opts && opts[__SEEN_KEY]) || null;\n`; - body += `if (__seen === null) { __seen = new WeakSet(); opts = opts ? { ...opts, [__SEEN_KEY]: __seen } : { [__SEEN_KEY]: __seen }; }\n`; - body += `if (__seen.has(input)) return ${wrapErr("[{path:'',code:'circular'}]")};\n`; - body += `__seen.add(input);\n`; - body += `try {\n`; - } - - // Whitelist check (§7.2) — reject undeclared fields - if (options?.whitelist) { - const allowedKeys = new Set(); - for (const [fieldKey, meta] of Object.entries(merged)) { - const extractKey = getDeserializeExtractKey(fieldKey, meta.expose); - allowedKeys.add(extractKey); + // Error array (collectErrors mode) + if (collectErrors) { + body += `var ${GEN.errList} = [];\n`; } - const allowedIdx = refs.length; - refs.push(allowedKeys); - // Indexed Object.keys loop — empirically 2–30× faster than for-in + Object.hasOwn on - // Bun/JSC. The keys array allocation is dominated by the per-iteration cost of for-in's - // prototype walk + hasOwn function call. - if (collectErrors) { - body += `{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var ${GEN.key}=__wlk[__wli];if(!refs[${allowedIdx}].has(${GEN.key}))${GEN.errList}.push({path:${GEN.key},code:'whitelistViolation'});}}\n`; - } else { - body += `{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var ${GEN.key}=__wlk[__wli];if(!refs[${allowedIdx}].has(${GEN.key}))return ${wrapErr(`[{path:${GEN.key},code:'whitelistViolation'}]`)};}}\n`; + // preamble: input type guard + body += `if (input == null || typeof input !== 'object' || Array.isArray(input)) return ${wrapErr("[{path:'',code:'invalidInput'}]")};\n`; + + // WeakSet guard (circular references) — N-3 fix: WeakSet lives per-call, threaded through + // `opts` via a Symbol-keyed slot so nested DTOs in the same call share it. Symbol keys are + // invisible to `Object.keys`/checkCallOptions, so this doesn't pollute the user's opts shape. + // The previous shared-ref WeakSet caused concurrent async deserialize() to false-positive. + if (needsCircularCheck) { + // __SEEN_KEY is hoisted out of the per-call body and captured via the closure + // arguments of `new Function(...)` below — eliminates Symbol.for() lookup on every call. + // Object literal spread is replaced with branched alloc — Bun/JSC optimizes literal-spread + // better than Object.assign({}, ...) (audit H4/H5). + body += `var __seen = (opts && opts[__SEEN_KEY]) || null;\n`; + body += `if (__seen === null) { __seen = new WeakSet(); opts = opts ? { ...opts, [__SEEN_KEY]: __seen } : { [__SEEN_KEY]: __seen }; }\n`; + body += `if (__seen.has(input)) return ${wrapErr("[{path:'',code:'circular'}]")};\n`; + body += `__seen.add(input);\n`; + body += `try {\n`; } - } - // Groups variable — only when expose groups or validation rule groups exist (§4.9, §M4). - // Single for-of with early break avoids Object.values alloc + closure allocations. - let hasGroupsField = false; - for (const fk in merged) { - const meta = merged[fk]!; - const exposeGroups = getDeserializeExposeGroups(meta.expose); - if (exposeGroups && exposeGroups.length > 0) { - hasGroupsField = true; - break; + // Whitelist check — reject undeclared fields + if (options?.whitelist) { + const allowedKeys = new Set(); + for (const [fieldKey, meta] of Object.entries(merged)) { + const extractKey = resolveExposeName(fieldKey, meta.expose, Direction.Deserialize); + allowedKeys.add(extractKey); + } + const allowedIdx = refs.length; + refs.push(allowedKeys); + + // Indexed Object.keys loop — empirically 2–30× faster than for-in + Object.hasOwn on + // Bun/JSC. The keys array allocation is dominated by the per-iteration cost of for-in's + // prototype walk + hasOwn function call. + if (collectErrors) { + body += `{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var ${GEN.key}=__wlk[__wli];if(!refs[${allowedIdx}].has(${GEN.key}))${GEN.errList}.push({path:${GEN.key},code:'whitelistViolation'});}}\n`; + } else { + body += `{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var ${GEN.key}=__wlk[__wli];if(!refs[${allowedIdx}].has(${GEN.key}))return ${wrapErr(`[{path:${GEN.key},code:'whitelistViolation'}]`)};}}\n`; + } } - let ruleHasGroups = false; - for (const rd of meta.validation) { - if (rd.groups && rd.groups.length > 0) { - ruleHasGroups = true; + + // Groups variable — only when expose groups or validation rule groups exist. + // Single for-of with early break avoids Object.values alloc + closure allocations. + let hasGroupsField = false; + for (const fk in merged) { + const meta = merged[fk]!; + const exposeGroups = resolveExposeGroups(meta.expose, Direction.Deserialize); + if (exposeGroups && exposeGroups.length > 0) { + hasGroupsField = true; + break; + } + let ruleHasGroups = false; + for (const rd of meta.validation) { + if (rd.groups && rd.groups.length > 0) { + ruleHasGroups = true; + break; + } + } + if (ruleHasGroups) { + hasGroupsField = true; break; } } - if (ruleHasGroups) { - hasGroupsField = true; - break; + if (hasGroupsField) { + body += `var ${GEN.groups} = opts && opts.groups;\n`; + body += `var ${GEN.group0} = ${GEN.groups} && ${GEN.groups}.length === 1 ? ${GEN.groups}[0] : null;\n`; + body += `var ${GEN.groupsSet} = ${GEN.groups} && ${GEN.groups}.length > 1 ? new Set(${GEN.groups}) : null;\n`; } - } - if (hasGroupsField) { - body += `var ${GEN.groups} = opts && opts.groups;\n`; - body += `var ${GEN.group0} = ${GEN.groups} && ${GEN.groups}.length === 1 ? ${GEN.groups}[0] : null;\n`; - body += `var ${GEN.groupsSet} = ${GEN.groups} && ${GEN.groups}.length > 1 ? new Set(${GEN.groups}) : null;\n`; - } - - // ── Per-field code generation ────────────────────────────────────────────── - - for (const [fieldKey, meta] of Object.entries(merged)) { - const fieldCode = generateFieldCode(fieldKey, meta, { - stopAtFirstError, - collectErrors, - exposeDefaultValues, - isAsync, - regexes, - refs, - execs, - options, - validateOnly, - resolve, - }); - body += fieldCode; - } - - // ── epilogue ────────────────────────────────────────────────────────────── - - if (collectErrors) { - body += `if (${GEN.errList}.length) return ${validateOnly ? GEN.errList : `err(${GEN.errList})`};\n`; - } - body += `return ${validateOnly ? 'null' : GEN.out};\n`; - // Close try/finally for circular reference WeakSet cleanup - if (needsCircularCheck) { - body += `} finally { __seen.delete(input); }\n`; - } + // ── Per-field code generation ────────────────────────────────────────────── - // sourceURL (§4.9) - // Sanitize class name so it cannot inject newlines / */ that would break out of the comment. - const safeClsName = Class.name.replace(/[^\w$.-]/g, '_'); - body += `//# sourceURL=baker://${safeClsName}/${validateOnly ? 'validate' : 'deserialize'}\n`; - - // ── Execute new Function ─────────────────────────────────────────────────── - - const fnKeyword = isAsync ? 'async function' : 'function'; - const seenKey = Symbol.for('baker:circular-seen'); - const executor = new Function( - '_Cls', - 're', - 'refs', - 'execs', - 'err', - 'isErr', - '__SEEN_KEY', - `return ${fnKeyword}(input, opts) { ` + body + ' }', - )(Class, regexes, refs, execs, resultErr, resultIsErr, seenKey) as ( - input: unknown, - opts?: RuntimeOptions, - ) => Result | ResultAsync; - - return executor; -} + for (const [fieldKey, meta] of Object.entries(merged)) { + body += this.generateFieldCode(fieldKey, meta); + } -// ───────────────────────────────────────────────────────────────────────────── -// buildValidateCode — validate-only executor (no Object.create, no assignments) -// ───────────────────────────────────────────────────────────────────────────── + // ── epilogue ────────────────────────────────────────────────────────────── -function buildValidateCode( - Class: Function, - merged: RawClassMeta, - options: SealOptions | undefined, - needsCircularCheck: boolean, - isAsync: boolean, - resolve: (cls: Function) => SealedExecutors | undefined, -): ValidateExecutor { - return buildDeserializeCode(Class, merged, options, needsCircularCheck, isAsync, resolve, true); -} + if (collectErrors) { + body += `if (${GEN.errList}.length) return ${validateOnly ? GEN.errList : `err(${GEN.errList})`};\n`; + } + body += `return ${validateOnly ? 'null' : GEN.out};\n`; -// ───────────────────────────────────────────────────────────────────────────── -// nullable/optional guard — truth-table strategy pattern (D-3) -// ───────────────────────────────────────────────────────────────────────────── + // Close try/finally for circular reference WeakSet cleanup + if (needsCircularCheck) { + body += `} finally { __seen.delete(input); }\n`; + } -function resolveGuardKey(isNullable: boolean, useOptionalGuard: boolean, isDefined: boolean): GuardKey { - if (isNullable && useOptionalGuard) { - return GuardKey.NullableOptional; - } - if (isNullable) { - return GuardKey.Nullable; - } - if (isDefined) { - return GuardKey.Defined; + // sourceURL + // Sanitize class name so it cannot inject newlines / */ that would break out of the comment. + const safeClsName = Class.name.replace(/[^\w$.-]/g, '_'); + body += `//# sourceURL=baker://${safeClsName}/${validateOnly ? 'validate' : 'deserialize'}\n`; + + // ── Execute new Function ─────────────────────────────────────────────────── + + const fnKeyword = isAsync ? 'async function' : 'function'; + const seenKey = Symbol.for('baker:circular-seen'); + const executor = new Function( + '_Cls', + 're', + 'refs', + 'execs', + 'err', + 'isErr', + '__SEEN_KEY', + `return ${fnKeyword}(input, opts) { ` + body + ' }', + )(Class, regexes, refs, execs, resultErr, resultIsErr, seenKey) as ( + input: unknown, + opts?: RuntimeOptions, + ) => Result | ResultAsync; + + return executor; } - if (useOptionalGuard) { - return GuardKey.Optional; - } - return GuardKey.Default; -} - -interface GuardParams { - varName: string; - emitCtx: EmitContext; - assignNull: string; - validationCode: string; -} -const GUARD_STRATEGIES: Record string> = { - // Case 4: @IsNullable + @IsOptional — assign null, skip undefined - [GuardKey.NullableOptional]({ varName, assignNull, validationCode }) { - let code = `if (${varName} === null) { ${assignNull}}\n`; - code += `else if (${varName} !== undefined) {\n`; - code += validationCode; - code += '}\n'; - return code; - }, - // Case 3: @IsNullable (+ optional @IsDefined — same behavior) - [GuardKey.Nullable]({ varName, emitCtx, assignNull, validationCode }) { - let code = `if (${varName} === undefined) ${emitCtx.fail('isDefined')};\n`; - code += `else if (${varName} !== null) {\n`; - code += validationCode; - code += `} else { ${assignNull}}\n`; - return code; - }, - // @IsDefined — reject only undefined, null/""/0 etc. pass through to subsequent validation - [GuardKey.Defined]({ varName, emitCtx, validationCode }) { - let code = `if (${varName} === undefined) ${emitCtx.fail('isDefined')};\n`; - code += validationCode; - return code; - }, - // Case 2: @IsOptional — skip entirely on undefined/null - [GuardKey.Optional]({ varName, validationCode }) { - let code = `if (${varName} !== undefined && ${varName} !== null) {\n`; - code += validationCode; - code += '}\n'; - return code; - }, - // Case 1: No flags (default) — reject undefined/null - [GuardKey.Default]({ varName, emitCtx, validationCode }) { - let code = `if (${varName} === undefined || ${varName} === null) ${emitCtx.fail('isDefined')};\n`; - code += `else {\n`; - code += validationCode; - code += '}\n'; - return code; - }, -}; - -// ───────────────────────────────────────────────────────────────────────────── -// Field code generation -// ───────────────────────────────────────────────────────────────────────────── + // ── Field code generation ──────────────────────────────────────────────────── -interface FieldCodeContext { - stopAtFirstError: boolean; - collectErrors: boolean; - exposeDefaultValues: boolean; - isAsync: boolean; - regexes: RegExp[]; - refs: unknown[]; - execs: SealedExecutors[]; - options: SealOptions | undefined; - validateOnly: boolean; - /** Resolve a nested class's sealed executor from the owning baker's seal context. */ - resolve: (cls: Function) => SealedExecutors | undefined; - /** Track classes being inlined to detect circular references */ - inlineNestedClasses?: Set; - /** JS expression for path prefix (inline nested context) */ - pathPrefix?: string; - /** Prefix for generated variable names (inline nested context) */ - varPrefix?: string; - /** Input object expression — 'input' by default, custom for inline nested */ - inputExpr?: string; -} + private generateFieldCode(fieldKey: string, meta: RawPropertyMeta): string { + const { exposeDefaultValues } = this; -function generateFieldCode(fieldKey: string, meta: RawPropertyMeta, ctx: FieldCodeContext): string { - const { exposeDefaultValues } = ctx; + // ⓪ Exclude deserializeOnly / bidirectional → skip + if (meta.exclude) { + if (!meta.exclude.serializeOnly) { + if (this.options?.debug) { + const reason = meta.exclude.deserializeOnly ? 'deserializeOnly' : 'bidirectional'; + return `// [baker] field ${JSON.stringify(fieldKey)} excluded (${reason} @Exclude)\n`; + } + return ''; + } + } - // ⓪ Exclude deserializeOnly / bidirectional → skip - if (meta.exclude) { - if (!meta.exclude.serializeOnly) { - if (ctx.options?.debug) { - const reason = meta.exclude.deserializeOnly ? 'deserializeOnly' : 'bidirectional'; - return `// [baker] field ${JSON.stringify(fieldKey)} excluded (${reason} @Exclude)\n`; + // Expose: check if this field is exposed to deserialize + // If all @Expose entries are serializeOnly, skip field + if (meta.expose.length > 0 && meta.expose.every(e => e.serializeOnly)) { + if (this.options?.debug) { + return `// [baker] field ${JSON.stringify(fieldKey)} excluded (all @Expose entries are serializeOnly)\n`; } return ''; } - } - // Expose: check if this field is exposed to deserialize - // If all @Expose entries are serializeOnly, skip field - if (meta.expose.length > 0 && meta.expose.every(e => e.serializeOnly)) { - if (ctx.options?.debug) { - return `// [baker] field ${JSON.stringify(fieldKey)} excluded (all @Expose entries are serializeOnly)\n`; - } - return ''; - } + const varName = toVarName(fieldKey, this.varPrefix); + const extractKey = resolveExposeName(fieldKey, meta.expose, Direction.Deserialize); + const exposeGroups = resolveExposeGroups(meta.expose, Direction.Deserialize); + const inputObj = this.inputExpr || 'input'; - const varName = toVarName(fieldKey, ctx.varPrefix); - const extractKey = getDeserializeExtractKey(fieldKey, meta.expose); - const exposeGroups = getDeserializeExposeGroups(meta.expose); - const inputObj = ctx.inputExpr || 'input'; + // Create EmitContext — bake field-level message/context so EVERY field-own-path failure + // (gate, required-missing, conversion, structural gates) carries them, not just rule bodies. + const fieldExtras = this.computeFieldExtras(meta, fieldKey, varName); + const emitCtx = this.makeEmitCtx(fieldKey, fieldExtras); - // Create EmitContext — bake field-level message/context so EVERY field-own-path failure - // (gate, required-missing, conversion, structural gates) carries them, not just rule bodies. - const fieldExtras = computeFieldExtras(meta, fieldKey, varName, ctx); - const emitCtx = makeEmitCtx(fieldKey, ctx, fieldExtras); + let fieldCode = ''; - let fieldCode = ''; + // ① @ValidateIf guard + let validateIfIdx: number | null = null; + if (meta.flags.validateIf) { + validateIfIdx = this.refs.length; + this.refs.push(meta.flags.validateIf); + } - // ① @ValidateIf guard - let validateIfIdx: number | null = null; - if (meta.flags.validateIf) { - validateIfIdx = ctx.refs.length; - ctx.refs.push(meta.flags.validateIf); - } + // ③ Extract + exposeDefaultValues — W7 (N-4): use Object.hasOwn to block prototype-inherited values + let extractCode: string; + const extractKeyJson = JSON.stringify(extractKey); + if (exposeDefaultValues && !meta.flags.isOptional) { + // exposeDefaultValues still needs hasOwn — must distinguish "missing key" (use default) + // from "explicit undefined" (no default). Prototype-only keys are treated as missing. + const defaultsSource = this.validateOnly ? '__bk$defs' : GEN.out; + extractCode = `var ${varName} = Object.hasOwn(${inputObj}, ${extractKeyJson}) ? ${inputObj}[${extractKeyJson}] : ${defaultsSource}[${JSON.stringify(fieldKey)}];\n`; + } else { + // Direct property access (own or inherited), matching the fast-validator norm (e.g. ajv). + // A per-field `Object.hasOwn` guard would read own-only but cost ~10 ns per 5-field DTO + // (Bun 1.3.13 / i7-13700K) — a ~30% regression on the hot path. The only case it would change + // is an input whose prototype chain carries a declared field name, which requires a global + // `Object.prototype` pollution introduced elsewhere (a separate, pre-existing app vulnerability + // — baker's own input gate rejects `__proto__` payloads). Normal inputs (JSON.parse, framework + // request bodies) are always own-keyed, so this never triggers in practice. + extractCode = `var ${varName} = ${inputObj}[${extractKeyJson}];\n`; + } - // ③ Extract + exposeDefaultValues — W7 (N-4): use Object.hasOwn to block prototype-inherited values - let extractCode: string; - const extractKeyJson = JSON.stringify(extractKey); - if (exposeDefaultValues && !meta.flags.isOptional) { - // exposeDefaultValues still needs hasOwn — must distinguish "missing key" (use default) - // from "explicit undefined" (no default). Prototype-only keys are treated as missing. - const defaultsSource = ctx.validateOnly ? '__bk$defs' : GEN.out; - extractCode = `var ${varName} = Object.hasOwn(${inputObj}, ${extractKeyJson}) ? ${inputObj}[${extractKeyJson}] : ${defaultsSource}[${JSON.stringify(fieldKey)}];\n`; - } else { - // Direct property access (own or inherited), matching the fast-validator norm (e.g. ajv). - // A per-field `Object.hasOwn` guard would read own-only but cost ~10 ns per 5-field DTO - // (Bun 1.3.13 / i7-13700K) — a ~30% regression on the hot path. The only case it would change - // is an input whose prototype chain carries a declared field name, which requires a global - // `Object.prototype` pollution introduced elsewhere (a separate, pre-existing app vulnerability - // — baker's own input gate rejects `__proto__` payloads). Normal inputs (JSON.parse, framework - // request bodies) are always own-keyed, so this never triggers in practice. - extractCode = `var ${varName} = ${inputObj}[${extractKeyJson}];\n`; - } + // groups check wrap + let fieldStart = ''; + let fieldEnd = ''; + if (exposeGroups && exposeGroups.length > 0) { + fieldStart = `if ((${GEN.group0} !== null || ${GEN.groupsSet}) && (${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, exposeGroups)})) {\n`; + fieldEnd = '}\n'; + } - // groups check wrap (§4.5) - let fieldStart = ''; - let fieldEnd = ''; - if (exposeGroups && exposeGroups.length > 0) { - fieldStart = `if ((${GEN.group0} !== null || ${GEN.groupsSet}) && (${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, exposeGroups)})) {\n`; - fieldEnd = '}\n'; - } + // inner content (extract + optional guard + validation + assign) + let innerCode = extractCode; - // inner content (extract + optional guard + validation + assign) - let innerCode = extractCode; + // ② null/undefined guard — optional / nullable combinations + const useOptionalGuard = meta.flags.isOptional === true; + const isNullable = meta.flags.isNullable === true; - // ② null/undefined guard — @IsOptional, @IsNullable, @IsDefined combinations (§4.3, Phase5) - const useOptionalGuard = !!(meta.flags.isOptional && !meta.flags.isDefined); - const isNullable = meta.flags.isNullable === true; + const validationCode = this.generateValidationCode(fieldKey, varName, meta, emitCtx, exposeGroups); + const assignNull = this.validateOnly ? '' : `${GEN.out}[${JSON.stringify(fieldKey)}] = null;\n`; - const validationCode = generateValidationCode(fieldKey, varName, meta, ctx, emitCtx, exposeGroups); - const assignNull = ctx.validateOnly ? '' : `${GEN.out}[${JSON.stringify(fieldKey)}] = null;\n`; + const guardKey = resolveGuardKey(isNullable, useOptionalGuard); + innerCode += GUARD_STRATEGIES[guardKey]({ varName, emitCtx, assignNull, validationCode }); - const guardKey = resolveGuardKey(isNullable, useOptionalGuard, meta.flags.isDefined ?? false); - innerCode += GUARD_STRATEGIES[guardKey]({ varName, emitCtx, assignNull, validationCode }); + // ① @ValidateIf outer wrap + if (validateIfIdx !== null) { + fieldCode += fieldStart + `if (refs[${validateIfIdx}](${inputObj})) {\n` + innerCode + '}\n' + fieldEnd; + } else { + fieldCode += fieldStart + innerCode + fieldEnd; + } - // ① @ValidateIf outer wrap - if (validateIfIdx !== null) { - fieldCode += fieldStart + `if (refs[${validateIfIdx}](${inputObj})) {\n` + innerCode + '}\n' + fieldEnd; - } else { - fieldCode += fieldStart + innerCode + fieldEnd; + return fieldCode; } - return fieldCode; -} + // ── Validation code generation — type guard + transform + validate + assign ── -// ───────────────────────────────────────────────────────────────────────────── -// Validation code generation — type guard + transform + validate + assign -// ───────────────────────────────────────────────────────────────────────────── + private generateValidationCode( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + fieldGroups?: string[], + ): string { + const { collectErrors } = this; -function generateValidationCode( - fieldKey: string, - varName: string, - meta: RawPropertyMeta, - ctx: FieldCodeContext, - emitCtx: EmitContext, - fieldGroups?: string[], -): string { - const { collectErrors } = ctx; - - let code = ''; - - // @Transform (deserialize direction) — before validation (§4.3 ⑤) - const dsTransforms = meta.transform.filter(td => !td.options?.serializeOnly); - if (dsTransforms.length > 0) { - const fkJson = JSON.stringify(fieldKey); - const objExpr = ctx.inputExpr || 'input'; - if (dsTransforms.length === 1) { - const td = dsTransforms[0]!; - const refIdx = ctx.refs.length; - ctx.refs.push(td.fn); - const callExpr = `refs[${refIdx}]({value:${varName},key:${fkJson},obj:${objExpr}})`; - code += `${varName} = ${td.isAsync ? 'await ' : ''}${callExpr};\n`; - } else if (dsTransforms.length === 2) { - const td0 = dsTransforms[0]!; - const td1 = dsTransforms[1]!; - const refIdx0 = ctx.refs.length; - ctx.refs.push(td0.fn); - const refIdx1 = ctx.refs.length; - ctx.refs.push(td1.fn); - const call0 = `refs[${refIdx0}]({value:${varName},key:${fkJson},obj:${objExpr}})`; - const expr0 = td0.isAsync ? `await ${call0}` : call0; - const call1 = `refs[${refIdx1}]({value:${expr0},key:${fkJson},obj:${objExpr}})`; - code += `${varName} = ${td1.isAsync ? 'await ' : ''}${call1};\n`; - } else { - for (const td of dsTransforms) { - const refIdx = ctx.refs.length; - ctx.refs.push(td.fn); + let code = ''; + + // @Transform (deserialize direction) — before validation + const dsTransforms = meta.transform.filter(td => !td.options?.serializeOnly); + if (dsTransforms.length > 0) { + const fkJson = JSON.stringify(fieldKey); + const objExpr = this.inputExpr || 'input'; + if (dsTransforms.length === 1) { + const td = dsTransforms[0]!; + const refIdx = this.refs.length; + this.refs.push(td.fn); const callExpr = `refs[${refIdx}]({value:${varName},key:${fkJson},obj:${objExpr}})`; code += `${varName} = ${td.isAsync ? 'await ' : ''}${callExpr};\n`; + } else if (dsTransforms.length === 2) { + const td0 = dsTransforms[0]!; + const td1 = dsTransforms[1]!; + const refIdx0 = this.refs.length; + this.refs.push(td0.fn); + const refIdx1 = this.refs.length; + this.refs.push(td1.fn); + const call0 = `refs[${refIdx0}]({value:${varName},key:${fkJson},obj:${objExpr}})`; + const expr0 = td0.isAsync ? `await ${call0}` : call0; + const call1 = `refs[${refIdx1}]({value:${expr0},key:${fkJson},obj:${objExpr}})`; + code += `${varName} = ${td1.isAsync ? 'await ' : ''}${call1};\n`; + } else { + for (const td of dsTransforms) { + const refIdx = this.refs.length; + this.refs.push(td.fn); + const callExpr = `refs[${refIdx}]({value:${varName},key:${fkJson},obj:${objExpr}})`; + code += `${varName} = ${td.isAsync ? 'await ' : ''}${callExpr};\n`; + } } } - } - - // Collection (Map/Set) auto conversion - if (meta.type?.collection) { - code += ctx.validateOnly - ? generateCollectionCodeValidateOnly(fieldKey, varName, meta, ctx, emitCtx) - : generateCollectionCode(fieldKey, varName, meta, ctx, emitCtx); - return code; - } - - // @ValidateNested + @Type (§8.1) - if (meta.flags.validateNested && meta.type?.fn) { - code += ctx.validateOnly - ? generateNestedCodeValidateOnly(fieldKey, varName, meta, ctx, emitCtx) - : generateNestedCode(fieldKey, varName, meta, ctx, emitCtx); - return code; - } - // No validation rules → direct assign (skip in validate mode) - if (meta.validation.length === 0) { - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + // Collection (Map/Set) auto conversion + if (meta.type?.collection) { + code += this.validateOnly + ? this.generateCollectionCodeValidateOnly(fieldKey, varName, meta, emitCtx, fieldGroups) + : this.generateCollectionCode(fieldKey, varName, meta, emitCtx, fieldGroups); + return code; } - return code; - } - - // Build validation with type gate - code += buildRulesCode(fieldKey, varName, meta.validation, collectErrors, emitCtx, ctx, meta, fieldGroups); - - return code; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers for computing message/context extra fields in generated issue objects -// ───────────────────────────────────────────────────────────────────────────── -/** Build the `,message:...,context:...` extras string for a generated issue object. - * `getConstraintsArg` produces the JS expression for a message function's `constraints` - * field; it runs AFTER the message ref is pushed, preserving ref-array order. */ -function buildIssueExtras( - message: string | ((args: MessageArgs) => string) | undefined, - context: unknown, - getConstraintsArg: () => string, - fieldKey: string, - varName: string, - ctx: FieldCodeContext, -): string { - let extra = ''; - if (typeof message === 'string') { - extra += `,message:${JSON.stringify(message)}`; - } else if (typeof message === 'function') { - const msgIdx = ctx.refs.length; - ctx.refs.push(message as unknown); - const constraintsArg = getConstraintsArg(); - extra += `,message:refs[${msgIdx}]({property:${JSON.stringify(fieldKey)},value:${varName},constraints:${constraintsArg}})`; - } - if (context !== undefined) { - const ctxIdx = ctx.refs.length; - ctx.refs.push(context); - extra += `,context:refs[${ctxIdx}]`; - } - return extra; -} + // @ValidateNested + @Type + if (meta.flags.validateNested && meta.type?.fn) { + code += this.validateOnly + ? this.generateNestedCodeValidateOnly(fieldKey, varName, meta, emitCtx) + : this.generateNestedCode(fieldKey, varName, meta, emitCtx); + return code; + } -/** Per-rule extras — a message function receives the failing rule's `constraints`. */ -function computeRuleExtras(rd: RuleDef, fieldKey: string, varName: string, ctx: FieldCodeContext): string { - return buildIssueExtras( - rd.message, - rd.context, - () => { - const constraintsIdx = ctx.refs.length; - ctx.refs.push(rd.rule.constraints ?? {}); - return `refs[${constraintsIdx}]`; - }, - fieldKey, - varName, - ctx, - ); -} + // No validation rules → direct assign (skip in validate mode) + if (meta.validation.length === 0) { + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + return code; + } -/** Field-level extras appended to EVERY failure of a field — including non-rule failures - * (type gate, required-missing, conversion, structural gates) and type-only fields. No - * specific rule applies, so a message function gets `constraints:{}`. */ -function computeFieldExtras(meta: RawPropertyMeta, fieldKey: string, varName: string, ctx: FieldCodeContext): string { - return buildIssueExtras(meta.message, meta.context, () => '{}', fieldKey, varName, ctx); -} + // Build validation with type gate + code += this.buildRulesCode(fieldKey, varName, meta.validation, collectErrors, emitCtx, meta, fieldGroups); -/** Create per-rule EmitContext (with message/context overrides) */ -function makeRuleEmitCtx( - baseEmitCtx: EmitContext, - fieldKey: string, - varName: string, - rd: RuleDef, - ctx: FieldCodeContext, -): EmitContext { - const extra = computeRuleExtras(rd, fieldKey, varName, ctx); - if (!extra) { - return baseEmitCtx; + return code; } - const pathExpr = baseEmitCtx.pathExpr ?? JSON.stringify(fieldKey); - return { - ...baseEmitCtx, - fail(code: string): string { - if (baseEmitCtx.collectErrors) { - return `${GEN.errList}.push({path:${pathExpr},code:${JSON.stringify(code)}${extra}})`; - } else if (ctx.validateOnly) { - return `return [{path:${pathExpr},code:${JSON.stringify(code)}${extra}}]`; - } - return `return err([{path:${pathExpr},code:${JSON.stringify(code)}${extra}}])`; - }, - }; -} -function emitRuleList( - fieldKey: string, - varName: string, - rules: RuleDef[], - emitCtx: EmitContext, - ctx: FieldCodeContext, - indent: string, - fieldGroups?: string[], - insideTypeGate?: boolean, -): string { - let code = ''; - // Single-pass partition over rules, counting both cacheable categories without a filter[] alloc. - let lengthCount = 0; - let timeCount = 0; - for (const rd of rules) { - if (!sameGroups(rd.groups, fieldGroups)) { - continue; + // ── Helpers for computing message/context extra fields in generated issue objects ── + + /** Build the `,message:...,context:...` extras string for a generated issue object. + * `getConstraintsArg` produces the JS expression for a message function's `constraints` + * field; it runs AFTER the message ref is pushed, preserving ref-array order. */ + private buildIssueExtras( + message: string | ((args: MessageArgs) => string) | undefined, + context: unknown, + getConstraintsArg: () => string, + fieldKey: string, + varName: string, + ): string { + let extra = ''; + if (typeof message === 'string') { + extra += `,message:${JSON.stringify(message)}`; + } else if (typeof message === 'function') { + const msgIdx = this.refs.length; + this.refs.push(message as unknown); + const constraintsArg = getConstraintsArg(); + extra += `,message:refs[${msgIdx}]({property:${JSON.stringify(fieldKey)},value:${varName},constraints:${constraintsArg}})`; } - if (rd.rule.plan?.cacheKey === CacheKey.Length) { - lengthCount += 1; - } else if (rd.rule.plan?.cacheKey === CacheKey.Time) { - timeCount += 1; + if (context !== undefined) { + const ctxIdx = this.refs.length; + this.refs.push(context); + extra += `,context:refs[${ctxIdx}]`; } + return extra; } - const sk = sanitizeKey(fieldKey); - const lengthVar = lengthCount > 1 ? `${GEN.arr}${sk}len` : null; - const timeVar = timeCount > 1 ? `${GEN.arr}${sk}time` : null; - if (lengthVar) { - code += `${indent}var ${lengthVar} = ${varName}.length;\n`; + /** Per-rule extras — a message function receives the failing rule's `constraints`. */ + private computeRuleExtras(rd: RuleDef, fieldKey: string, varName: string): string { + return this.buildIssueExtras( + rd.message, + rd.context, + () => { + const constraintsIdx = this.refs.length; + this.refs.push(rd.rule.constraints ?? {}); + return `refs[${constraintsIdx}]`; + }, + fieldKey, + varName, + ); } - if (timeVar) { - code += `${indent}var ${timeVar} = ${varName}.getTime();\n`; + + /** Field-level extras appended to EVERY failure of a field — including non-rule failures + * (type gate, required-missing, conversion, structural gates) and type-only fields. No + * specific rule applies, so a message function gets `constraints:{}`. */ + private computeFieldExtras(meta: RawPropertyMeta, fieldKey: string, varName: string): string { + return this.buildIssueExtras(meta.message, meta.context, () => '{}', fieldKey, varName); } - for (const rd of rules) { - const sg = sameGroups(rd.groups, fieldGroups); // cache once — was called 3× per rule - const ruleEmitCtx = makeRuleEmitCtx(emitCtx, fieldKey, varName, rd, ctx); - const gatedCtx = insideTypeGate ? { ...ruleEmitCtx, insideTypeGate: true } : ruleEmitCtx; - let emitted: string; - if (sg && rd.rule.plan && (lengthVar || timeVar)) { - const cache: { length?: string; time?: string } = {}; - if (rd.rule.plan.cacheKey === CacheKey.Length && lengthVar) { - cache.length = lengthVar; - } - if (rd.rule.plan.cacheKey === CacheKey.Time && timeVar) { - cache.time = timeVar; - } - emitted = emitRulePlan(varName, gatedCtx, rd.rule.ruleName, rd.rule.plan, cache, insideTypeGate); - } else { - emitted = rd.rule.emit(varName, gatedCtx); + /** Create per-rule EmitContext (with message/context overrides) */ + private makeRuleEmitCtx(baseEmitCtx: EmitContext, fieldKey: string, varName: string, rd: RuleDef): EmitContext { + const extra = this.computeRuleExtras(rd, fieldKey, varName); + if (!extra) { + return baseEmitCtx; } - if (!emitted) { - continue; - } // empty emit (e.g., asserter fully subsumed by gate) - const ruleCode = sg ? emitted : wrapGroupsGuard(rd, emitted); - code += indent + ruleCode.replace(/\n/g, '\n' + indent) + '\n'; + const pathExpr = baseEmitCtx.pathExpr ?? JSON.stringify(fieldKey); + const validateOnly = this.validateOnly; + return { + ...baseEmitCtx, + fail(code: string): string { + if (baseEmitCtx.collectErrors) { + return `${GEN.errList}.push({path:${pathExpr},code:${JSON.stringify(code)}${extra}})`; + } else if (validateOnly) { + return `return [{path:${pathExpr},code:${JSON.stringify(code)}${extra}}]`; + } + return `return err([{path:${pathExpr},code:${JSON.stringify(code)}${extra}}])`; + }, + }; } - return code; -} - -// ───────────────────────────────────────────────────────────────────────────── -// wrapGroupsGuard — per-rule validation groups check wrapper (§M4) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * When rd.groups is set, only execute code if there is an intersection with runtime __bk$groups. - * Rules without groups always execute (preserves existing behavior). - */ -function wrapGroupsGuard(rd: RuleDef, code: string): string { - if (!rd.groups || rd.groups.length === 0) { - return code; - } - return `if ((${GEN.group0} === null && !${GEN.groupsSet}) || ${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, rd.groups)}) {\n${code}\n}\n`; -} + private emitRuleList( + fieldKey: string, + varName: string, + rules: RuleDef[], + emitCtx: EmitContext, + indent: string, + fieldGroups?: string[], + insideTypeGate?: boolean, + ): string { + let code = ''; + // Single-pass partition over rules, counting both cacheable categories without a filter[] alloc. + let lengthCount = 0; + let timeCount = 0; + for (const rd of rules) { + if (!sameGroups(rd.groups, fieldGroups)) { + continue; + } + if (rd.rule.plan?.cacheKey === CacheKey.Length) { + lengthCount += 1; + } else if (rd.rule.plan?.cacheKey === CacheKey.Time) { + timeCount += 1; + } + } + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); + const lengthVar = lengthCount > 1 ? `${GEN.arr}${sk}len` : null; + const timeVar = timeCount > 1 ? `${GEN.arr}${sk}time` : null; -function sameGroups(a?: string[], b?: string[]): boolean { - if (!a || a.length === 0) { - return !b || b.length === 0; - } - if (!b || a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false; + if (lengthVar) { + code += `${indent}var ${lengthVar} = ${varName}.length;\n`; + } + if (timeVar) { + code += `${indent}var ${timeVar} = ${varName}.getTime();\n`; } - } - return true; -} -// ───────────────────────────────────────────────────────────────────────────── -// generateConversionCode — enableImplicitConversion conversion code generation -// ───────────────────────────────────────────────────────────────────────────── + for (const rd of rules) { + const sg = sameGroups(rd.groups, fieldGroups); // cache once — was called 3× per rule + const ruleEmitCtx = this.makeRuleEmitCtx(emitCtx, fieldKey, varName, rd); + const gatedCtx = insideTypeGate ? { ...ruleEmitCtx, insideTypeGate: true } : ruleEmitCtx; + let emitted: string; + if (sg && rd.rule.plan && (lengthVar || timeVar)) { + const cache: RulePlanCache = {}; + if (rd.rule.plan.cacheKey === CacheKey.Length && lengthVar) { + cache.length = lengthVar; + } + if (rd.rule.plan.cacheKey === CacheKey.Time && timeVar) { + cache.time = timeVar; + } + emitted = emitRulePlan(varName, gatedCtx, rd.rule.ruleName, rd.rule.plan, cache, insideTypeGate); + } else { + emitted = rd.rule.emit(varName, gatedCtx); + } + if (!emitted) { + continue; + } // empty emit (e.g., asserter fully subsumed by gate) + const ruleCode = sg ? emitted : wrapGroupsGuard(rd, emitted); + code += indent + ruleCode.replace(/\n/g, '\n' + indent) + '\n'; + } -function generateConversionCode( - targetType: string, - varName: string, - fieldKey: string, - skipVar: string | null, // null = stopAtFirstError - collectErrors: boolean, - emitCtx: EmitContext, -): string { - const failCode = collectErrors - ? `${emitCtx.fail('conversionFailed')}; ${skipVar} = true;` - : emitCtx.fail('conversionFailed') + ';'; - - switch (targetType) { - case 'string': - return ` ${varName} = String(${varName});\n`; - case 'number': - return ` ${varName} = Number(${varName});\n if (isNaN(${varName})) { ${failCode} }\n`; - case 'boolean': - return ( - ` if (${varName} === 'true' || ${varName} === '1' || ${varName} === 1) ${varName} = true;\n` + - ` else if (${varName} === 'false' || ${varName} === '0' || ${varName} === 0) ${varName} = false;\n` + - ` else { ${failCode} }\n` - ); - case 'date': - return ` ${varName} = new Date(${varName});\n if (isNaN(${varName}.getTime())) { ${failCode} }\n`; - default: - throw new BakerError(`Unknown implicit conversion type: "${targetType}" for field "${fieldKey}"`); + return code; } -} -/** `@Type`() primitive builtin → target type mapping */ -const PRIMITIVE_TYPE_HINTS: Record = { - Number: 'number', - Boolean: 'boolean', - String: 'string', - Date: 'date', -}; - -/** Asserter rule name → gate type mapping */ -const ASSERTER_TO_GATE: Record = { - isString: 'string', - isNumber: 'number', - isBoolean: 'boolean', - isDate: 'date', - isInt: 'number', - isArray: 'array', - isObject: 'object', -}; - -/** Asserters whose gate check fully subsumes the rule (skip emit inside gate) */ -const GATE_ONLY_ASSERTERS = new Set(['isString', 'isBoolean', 'isDate', 'isArray', 'isObject']); + // ── buildRulesCode — type guard + marker pattern ── + // Decomposed into: categorizeRules → resolveTypeGate → emitTypedRules / emitGeneralRules / emitEachRules -// ───────────────────────────────────────────────────────────────────────────── -// buildRulesCode — type guard + marker pattern (§4.3, §4.10) -// Decomposed into: categorizeRules → resolveTypeGate → emitTypedRules / emitGeneralRules / emitEachRules -// ───────────────────────────────────────────────────────────────────────────── + /** resolveTypeGate — determine effective gate type from asserters/conversion/type hints */ + private resolveTypeGate(fieldKey: string, categorized: CategorizedRules, meta: RawPropertyMeta | undefined): ResolvedTypeGate { + const { generalRules, typedDeps } = categorized; -/** Result of categorizeRules — each/nonEach split and typed dependency classification */ -interface CategorizedRules { - each: RuleDef[]; - generalRules: RuleDef[]; - /** The single typed dependency group (if any) after conflict check */ - typedDeps: { type: 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object'; deps: RuleDef[] } | undefined; -} + const hasTypedDeps = !!typedDeps; + const gateType = typedDeps?.type ?? null; + const gateDeps = typedDeps?.deps ?? []; -/** categorizeRules — separate each/nonEach rules, detect mixed gate conflicts */ -function categorizeRules(fieldKey: string, validation: RawPropertyMeta['validation']): CategorizedRules { - // Single-pass partition — was 9 separate .filter() passes over the same array, each allocating - // a fresh intermediate. For a field with N rules, runs at seal time only but adds up across DTOs. - const each: RuleDef[] = []; - const generalRules: RuleDef[] = []; - const typedBuckets: Record = { - string: [], - number: [], - boolean: [], - date: [], - array: [], - object: [], - }; - for (const rd of validation) { - if (rd.each) { - each.push(rd); - continue; - } - const reqType = rd.rule.requiresType; - if (reqType !== undefined) { - typedBuckets[reqType]!.push(rd); - } else { - generalRules.push(rd); + // Find type asserter in generalRules matching gate type + let typeAsserterIdx = -1; + if (gateType) { + typeAsserterIdx = generalRules.findIndex(rd => ASSERTER_TO_GATE[rd.rule.ruleName] === gateType); } - } - // Mixed gate conflict detection — at most one bucket should be non-empty - let chosen: CategorizedRules['typedDeps'] = undefined; - let activeTypes: string[] | null = null; - for (const t of ['string', 'number', 'boolean', 'date', 'array', 'object'] as const) { - const deps = typedBuckets[t]!; - if (deps.length === 0) { - continue; - } - if (chosen) { - // Late allocation: only build the array when we actually need to report a conflict - if (activeTypes === null) { - activeTypes = [chosen.type]; + // enableImplicitConversion check — skip if explicit @Transform for deserialize direction + const enableConversion = !!this.options?.enableImplicitConversion && !meta?.transform.some(td => !td.options?.serializeOnly); + + // enableImplicitConversion: asserter-only gate inference — generate conversion gate even for standalone @IsNumber() usage + let asserterInferredGate: string | null = null; + if (!hasTypedDeps && enableConversion && typeAsserterIdx < 0) { + for (let i = 0; i < generalRules.length; i++) { + const gate = ASSERTER_TO_GATE[generalRules[i]!.rule.ruleName]; + if (gate) { + typeAsserterIdx = i; + asserterInferredGate = gate; + break; + } } - activeTypes.push(t); - } else { - chosen = { type: t, deps }; } - } - if (activeTypes) { - throw new BakerError(`Field "${fieldKey}" has conflicting requiresType: ${activeTypes.join(', ')}`); - } - - return { each, generalRules, typedDeps: chosen }; -} - -/** Result of resolveTypeGate — effective gate type and related metadata */ -interface ResolvedTypeGate { - effectiveGateType: string | null; - /** The typed dependency rules (from requiresType) */ - gateDeps: RuleDef[]; - /** Index of the type asserter within generalRules (-1 if none) */ - typeAsserterIdx: number; - /** The type asserter rule def (if found) */ - typeAsserter: RuleDef | undefined; - /** Whether conversion is enabled for this field */ - enableConversion: boolean; - /** Whether this gate was inferred from asserter only (no typed deps) */ - asserterInferredGate: string | null; - /** Whether this gate was inferred from @Type hint */ - typeHintGate: string | null; -} - -/** resolveTypeGate — determine effective gate type from asserters/conversion/type hints */ -function resolveTypeGate( - fieldKey: string, - categorized: CategorizedRules, - meta: RawPropertyMeta | undefined, - ctx: FieldCodeContext, -): ResolvedTypeGate { - const { generalRules, typedDeps } = categorized; - - const hasTypedDeps = !!typedDeps; - const gateType = typedDeps?.type ?? null; - const gateDeps = typedDeps?.deps ?? []; - - // Find type asserter in generalRules matching gate type - let typeAsserterIdx = -1; - if (gateType) { - typeAsserterIdx = generalRules.findIndex(rd => ASSERTER_TO_GATE[rd.rule.ruleName] === gateType); - } - // enableImplicitConversion check — skip if explicit @Transform for deserialize direction - const enableConversion = !!ctx.options?.enableImplicitConversion && !meta?.transform.some(td => !td.options?.serializeOnly); - - // enableImplicitConversion: asserter-only gate inference — generate conversion gate even for standalone @IsNumber() usage - let asserterInferredGate: string | null = null; - if (!hasTypedDeps && enableConversion && typeAsserterIdx < 0) { - for (let i = 0; i < generalRules.length; i++) { - const gate = ASSERTER_TO_GATE[generalRules[i]!.rule.ruleName]; - if (gate) { - typeAsserterIdx = i; - asserterInferredGate = gate; - break; + const typeAsserter = typeAsserterIdx >= 0 ? generalRules[typeAsserterIdx] : undefined; + + // @Type() primitive hint — infer conversion target when no typed deps exist + let typeHintGate: string | null = null; + if (!hasTypedDeps && !asserterInferredGate && enableConversion && meta?.type?.fn) { + try { + const raw = meta.type.fn(); + const typeCtor = Array.isArray(raw) ? raw[0] : raw; + typeHintGate = typeCtor ? (PRIMITIVE_TYPE_HINTS[typeCtor.name] ?? null) : null; + } catch (e) { + throw new BakerError(`field "${fieldKey}": @Field type function threw: ${(e as Error).message}`, { cause: e }); } } - } - const typeAsserter = typeAsserterIdx >= 0 ? generalRules[typeAsserterIdx] : undefined; - - // @Type() primitive hint — infer conversion target when no typed deps exist - let typeHintGate: string | null = null; - if (!hasTypedDeps && !asserterInferredGate && enableConversion && meta?.type?.fn) { - try { - const raw = meta.type.fn(); - const typeCtor = Array.isArray(raw) ? raw[0] : raw; - typeHintGate = typeCtor ? (PRIMITIVE_TYPE_HINTS[typeCtor.name] ?? null) : null; - } catch (e) { - throw new BakerError(`field "${fieldKey}": @Field type function threw: ${(e as Error).message}`, { cause: e }); - } + return { + effectiveGateType: gateType ?? asserterInferredGate ?? typeHintGate, + gateDeps, + typeAsserterIdx, + typeAsserter, + enableConversion, + asserterInferredGate, + typeHintGate, + }; } - return { - effectiveGateType: gateType ?? asserterInferredGate ?? typeHintGate, - gateDeps, - typeAsserterIdx, - typeAsserter, - enableConversion, - asserterInferredGate, - typeHintGate, - }; -} - -/** Config object for emitTypedRules — bundles closure-captured vars into explicit parameter */ -interface TypeGateConfig { - effectiveGateType: string; - gateCondition: string; - gateErrorCode: string; - gateEmitCtx: EmitContext; - otherGeneral: RuleDef[]; - gateDeps: RuleDef[]; - typeAsserter: RuleDef | undefined; - enableConversion: boolean; -} - -/** emitTypedRules — generate type gate + inner validation code */ -function emitTypedRules( - fieldKey: string, - varName: string, - collectErrors: boolean, - emitCtx: EmitContext, - ctx: FieldCodeContext, - config: TypeGateConfig, - fieldGroups?: string[], -): string { - let code = ''; - const sk = sanitizeKey(fieldKey); // cached — was called up to 4× in this function before - - const { effectiveGateType, gateCondition, gateErrorCode, gateEmitCtx, otherGeneral, gateDeps, typeAsserter, enableConversion } = - config; - - // Helper: emit inner validation rules - const emitInnerRules = (indent: string): string => { - const rules: RuleDef[] = []; - // typeAsserter emit — skip GATE_ONLY_ASSERTERS (isString, isBoolean) as they fully overlap with the gate - if (typeAsserter && !GATE_ONLY_ASSERTERS.has(typeAsserter.rule.ruleName)) { - rules.push(typeAsserter); - } - rules.push(...otherGeneral, ...gateDeps); - return emitRuleList(fieldKey, varName, rules, emitCtx, ctx, indent, fieldGroups, true); - }; + /** emitTypedRules — generate type gate + inner validation code */ + private emitTypedRules( + fieldKey: string, + varName: string, + collectErrors: boolean, + emitCtx: EmitContext, + config: TypeGateConfig, + fieldGroups?: string[], + ): string { + let code = ''; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); // cached — was called up to 4× in this function before + + const { + effectiveGateType, + gateCondition, + gateErrorCode, + gateEmitCtx, + otherGeneral, + gateDeps, + typeAsserter, + enableConversion, + } = config; + + // Helper: emit inner validation rules + const emitInnerRules = (indent: string): string => { + const rules: RuleDef[] = []; + // typeAsserter emit — skip GATE_ONLY_ASSERTERS (isString, isBoolean) as they fully overlap with the gate + if (typeAsserter && !GATE_ONLY_ASSERTERS.has(typeAsserter.rule.ruleName)) { + rules.push(typeAsserter); + } + rules.push(...otherGeneral, ...gateDeps); + return this.emitRuleList(fieldKey, varName, rules, emitCtx, indent, fieldGroups, true); + }; - if (collectErrors) { const canConvert = enableConversion && (effectiveGateType === 'string' || @@ -991,996 +686,1040 @@ function emitTypedRules( effectiveGateType === 'boolean' || effectiveGateType === 'date'); - if (canConvert) { - // Conversion mode: try convert on gate failure, skip field if conversion fails - const skipVar = `${GEN.skip}${sk}`; - code += `var ${skipVar} = false;\n`; - code += `if (${gateCondition}) {\n`; - code += generateConversionCode(effectiveGateType, varName, fieldKey, skipVar, true, emitCtx); - code += `}\n`; - code += `if (!${skipVar}) {\n`; - if (ctx.validateOnly) { - code += emitInnerRules(' '); + if (collectErrors) { + if (canConvert) { + // Conversion mode: try convert on gate failure, skip field if conversion fails + const skipVar = `${GEN.skip}${sk}`; + code += `var ${skipVar} = false;\n`; + code += `if (${gateCondition}) {\n`; + code += generateConversionCode(effectiveGateType, varName, fieldKey, skipVar, true, emitCtx); + code += `}\n`; + code += `if (!${skipVar}) {\n`; + if (this.validateOnly) { + code += emitInnerRules(' '); + } else { + const markVar = `${GEN.mark}${sk}`; + code += ` var ${markVar} = ${GEN.errList}.length;\n`; + code += emitInnerRules(' '); + code += ` if (${GEN.errList}.length === ${markVar}) ${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + code += `}\n`; } else { - const markVar = `${GEN.mark}${sk}`; - code += ` var ${markVar} = ${GEN.errList}.length;\n`; - code += emitInnerRules(' '); - code += ` if (${GEN.errList}.length === ${markVar}) ${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + code += `if (${gateCondition}) ${gateEmitCtx.fail(gateErrorCode)};\n`; + code += `else {\n`; + if (this.validateOnly) { + code += emitInnerRules(' '); + } else { + const markVar = `${GEN.mark}${sk}`; + code += ` var ${markVar} = ${GEN.errList}.length;\n`; + code += emitInnerRules(' '); + code += ` if (${GEN.errList}.length === ${markVar}) ${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + code += `}\n`; } - code += `}\n`; } else { - code += `if (${gateCondition}) ${gateEmitCtx.fail(gateErrorCode)};\n`; - code += `else {\n`; - if (ctx.validateOnly) { - code += emitInnerRules(' '); + if (canConvert) { + code += `if (${gateCondition}) {\n`; + code += generateConversionCode(effectiveGateType, varName, fieldKey, null, false, emitCtx); + code += `}\n`; + code += emitInnerRules(''); + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } } else { - const markVar = `${GEN.mark}${sk}`; - code += ` var ${markVar} = ${GEN.errList}.length;\n`; - code += emitInnerRules(' '); - code += ` if (${GEN.errList}.length === ${markVar}) ${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + code += `if (${gateCondition}) ${gateEmitCtx.fail(gateErrorCode)};\n`; + code += emitInnerRules(''); + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } } - code += `}\n`; } - } else { - const canConvert = - enableConversion && - (effectiveGateType === 'string' || - effectiveGateType === 'number' || - effectiveGateType === 'boolean' || - effectiveGateType === 'date'); - if (canConvert) { - code += `if (${gateCondition}) {\n`; - code += generateConversionCode(effectiveGateType, varName, fieldKey, null, false, emitCtx); - code += `}\n`; - code += emitInnerRules(''); - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; - } - } else { - code += `if (${gateCondition}) ${gateEmitCtx.fail(gateErrorCode)};\n`; - code += emitInnerRules(''); - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; - } - } + return code; } - return code; -} + /** emitGeneralRules — generate type-agnostic rule code */ + private emitGeneralRules( + fieldKey: string, + varName: string, + generalRules: RuleDef[], + collectErrors: boolean, + emitCtx: EmitContext, + fieldGroups?: string[], + ): string { + let code = ''; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); -/** emitGeneralRules — generate type-agnostic rule code */ -function emitGeneralRules( - fieldKey: string, - varName: string, - generalRules: RuleDef[], - collectErrors: boolean, - emitCtx: EmitContext, - ctx: FieldCodeContext, - fieldGroups?: string[], -): string { - let code = ''; - - if (collectErrors) { - if (generalRules.length === 0) { - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + if (collectErrors) { + if (generalRules.length === 0) { + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + } else if (this.validateOnly) { + code += this.emitRuleList(fieldKey, varName, generalRules, emitCtx, '', fieldGroups); + } else { + const markVar = `${GEN.mark}${sk}`; + code += `var ${markVar} = ${GEN.errList}.length;\n`; + code += this.emitRuleList(fieldKey, varName, generalRules, emitCtx, '', fieldGroups); + code += `if (${GEN.errList}.length === ${markVar}) ${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; } - } else if (ctx.validateOnly) { - code += emitRuleList(fieldKey, varName, generalRules, emitCtx, ctx, '', fieldGroups); } else { - const markVar = `${GEN.mark}${sanitizeKey(fieldKey)}`; - code += `var ${markVar} = ${GEN.errList}.length;\n`; - code += emitRuleList(fieldKey, varName, generalRules, emitCtx, ctx, '', fieldGroups); - code += `if (${GEN.errList}.length === ${markVar}) ${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; - } - } else { - code += emitRuleList(fieldKey, varName, generalRules, emitCtx, ctx, '', fieldGroups); - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + code += this.emitRuleList(fieldKey, varName, generalRules, emitCtx, '', fieldGroups); + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } } - } - return code; -} - -/** emitEachRules — generate Array/Set/Map each code */ -function emitEachRules( - fieldKey: string, - varName: string, - eachRules: RuleDef[], - collectErrors: boolean, - emitCtx: EmitContext, - ctx: FieldCodeContext, - fieldGroups?: string[], -): string { - let code = ''; - if (eachRules.length === 0) { return code; } - // pathKey must honor ctx.pathPrefix so inlined nested DTOs report full path. - // Without this, validate(Parent, ...) returned `tags[1]` while deserialize returned `nested.tags[1]`. - const pathKey = ctx.pathPrefix ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); - const sk = sanitizeKey(fieldKey); - const iVar = `${GEN.index}${sk}`; - const siVar = `${GEN.setIdx}${sk}`; - const svVar = `${GEN.setVal}${sk}`; - const miVar = `${GEN.mapIdx}${sk}`; - const mvVar = `${GEN.mapVal}${sk}`; - const prefixVar = `__bk$ep_${sk}`; - const kindVar = `__bk$ck${sk}`; - - // Collection kind + non-collection (isArray) rejection are FIELD-level, not per-rule: compute the - // kind once and reject a non-array/Set/Map a single time. Emitting these inside the per-rule loop - // pushed a duplicate `isArray` issue for every element rule when a non-collection value was given. - code += `var ${kindVar} = Array.isArray(${varName})?1:(${varName} instanceof Set?2:(${varName} instanceof Map?3:0));\n`; - code += `var ${prefixVar} = ${pathKey}+'[';\n`; - code += `if (${kindVar} === 0) ${emitCtx.fail('isArray')};\n`; - - for (const rd of eachRules) { - const extra = computeRuleExtras(rd, fieldKey, varName, ctx); - // Cache the groups-guard predicate once — was previously evaluated twice (open + close) - const rdGroups = rd.groups && rd.groups.length > 0 && !sameGroups(rd.groups, fieldGroups) ? rd.groups : null; - const eachGuardOpen = rdGroups - ? `if ((${GEN.group0} === null && !${GEN.groupsSet}) || ${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, rdGroups)}) {\n` - : ''; - const eachGuardClose = rdGroups ? '}\n' : ''; - - // Collection descriptors: [idxVar, elemExpr, loopHeader, counterDecl, counterInc] - const collections = [ - { - guard: `Array.isArray(${varName})`, - idxVar: iVar, - elemExpr: `${varName}[${iVar}]`, - loopHeader: `for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++)`, - counterDecl: '', - counterInc: '', - }, - { - guard: `${varName} instanceof Set`, - idxVar: siVar, - elemExpr: svVar, - loopHeader: `for (var ${svVar} of ${varName})`, - counterDecl: `var ${siVar} = 0;\n`, - counterInc: `${siVar}++;\n`, - }, - { - guard: `${varName} instanceof Map`, - idxVar: miVar, - elemExpr: mvVar, - loopHeader: `for (var ${mvVar} of ${varName}.values())`, - counterDecl: `var ${miVar} = 0;\n`, - counterInc: `${miVar}++;\n`, - }, - ]; - - // prefixVar (path prefix) is declared once at field level and reused by all branches. - const emitCollectionBlock = (col: (typeof collections)[number]): string => { - const failFn = (c: string) => - collectErrors - ? `${GEN.errList}.push({path:${prefixVar}+${col.idxVar}+']',code:${JSON.stringify(c)}${extra}})` - : ctx.validateOnly - ? `return [{path:${prefixVar}+${col.idxVar}+']',code:${JSON.stringify(c)}${extra}}]` - : `return err([{path:${prefixVar}+${col.idxVar}+']',code:${JSON.stringify(c)}${extra}}])`; - const colEmitCtx: EmitContext = { ...emitCtx, fail: failFn }; - let block = ''; - block += ` ${col.counterDecl}`; - block += ` ${col.loopHeader} {\n`; - block += ' ' + rd.rule.emit(col.elemExpr, colEmitCtx) + '\n'; - if (col.counterInc) { - block += ` ${col.counterInc}`; - } - block += ` }\n`; - return block; - }; - - // Element loops per collection kind. The kind dispatch and the non-collection (isArray) - // rejection are emitted once at field level above; here we only run the element loop for the - // matching kind. kind 0 (non-collection) was already rejected, so no `else` branch is needed. - code += eachGuardOpen; - code += `if (${kindVar} === 1) {\n`; - code += emitCollectionBlock(collections[0]!); - code += `} else if (${kindVar} === 2) {\n`; - code += emitCollectionBlock(collections[1]!); - code += `} else if (${kindVar} === 3) {\n`; - code += emitCollectionBlock(collections[2]!); - code += `}\n`; - code += eachGuardClose; - } - - return code; -} - -/** buildRulesCode — orchestrator that composes categorize → resolve → emit phases */ -function buildRulesCode( - fieldKey: string, - varName: string, - validation: RawPropertyMeta['validation'], - collectErrors: boolean, - emitCtx: EmitContext, - ctx: FieldCodeContext, - meta?: RawPropertyMeta, - fieldGroups?: string[], -): string { - // Phase 1: Categorize rules - const categorized = categorizeRules(fieldKey, validation); - - // Phase 2: Resolve type gate - const resolved = resolveTypeGate(fieldKey, categorized, meta, ctx); - - let code = ''; - - // Phase 3: Emit typed or general rules - const hasTypedDeps = !!categorized.typedDeps; - if (hasTypedDeps || resolved.asserterInferredGate || resolved.typeHintGate) { - // Other general rules (excluding the type asserter) - const otherGeneral = resolved.typeAsserter - ? categorized.generalRules.filter((_, i) => i !== resolved.typeAsserterIdx) - : categorized.generalRules; - - // Generate type gate condition — date uses instanceof, others use typeof - let gateCondition: string; - let gateErrorCode: string; - - if (resolved.typeAsserter) { - gateErrorCode = resolved.typeAsserter.rule.ruleName; - } else if (resolved.gateDeps.length > 0) { - gateErrorCode = resolved.gateDeps[0]!.rule.ruleName; - } else { - gateErrorCode = 'conversionFailed'; // @Type hint only — no asserter or deps + /** emitEachRules — generate Array/Set/Map each code */ + private emitEachRules( + fieldKey: string, + varName: string, + eachRules: RuleDef[], + collectErrors: boolean, + emitCtx: EmitContext, + fieldGroups?: string[], + ): string { + let code = ''; + if (eachRules.length === 0) { + return code; } - if (resolved.effectiveGateType === 'date') { - gateCondition = `!(${varName} instanceof Date) || isNaN(${varName}.getTime())`; - } else if (resolved.effectiveGateType === 'array') { - gateCondition = `!Array.isArray(${varName})`; - } else if (resolved.effectiveGateType === 'object') { - gateCondition = `typeof ${varName} !== 'object' || ${varName} === null || Array.isArray(${varName})`; - } else if (resolved.effectiveGateType === 'number') { - gateCondition = `typeof ${varName} !== 'number' || isNaN(${varName})`; - } else { - gateCondition = `typeof ${varName} !== '${resolved.effectiveGateType}'`; + // pathKey must honor this.pathPrefix so inlined nested DTOs report full path. + // Without this, validate(Parent, ...) returned `tags[1]` while deserialize returned `nested.tags[1]`. + const pathKey = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); + const iVar = `${GEN.index}${sk}`; + const siVar = `${GEN.setIdx}${sk}`; + const svVar = `${GEN.setVal}${sk}`; + const miVar = `${GEN.mapIdx}${sk}`; + const mvVar = `${GEN.mapVal}${sk}`; + const prefixVar = `__bk$ep_${sk}`; + const kindVar = `__bk$ck${sk}`; + // Per-iteration element binding — a message function on an `each` rule must receive the failing + // ELEMENT as `value` (matching the element-level path `field[i]`), not the whole collection. + const elemVar = `__bk$el${sk}`; + + // Collection kind + non-collection (isArray) rejection are FIELD-level, not per-rule: compute the + // kind once and reject a non-array/Set/Map a single time. Emitting these inside the per-rule loop + // pushed a duplicate `isArray` issue for every element rule when a non-collection value was given. + code += `var ${kindVar} = Array.isArray(${varName})?1:(${varName} instanceof Set?2:(${varName} instanceof Map?3:0));\n`; + code += `var ${prefixVar} = ${pathKey}+'[';\n`; + code += `if (${kindVar} === 0) ${emitCtx.fail('isArray')};\n`; + + for (const rd of eachRules) { + // `value` in a message/context refs the per-iteration element binding (declared in each loop body). + const extra = this.computeRuleExtras(rd, fieldKey, elemVar); + // Cache the groups-guard predicate once — was previously evaluated twice (open + close) + const rdGroups = rd.groups && rd.groups.length > 0 && !sameGroups(rd.groups, fieldGroups) ? rd.groups : null; + const eachGuardOpen = rdGroups + ? `if ((${GEN.group0} === null && !${GEN.groupsSet}) || ${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, rdGroups)}) {\n` + : ''; + const eachGuardClose = rdGroups ? '}\n' : ''; + + // Collection descriptors: [idxVar, elemExpr, loopHeader, counterDecl, counterInc] + const collections = [ + { + guard: `Array.isArray(${varName})`, + idxVar: iVar, + elemExpr: `${varName}[${iVar}]`, + loopHeader: `for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++)`, + counterDecl: '', + counterInc: '', + }, + { + guard: `${varName} instanceof Set`, + idxVar: siVar, + elemExpr: svVar, + loopHeader: `for (var ${svVar} of ${varName})`, + counterDecl: `var ${siVar} = 0;\n`, + counterInc: `${siVar}++;\n`, + }, + { + guard: `${varName} instanceof Map`, + idxVar: miVar, + elemExpr: mvVar, + loopHeader: `for (var ${mvVar} of ${varName}.values())`, + counterDecl: `var ${miVar} = 0;\n`, + counterInc: `${miVar}++;\n`, + }, + ]; + + // prefixVar (path prefix) is declared once at field level and reused by all branches. + const emitCollectionBlock = (col: (typeof collections)[number]): string => { + const failFn = (c: string) => + collectErrors + ? `${GEN.errList}.push({path:${prefixVar}+${col.idxVar}+']',code:${JSON.stringify(c)}${extra}})` + : this.validateOnly + ? `return [{path:${prefixVar}+${col.idxVar}+']',code:${JSON.stringify(c)}${extra}}]` + : `return err([{path:${prefixVar}+${col.idxVar}+']',code:${JSON.stringify(c)}${extra}}])`; + const colEmitCtx: EmitContext = { ...emitCtx, fail: failFn }; + let block = ''; + block += ` ${col.counterDecl}`; + block += ` ${col.loopHeader} {\n`; + block += ` var ${elemVar} = ${col.elemExpr};\n`; + block += ' ' + rd.rule.emit(elemVar, colEmitCtx) + '\n'; + if (col.counterInc) { + block += ` ${col.counterInc}`; + } + block += ` }\n`; + return block; + }; + + // Element loops per collection kind. The kind dispatch and the non-collection (isArray) + // rejection are emitted once at field level above; here we only run the element loop for the + // matching kind. kind 0 (non-collection) was already rejected, so no `else` branch is needed. + code += eachGuardOpen; + code += `if (${kindVar} === 1) {\n`; + code += emitCollectionBlock(collections[0]!); + code += `} else if (${kindVar} === 2) {\n`; + code += emitCollectionBlock(collections[1]!); + code += `} else if (${kindVar} === 3) {\n`; + code += emitCollectionBlock(collections[2]!); + code += `}\n`; + code += eachGuardClose; } - // Type gate fail — reflect message/context if typeAsserter rd exists - const gateEmitCtx = resolved.typeAsserter ? makeRuleEmitCtx(emitCtx, fieldKey, varName, resolved.typeAsserter, ctx) : emitCtx; - - code += emitTypedRules( - fieldKey, - varName, - collectErrors, - emitCtx, - ctx, - { - effectiveGateType: resolved.effectiveGateType!, - gateCondition, - gateErrorCode, - gateEmitCtx, - otherGeneral, - gateDeps: resolved.gateDeps, - typeAsserter: resolved.typeAsserter, - enableConversion: resolved.enableConversion, - }, - fieldGroups, - ); - } else { - code += emitGeneralRules(fieldKey, varName, categorized.generalRules, collectErrors, emitCtx, ctx, fieldGroups); - } - - // Phase 4: Emit each rules - code += emitEachRules(fieldKey, varName, categorized.each, collectErrors, emitCtx, ctx, fieldGroups); - - return code; -} - -// ───────────────────────────────────────────────────────────────────────────── -// generateCollectionCode — Map/Set auto conversion -// ───────────────────────────────────────────────────────────────────────────── - -function generateCollectionCode( - fieldKey: string, - varName: string, - meta: RawPropertyMeta, - ctx: FieldCodeContext, - emitCtx: EmitContext, -): string { - const { collectErrors, execs } = ctx; - const sk = sanitizeKey(fieldKey); - const collection = meta.type!.collection!; - const awaitKw = ctx.isAsync ? 'await ' : ''; - - // nested DTO executor (if present) - let execIdx = -1; - if (meta.type!.resolvedCollectionValue) { - const nestedSealed = ctx.resolve(meta.type!.resolvedCollectionValue) as SealedExecutors; - execIdx = execs.length; - execs.push(nestedSealed); + return code; } - let code = ''; - - if (collection === CollectionType.Set) { - // input: array → Set - code += `if (Array.isArray(${varName})) {\n`; - - // array-level validation rules (e.g. arrayMinSize) - const nonEachRules = meta.validation.filter(rd => !rd.each); - code += emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ctx, ' '); - - if (execIdx >= 0) { - // nested DTO Set - const iVar = `${GEN.index}${sk}`; - code += ` var ${GEN.arr}${sk} = new Set();\n`; - code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].deserialize(${varName}[${iVar}], opts);\n`; - code += ` if (isErr(${GEN.result}${sk})) {\n`; - if (collectErrors) { - code += ` var ${GEN.errors}${sk} = ${GEN.result}${sk}.data;\n`; - code += ` var __bk$pp${sk} = ${JSON.stringify(fieldKey)}+'['+${iVar}+'].';\n`; - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.errors}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; - code += - ` ` + - nestedErrPush( - GEN.errList, - `__bk$pp${sk}+${GEN.errors}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.errors}${sk}[${GEN.nestedIdx}${sk}]`, - `__ne${sk}`, - ); - code += ` }\n`; + /** buildRulesCode — orchestrator that composes categorize → resolve → emit phases */ + private buildRulesCode( + fieldKey: string, + varName: string, + validation: RawPropertyMeta['validation'], + collectErrors: boolean, + emitCtx: EmitContext, + meta?: RawPropertyMeta, + fieldGroups?: string[], + ): string { + // Phase 1: Categorize rules + const categorized = categorizeRules(fieldKey, validation); + + // Phase 2: Resolve type gate + const resolved = this.resolveTypeGate(fieldKey, categorized, meta); + + let code = ''; + + // Phase 3: Emit typed or general rules + const hasTypedDeps = !!categorized.typedDeps; + if (hasTypedDeps || resolved.asserterInferredGate || resolved.typeHintGate) { + // Other general rules (excluding the type asserter) + const otherGeneral = resolved.typeAsserter + ? categorized.generalRules.filter((_, i) => i !== resolved.typeAsserterIdx) + : categorized.generalRules; + + // Generate type gate condition — date uses instanceof, others use typeof + let gateCondition: string; + let gateErrorCode: string; + + if (resolved.typeAsserter) { + gateErrorCode = resolved.typeAsserter.rule.ruleName; + } else if (resolved.gateDeps.length > 0) { + gateErrorCode = resolved.gateDeps[0]!.rule.ruleName; } else { - code += ` var ${GEN.errors}${sk} = ${GEN.result}${sk}.data;\n`; - code += ` var __bk$pp${sk} = ${JSON.stringify(fieldKey)}+'['+${iVar}+'].';\n`; - code += ` ` + nestedErrReturn(`__bk$pp${sk}+${GEN.errors}${sk}[0].path`, `${GEN.errors}${sk}[0]`, `__ne${sk}`); - } - code += ` } else { ${GEN.arr}${sk}.add(${GEN.result}${sk}); }\n`; - code += ` }\n`; - code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; - } else { - // primitive Set - code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = new Set(${varName});\n`; - } - - // each validation rules (per element) - const eachRules = meta.validation.filter(rd => rd.each); - if (eachRules.length > 0) { - const siVar = `${GEN.setIdx}${sk}`; - const svVar = `${GEN.setVal}${sk}`; - code += ` var ${siVar} = 0;\n`; - code += ` for (var ${svVar} of ${GEN.out}[${JSON.stringify(fieldKey)}]) {\n`; - for (const rd of eachRules) { - const extra = computeRuleExtras(rd, fieldKey, varName, ctx); - const failFn = (c: string) => - collectErrors - ? `${GEN.errList}.push({path:${JSON.stringify(fieldKey)}+'['+${siVar}+']',code:${JSON.stringify(c)}${extra}})` - : `return err([{path:${JSON.stringify(fieldKey)}+'['+${siVar}+']',code:${JSON.stringify(c)}${extra}}])`; - const colEmitCtx: EmitContext = { ...emitCtx, fail: failFn }; - code += ` ${rd.rule.emit(svVar, colEmitCtx)}\n`; + gateErrorCode = 'conversionFailed'; // @Type hint only — no asserter or deps } - code += ` ${siVar}++;\n`; - code += ` }\n`; - } - code += `} else { ${emitCtx.fail('isArray')}; }\n`; - } else { - // Map: input plain object → Map - code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; - - if (execIdx >= 0) { - // nested DTO Map — indexed Object.keys loop (measured 2-30× faster than for-in+hasOwn on Bun/JSC) - const kVar = `${GEN.key}${sk}`; - const ksVar = `__bk$mk${sk}`; - const iVarMap = `__bk$mi${sk}`; - code += ` var ${GEN.arr}${sk} = new Map();\n`; - code += ` var ${ksVar} = Object.keys(${varName});\n`; - code += ` for (var ${iVarMap}=0; ${iVarMap}<${ksVar}.length; ${iVarMap}++) {\n`; - code += ` var ${kVar} = ${ksVar}[${iVarMap}];\n`; - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].deserialize(${varName}[${kVar}], opts);\n`; - code += ` if (isErr(${GEN.result}${sk})) {\n`; - if (collectErrors) { - code += ` var ${GEN.errors}${sk} = ${GEN.result}${sk}.data;\n`; - code += ` var __bk$pp${sk} = ${JSON.stringify(fieldKey)}+'['+${kVar}+'].';\n`; - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.errors}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; - code += - ` ` + - nestedErrPush( - GEN.errList, - `__bk$pp${sk}+${GEN.errors}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.errors}${sk}[${GEN.nestedIdx}${sk}]`, - `__ne${sk}`, - ); - code += ` }\n`; + if (resolved.effectiveGateType === 'date') { + gateCondition = `!(${varName} instanceof Date) || isNaN(${varName}.getTime())`; + } else if (resolved.effectiveGateType === 'array') { + gateCondition = `!Array.isArray(${varName})`; + } else if (resolved.effectiveGateType === 'object') { + gateCondition = `typeof ${varName} !== 'object' || ${varName} === null || Array.isArray(${varName})`; + } else if (resolved.effectiveGateType === 'number') { + gateCondition = `typeof ${varName} !== 'number' || isNaN(${varName})`; } else { - code += ` var ${GEN.errors}${sk} = ${GEN.result}${sk}.data;\n`; - code += ` var __bk$pp${sk} = ${JSON.stringify(fieldKey)}+'['+${kVar}+'].';\n`; - code += ` ` + nestedErrReturn(`__bk$pp${sk}+${GEN.errors}${sk}[0].path`, `${GEN.errors}${sk}[0]`, `__ne${sk}`); + gateCondition = `typeof ${varName} !== '${resolved.effectiveGateType}'`; } - code += ` } else { ${GEN.arr}${sk}.set(${kVar}, ${GEN.result}${sk}); }\n`; - code += ` }\n`; - code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + + // Type gate fail — reflect message/context if typeAsserter rd exists + const gateEmitCtx = resolved.typeAsserter + ? this.makeRuleEmitCtx(emitCtx, fieldKey, varName, resolved.typeAsserter) + : emitCtx; + + code += this.emitTypedRules( + fieldKey, + varName, + collectErrors, + emitCtx, + { + effectiveGateType: resolved.effectiveGateType!, + gateCondition, + gateErrorCode, + gateEmitCtx, + otherGeneral, + gateDeps: resolved.gateDeps, + typeAsserter: resolved.typeAsserter, + enableConversion: resolved.enableConversion, + }, + fieldGroups, + ); } else { - // primitive Map — indexed Object.keys loop - const ksVar = `__bk$mk${sk}`; - const iVarMap = `__bk$mi${sk}`; - code += ` var ${GEN.arr}${sk} = new Map();\n`; - code += ` var ${ksVar} = Object.keys(${varName});\n`; - code += ` for (var ${iVarMap}=0; ${iVarMap}<${ksVar}.length; ${iVarMap}++) {\n`; - code += ` var ${GEN.key}${sk} = ${ksVar}[${iVarMap}];\n`; - code += ` ${GEN.arr}${sk}.set(${GEN.key}${sk}, ${varName}[${GEN.key}${sk}]);\n`; - code += ` }\n`; - code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + code += this.emitGeneralRules(fieldKey, varName, categorized.generalRules, collectErrors, emitCtx, fieldGroups); } - code += `} else { ${emitCtx.fail('isObject')}; }\n`; - } - - return code; -} - -// ───────────────────────────────────────────────────────────────────────────── -// generateNestedCode — @ValidateNested + @Type (§8.1, §8.2) -// ───────────────────────────────────────────────────────────────────────────── + // Phase 4: Emit each rules + code += this.emitEachRules(fieldKey, varName, categorized.each, collectErrors, emitCtx, fieldGroups); -function generateNestedCode( - fieldKey: string, - varName: string, - meta: RawPropertyMeta, - ctx: FieldCodeContext, - emitCtx: EmitContext, -): string { - const { collectErrors, execs } = ctx; - - if (!meta.type) { - return `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + return code; } - let code = ''; - const sk = sanitizeKey(fieldKey); + /** + * Resolve a nested class's sealed executor. seal() seals every nested DTO (step 4) before deserialize + * codegen (step 6), so this is always present; throwing on `undefined` turns a would-be runtime + * "Cannot read 'deserialize'/'merged' of undefined" into a clear seal-time error and removes the cast. + */ + private resolveExecutor(cls: Function): SealedExecutors { + const sealed = this.resolve(cls); + if (sealed === undefined) { + throw new BakerError(`${this.Class.name}: nested class '${cls.name}' was not sealed before deserialize codegen.`); + } + return sealed; + } - if (meta.type.discriminator) { - // §8.3 discriminator - const discProp = JSON.stringify(meta.type.discriminator.property); - code += `var ${GEN.disc}${sk} = ${varName} && ${varName}[${discProp}];\n`; - code += `switch (${GEN.disc}${sk}) {\n`; - for (const sub of meta.type.discriminator.subTypes) { - const nestedSealed = ctx.resolve(sub.value) as SealedExecutors | undefined; - const execIdx = execs.length; - execs.push(nestedSealed as SealedExecutors); - const awaitKwD = ctx.isAsync ? 'await ' : ''; - code += ` case ${JSON.stringify(sub.name)}:\n`; - code += ` var ${GEN.result}${sk} = ${awaitKwD}execs[${execIdx}].deserialize(${varName}, opts);\n`; - code += generateNestedResultCode(fieldKey, `${GEN.result}${sk}`, collectErrors, ctx.pathPrefix); - code += ` break;\n`; + /** + * Emit element ('each') validation for a DECLARED collection (`@Type(() => Set/Map)`). Shared by the + * Set/Map × deserialize/validate-only sites so element rules get the same group filtering, per-element + * `value` binding (for function messages), and path indexing as the canonical `emitEachRules` path. + * `iterableExpr` must yield the element values (Set → the set, Map → `.values()`, array input → the array). + */ + private emitDeclaredEachRules( + fieldKey: string, + eachRules: RuleDef[], + iterableExpr: string, + sk: string, + emitCtx: EmitContext, + fieldGroups: string[] | undefined, + indent: string, + ): string { + if (eachRules.length === 0) { + return ''; } - const validSubTypeNamesJson = JSON.stringify(meta.type.discriminator.subTypes.map(s => s.name)); - const discPathExpr = emitCtx.pathExpr ?? JSON.stringify(fieldKey); - const discValueExpr = `${GEN.disc}${sk}`; - if (collectErrors) { - code += ` default: ${GEN.errList}.push({path:${discPathExpr},code:'invalidDiscriminator',context:{received:${discValueExpr},validSubTypes:${validSubTypeNamesJson}}});\n`; - } else if (ctx.validateOnly) { - code += ` default: return [{path:${discPathExpr},code:'invalidDiscriminator',context:{received:${discValueExpr},validSubTypes:${validSubTypeNamesJson}}}];\n`; - } else { - code += ` default: return err([{path:${discPathExpr},code:'invalidDiscriminator',context:{received:${discValueExpr},validSubTypes:${validSubTypeNamesJson}}}]);\n`; + const idxVar = `${GEN.setIdx}${sk}`; + const elemVar = `__bk$el${sk}`; + const prefixVar = `__bk$ep_${sk}`; + const prefixInit = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['` : `${JSON.stringify(fieldKey)}+'['`; + let code = `${indent}var ${prefixVar} = ${prefixInit};\n`; + // Rule-first iteration — one element loop PER rule, group guard hoisted outside the loop. + // Matches the canonical emitEachRules ordering (issues are rule-major) and its guard placement. + for (const rd of eachRules) { + // value in a function message refers to the per-iteration ELEMENT, not the whole collection. + const extra = this.computeRuleExtras(rd, fieldKey, elemVar); + const rdGroups = rd.groups && rd.groups.length > 0 && !sameGroups(rd.groups, fieldGroups) ? rd.groups : null; + const guardOpen = rdGroups + ? `${indent}if ((${GEN.group0} === null && !${GEN.groupsSet}) || ${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, rdGroups)}) {\n` + : ''; + const guardClose = rdGroups ? `${indent}}\n` : ''; + const failFn = (c: string) => + this.collectErrors + ? `${GEN.errList}.push({path:${prefixVar}+${idxVar}+']',code:${JSON.stringify(c)}${extra}})` + : this.validateOnly + ? `return [{path:${prefixVar}+${idxVar}+']',code:${JSON.stringify(c)}${extra}}]` + : `return err([{path:${prefixVar}+${idxVar}+']',code:${JSON.stringify(c)}${extra}}])`; + const colEmitCtx: EmitContext = { ...emitCtx, fail: failFn }; + code += guardOpen; + code += `${indent} var ${idxVar} = 0;\n`; + code += `${indent} for (var ${elemVar} of ${iterableExpr}) {\n`; + code += `${indent} ${rd.rule.emit(elemVar, colEmitCtx)}\n`; + code += `${indent} ${idxVar}++;\n`; + code += `${indent} }\n`; + code += guardClose; } - code += `}\n`; - // keepDiscriminatorProperty: preserve discriminator property in result object (PB-3) - if (meta.type.keepDiscriminatorProperty) { - const fkJson = JSON.stringify(fieldKey); - code += `{var __dh=${GEN.out}[${fkJson}]; if(__dh!=null) __dh[${discProp}]=${GEN.disc}${sk};}\n`; + return code; + } + + // ── generateCollectionCode — Map/Set auto conversion ── + + private generateCollectionCode( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + fieldGroups: string[] | undefined, + ): string { + const { collectErrors, execs } = this; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); + const type = meta.type!; + const collection = type.collection!; + const awaitKw = this.isAsync ? 'await ' : ''; + + // nested DTO executor (if present) + let execIdx = -1; + if (type.resolvedCollectionValue) { + const nestedSealed = this.resolveExecutor(type.resolvedCollectionValue); + execIdx = execs.length; + execs.push(nestedSealed); } - } else { - // §8.1 simple nested or §8.2 each array - const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); - const nestedSealed = ctx.resolve(nestedCls) as SealedExecutors | undefined; - const execIdx = execs.length; - execs.push(nestedSealed as SealedExecutors); - - // Check if validateNested each (array) — meta.type is already proven non-null above - const hasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); - - if (hasEach) { - const iVar = `${GEN.index}${sk}`; - const awaitKwE = ctx.isAsync ? 'await ' : ''; + + let code = ''; + + if (collection === CollectionType.Set) { + // input: array → Set code += `if (Array.isArray(${varName})) {\n`; - // Emit non-each array-level validation rules (e.g. @ArrayMinSize, @ArrayMaxSize) + // array-level validation rules (e.g. arrayMinSize) const nonEachRules = meta.validation.filter(rd => !rd.each); - code += emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ctx, ' '); - - code += ` var ${GEN.arr}${sk} = [];\n`; - code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; - code += ` var ${GEN.result}${sk} = ${awaitKwE}execs[${execIdx}].deserialize(${varName}[${iVar}], opts);\n`; - code += ` if (isErr(${GEN.result}${sk})) {\n`; - if (collectErrors) { - code += ` var ${GEN.errors}${sk} = ${GEN.result}${sk}.data;\n`; - code += ` var __bk$pp${sk} = ${JSON.stringify(fieldKey)}+'['+${iVar}+'].';\n`; - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.errors}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; - code += - ` ` + - nestedErrPush( - GEN.errList, - `__bk$pp${sk}+${GEN.errors}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.errors}${sk}[${GEN.nestedIdx}${sk}]`, - `__ne${sk}`, - ); - code += ` }\n`; + code += this.emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ' '); + + if (execIdx >= 0) { + // nested DTO Set + const iVar = `${GEN.index}${sk}`; + code += ` var ${GEN.arr}${sk} = new Set();\n`; + code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].deserialize(${varName}[${iVar}], opts);\n`; + code += generateNestedEachResultCode( + `${GEN.result}${sk}`, + `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`, + sk, + collectErrors, + `${GEN.arr}${sk}.add(${GEN.result}${sk});`, + ' ', + ); + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; } else { - code += ` var ${GEN.errors}${sk} = ${GEN.result}${sk}.data;\n`; - code += ` var __bk$pp${sk} = ${JSON.stringify(fieldKey)}+'['+${iVar}+'].';\n`; - code += ` ` + nestedErrReturn(`__bk$pp${sk}+${GEN.errors}${sk}[0].path`, `${GEN.errors}${sk}[0]`, `__ne${sk}`); + // primitive Set + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = new Set(${varName});\n`; } - code += ` } else { ${GEN.arr}${sk}.push(${GEN.result}${sk}); }\n`; - code += ` }\n`; - code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + + // each validation rules (per element) — iterate the materialized Set + const eachRules = meta.validation.filter(rd => rd.each); + code += this.emitDeclaredEachRules( + fieldKey, + eachRules, + `${GEN.out}[${JSON.stringify(fieldKey)}]`, + sk, + emitCtx, + fieldGroups, + ' ', + ); + code += `} else { ${emitCtx.fail('isArray')}; }\n`; } else { - const awaitKwS = ctx.isAsync ? 'await ' : ''; + // Map: input plain object → Map code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; - code += ` var ${GEN.result}${sk} = ${awaitKwS}execs[${execIdx}].deserialize(${varName}, opts);\n`; - code += generateNestedResultCode(fieldKey, `${GEN.result}${sk}`, collectErrors, ctx.pathPrefix); - code += `} else { ${emitCtx.fail('isObject')}; }\n`; - } - } - return code; -} + if (execIdx >= 0) { + // nested DTO Map — indexed Object.keys loop (measured 2-30× faster than for-in+hasOwn on Bun/JSC) + const kVar = `${GEN.key}${sk}`; + const ksVar = `__bk$mk${sk}`; + const iVarMap = `__bk$mi${sk}`; + code += ` var ${GEN.arr}${sk} = new Map();\n`; + code += ` var ${ksVar} = Object.keys(${varName});\n`; + code += ` for (var ${iVarMap}=0; ${iVarMap}<${ksVar}.length; ${iVarMap}++) {\n`; + code += ` var ${kVar} = ${ksVar}[${iVarMap}];\n`; + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].deserialize(${varName}[${kVar}], opts);\n`; + code += generateNestedEachResultCode( + `${GEN.result}${sk}`, + `${JSON.stringify(fieldKey)}+'['+${kVar}+'].'`, + sk, + collectErrors, + `${GEN.arr}${sk}.set(${kVar}, ${GEN.result}${sk});`, + ' ', + ); + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + } else { + // primitive Map — indexed Object.keys loop + const ksVar = `__bk$mk${sk}`; + const iVarMap = `__bk$mi${sk}`; + code += ` var ${GEN.arr}${sk} = new Map();\n`; + code += ` var ${ksVar} = Object.keys(${varName});\n`; + code += ` for (var ${iVarMap}=0; ${iVarMap}<${ksVar}.length; ${iVarMap}++) {\n`; + code += ` var ${GEN.key}${sk} = ${ksVar}[${iVarMap}];\n`; + code += ` ${GEN.arr}${sk}.set(${GEN.key}${sk}, ${varName}[${GEN.key}${sk}]);\n`; + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + } -function generateNestedResultCode(fieldKey: string, resultVar: string, collectErrors: boolean, pathPrefix?: string): string { - const sk = sanitizeKey(fieldKey); - // Prepend the current scope's path prefix so an executor reached from inside an inlined block - // (e.g. a circular nested DTO) keeps the full path, not just `fieldKey.`. - const ppValue = pathPrefix ? `${pathPrefix}+${JSON.stringify(fieldKey + '.')}` : JSON.stringify(fieldKey + '.'); - if (collectErrors) { - const errItem = `${GEN.errors}${sk}[${GEN.nestedIdx}${sk}]`; - return ( - ` if (isErr(${resultVar})) {\n` + - ` var ${GEN.errors}${sk} = ${resultVar}.data;\n` + - ` var __bk$pp${sk} = ${ppValue};\n` + - ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.errors}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n` + - ` ` + - nestedErrPush(GEN.errList, `__bk$pp${sk}+${errItem}.path`, errItem, `__ne${sk}`) + - ` }\n` + - ` } else { ${GEN.out}[${JSON.stringify(fieldKey)}] = ${resultVar}; }\n` - ); - } - const errFirst = `${GEN.errors}${sk}[0]`; - return ( - ` if (isErr(${resultVar})) {\n` + - ` var ${GEN.errors}${sk} = ${resultVar}.data;\n` + - ` var __bk$pp${sk} = ${ppValue};\n` + - ` ` + - nestedErrReturn(`__bk$pp${sk}+${errFirst}.path`, errFirst, `__ne${sk}`) + - ` } else { ${GEN.out}[${JSON.stringify(fieldKey)}] = ${resultVar}; }\n` - ); -} + // each validation rules (per value) — iterate the materialized Map's values + const eachRules = meta.validation.filter(rd => rd.each); + code += this.emitDeclaredEachRules( + fieldKey, + eachRules, + `${GEN.out}[${JSON.stringify(fieldKey)}].values()`, + sk, + emitCtx, + fieldGroups, + ' ', + ); -// ───────────────────────────────────────────────────────────────────────────── -// generateNestedCodeValidateOnly — validate-only nested (inline when possible) -// ───────────────────────────────────────────────────────────────────────────── + code += `} else { ${emitCtx.fail('isObject')}; }\n`; + } -// Inline-eligibility predicate: a nested DTO can be inlined unless it is already in the -// active inline-set (circular reference). Inlined directly at the three call sites below -// — no extra function call at seal time. + return code; + } -/** - * Emit inline validation code for all fields of a nested DTO. - * Reuses generateFieldCode with modified ctx (pathPrefix, varPrefix, inputExpr). - */ -function emitInlineNestedBlock( - nestedMerged: RawClassMeta, - nestedClass: Function, - inputExpr: string, - pathPrefixExpr: string, - varPrefix: string, - ctx: FieldCodeContext, -): string { - const inlinedSet = ctx.inlineNestedClasses!; - inlinedSet.add(nestedClass); - - const inlineCtx: FieldCodeContext = { - ...ctx, - pathPrefix: pathPrefixExpr, - varPrefix, - inputExpr, - exposeDefaultValues: false, // inline nested doesn't use exposeDefaultValues - resolve: ctx.resolve, - }; - - let code = ''; - for (const [fieldKey, meta] of Object.entries(nestedMerged)) { - code += generateFieldCode(fieldKey, meta, inlineCtx); + // ── generateNestedCode — @ValidateNested + @Type ── + + /** + * generateDiscriminatorEachCode — deserialize an ARRAY of discriminated DTOs. Mirrors the + * single-object discriminator path but dispatches the `switch` per element, reporting nested + * errors at `field[i].` paths and the invalid-discriminator error at the `field[i]` element path. + */ + private generateDiscriminatorEachCode( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + sk: string, + ): string { + const { collectErrors, execs } = this; + const disc = meta.type!.discriminator!; + const keepDisc = meta.type!.keepDiscriminatorProperty === true; + const discProp = JSON.stringify(disc.property); + const awaitKwD = this.isAsync ? 'await ' : ''; + const iVar = `${GEN.index}${sk}`; + const itemVar = `__bk$di${sk}`; + const discVar = `${GEN.disc}${sk}`; + const resVar = `${GEN.result}${sk}`; + const ppBase = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); + const elemPathPrefix = `${ppBase}+'['+${iVar}+'].'`; + const elemPath = `${ppBase}+'['+${iVar}+']'`; + const validNamesJson = JSON.stringify(disc.subTypes.map(s => s.name)); + + let code = `if (Array.isArray(${varName})) {\n`; + // Array-level (non-each) rules — e.g. arrayMinSize/arrayMaxSize — run once on the array itself. + const nonEachRules = meta.validation.filter(rd => !rd.each); + code += this.emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ' '); + code += ` var ${GEN.arr}${sk} = [];\n`; + code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; + code += ` var ${itemVar} = ${varName}[${iVar}];\n`; + code += ` var ${discVar} = ${itemVar} && ${itemVar}[${discProp}];\n`; + code += ` switch (${discVar}) {\n`; + for (const sub of disc.subTypes) { + const nestedSealed = this.resolveExecutor(sub.value); + const execIdx = execs.length; + execs.push(nestedSealed); + code += ` case ${JSON.stringify(sub.name)}: {\n`; + code += ` var ${resVar} = ${awaitKwD}execs[${execIdx}].deserialize(${itemVar}, opts);\n`; + const successStmt = `${keepDisc ? `${resVar}[${discProp}] = ${discVar}; ` : ''}${GEN.arr}${sk}.push(${resVar});`; + code += generateNestedEachResultCode(resVar, elemPathPrefix, sk, collectErrors, successStmt, ' '); + code += ` break;\n`; + code += ` }\n`; + } + const discCtx = `{path:${elemPath},code:'invalidDiscriminator',context:{received:${discVar},validSubTypes:${validNamesJson}}}`; + code += collectErrors ? ` default: ${GEN.errList}.push(${discCtx});\n` : ` default: return err([${discCtx}]);\n`; + code += ` }\n`; + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + code += `} else { ${emitCtx.fail('isArray')}; }\n`; + return code; } - inlinedSet.delete(nestedClass); - return code; -} + private generateNestedCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { + const { collectErrors, execs } = this; -function generateNestedCodeValidateOnly( - fieldKey: string, - varName: string, - meta: RawPropertyMeta, - ctx: FieldCodeContext, - emitCtx: EmitContext, -): string { - const { collectErrors, execs } = ctx; - if (!meta.type) { - return ''; - } - const sk = (ctx.varPrefix || '') + sanitizeKey(fieldKey); - let code = ''; + if (!meta.type) { + return `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } - // Initialize inline tracking set if not present - if (!ctx.inlineNestedClasses) { - ctx.inlineNestedClasses = new Set(); - } + let code = ''; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); - if (meta.type.discriminator) { - // Discriminator — inline each subType's validation - const discProp = JSON.stringify(meta.type.discriminator.property); - code += `var ${GEN.disc}${sk} = ${varName} && ${varName}[${discProp}];\n`; - code += `switch (${GEN.disc}${sk}) {\n`; - for (const sub of meta.type.discriminator.subTypes) { - const subSealed = ctx.resolve(sub.value) as SealedExecutors; - const subMerged = subSealed.merged; - const canInline = subMerged && !ctx.inlineNestedClasses.has(sub.value); - code += ` case ${JSON.stringify(sub.name)}:\n`; - if (canInline) { - const ppExpr = ctx.pathPrefix ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey + '.')}` : JSON.stringify(fieldKey + '.'); - const vpPrefix = `${sk}_d${sanitizeKey(sub.name)}_`; - code += emitInlineNestedBlock(subMerged!, sub.value, varName, ppExpr, vpPrefix, ctx); - } else { - const execIdx = execs.length; - execs.push(subSealed); - const awaitKw = ctx.isAsync ? 'await ' : ''; - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}, opts);\n`; - code += generateValidateNestedResult(fieldKey, `${GEN.result}${sk}`, collectErrors, ctx.pathPrefix); + if (meta.type.discriminator) { + // An array of discriminated DTOs (`type: () => [Base]` + discriminator) dispatches the switch + // PER ELEMENT — the single-object path below reads the discriminator off the array itself. + const discHasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); + if (discHasEach) { + return this.generateDiscriminatorEachCode(fieldKey, varName, meta, emitCtx, sk); } - code += ` break;\n`; - } - const validSubTypeNamesJsonV = JSON.stringify(meta.type.discriminator.subTypes.map(s => s.name)); - const discPathExprV = emitCtx.pathExpr ?? JSON.stringify(fieldKey); - const discValueExprV = `${GEN.disc}${sk}`; - if (collectErrors) { - code += ` default: ${GEN.errList}.push({path:${discPathExprV},code:'invalidDiscriminator',context:{received:${discValueExprV},validSubTypes:${validSubTypeNamesJsonV}}});\n`; - } else { - code += ` default: return [{path:${discPathExprV},code:'invalidDiscriminator',context:{received:${discValueExprV},validSubTypes:${validSubTypeNamesJsonV}}}];\n`; - } - code += `}\n`; - } else { - const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); - const nestedSealed = ctx.resolve(nestedCls) as SealedExecutors; - const nestedMerged = nestedSealed.merged; - const hasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); - - // Decide: inline or function call - const useInline = nestedMerged && !ctx.inlineNestedClasses.has(nestedCls); - - if (hasEach) { - const iVar = `${GEN.index}${sk}`; - code += `if (Array.isArray(${varName})) {\n`; - const nonEachRules = meta.validation.filter(rd => !rd.each); - code += emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ctx, ' '); - - code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; - - if (useInline) { - // INLINE: generate validation code directly in the loop body. - // Emit the per-iteration path as a single local var — both the invalidInput error - // path and the nested block reference it, avoiding two identical 3-string concats. - const itemVar = `__il$${sk}item`; - const ppVar = `__bk$pp${sk}`; - const ppExpr = ppVar; - const ppInit = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` - : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; - const vpPrefix = `${sk}i_`; - - code += ` var ${itemVar} = ${varName}[${iVar}];\n`; - code += ` var ${ppVar} = ${ppInit};\n`; - // Input type guard for the item — uses the cached prefix - code += ` if (${itemVar} == null || typeof ${itemVar} !== 'object' || Array.isArray(${itemVar})) `; - if (collectErrors) { - code += `${GEN.errList}.push({path:${ppVar},code:'invalidInput'});\n`; - } else { - code += `return [{path:${ppVar},code:'invalidInput'}];\n`; - } - code += ` else {\n`; - code += emitInlineNestedBlock(nestedMerged!, nestedCls, itemVar, ppExpr, vpPrefix, ctx); - code += ` }\n`; - } else { - // FALLBACK: function call to validate + // discriminator + const discProp = JSON.stringify(meta.type.discriminator.property); + code += `var ${GEN.disc}${sk} = ${varName} && ${varName}[${discProp}];\n`; + code += `switch (${GEN.disc}${sk}) {\n`; + for (const sub of meta.type.discriminator.subTypes) { + const nestedSealed = this.resolveExecutor(sub.value); const execIdx = execs.length; execs.push(nestedSealed); - const awaitKw = ctx.isAsync ? 'await ' : ''; - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}[${iVar}], opts);\n`; - code += ` if (${GEN.result}${sk} !== null) {\n`; - const ppVar = `__bk$pp${sk}`; - const ppInit = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` - : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; - code += ` var ${ppVar} = ${ppInit};\n`; - if (collectErrors) { - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.result}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; - code += - ` ` + - nestedErrPush( - GEN.errList, - `${ppVar}+${GEN.result}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.result}${sk}[${GEN.nestedIdx}${sk}]`, - `__ne${sk}`, - ); - code += ` }\n`; - } else { - code += ` ` + nestedErrReturn(`${ppVar}+${GEN.result}${sk}[0].path`, `${GEN.result}${sk}[0]`, `__ne${sk}`, true); - } - code += ` }\n`; + const awaitKwD = this.isAsync ? 'await ' : ''; + code += ` case ${JSON.stringify(sub.name)}:\n`; + code += ` var ${GEN.result}${sk} = ${awaitKwD}execs[${execIdx}].deserialize(${varName}, opts);\n`; + code += generateNestedResultCode(fieldKey, `${GEN.result}${sk}`, collectErrors, this.pathPrefix); + code += ` break;\n`; + } + const validSubTypeNamesJson = JSON.stringify(meta.type.discriminator.subTypes.map(s => s.name)); + const discPathExpr = emitCtx.pathExpr ?? JSON.stringify(fieldKey); + const discValueExpr = `${GEN.disc}${sk}`; + if (collectErrors) { + code += ` default: ${GEN.errList}.push({path:${discPathExpr},code:'invalidDiscriminator',context:{received:${discValueExpr},validSubTypes:${validSubTypeNamesJson}}});\n`; + } else if (this.validateOnly) { + code += ` default: return [{path:${discPathExpr},code:'invalidDiscriminator',context:{received:${discValueExpr},validSubTypes:${validSubTypeNamesJson}}}];\n`; + } else { + code += ` default: return err([{path:${discPathExpr},code:'invalidDiscriminator',context:{received:${discValueExpr},validSubTypes:${validSubTypeNamesJson}}}]);\n`; + } + code += `}\n`; + // keepDiscriminatorProperty: preserve discriminator property in result object (PB-3). + // `=== true` matches the serialize side exactly (default drop) — symmetric, not a truthy check. + if (meta.type.keepDiscriminatorProperty === true) { + const fkJson = JSON.stringify(fieldKey); + code += `{var __dh=${GEN.out}[${fkJson}]; if(__dh!=null) __dh[${discProp}]=${GEN.disc}${sk};}\n`; } - - code += ` }\n`; - code += `} else { ${emitCtx.fail('isArray')}; }\n`; } else { - // Single nested object — arrays are objects by `typeof` but are not valid nested DTOs; - // reject them here (matching the deserialize path) instead of descending into their fields. - code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; - - if (useInline) { - const ppExpr = ctx.pathPrefix ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey + '.')}` : JSON.stringify(fieldKey + '.'); - const vpPrefix = `${sk}_`; - code += emitInlineNestedBlock(nestedMerged!, nestedCls, varName, ppExpr, vpPrefix, ctx); + // simple nested or each array + const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); + const nestedSealed = this.resolveExecutor(nestedCls); + const execIdx = execs.length; + execs.push(nestedSealed); + + // Check if validateNested each (array) — meta.type is already proven non-null above + const hasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); + + if (hasEach) { + const iVar = `${GEN.index}${sk}`; + const awaitKwE = this.isAsync ? 'await ' : ''; + code += `if (Array.isArray(${varName})) {\n`; + + // Emit non-each array-level validation rules (e.g. @ArrayMinSize, @ArrayMaxSize) + const nonEachRules = meta.validation.filter(rd => !rd.each); + code += this.emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ' '); + + code += ` var ${GEN.arr}${sk} = [];\n`; + code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; + code += ` var ${GEN.result}${sk} = ${awaitKwE}execs[${execIdx}].deserialize(${varName}[${iVar}], opts);\n`; + code += generateNestedEachResultCode( + `${GEN.result}${sk}`, + `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`, + sk, + collectErrors, + `${GEN.arr}${sk}.push(${GEN.result}${sk});`, + ' ', + ); + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + code += `} else { ${emitCtx.fail('isArray')}; }\n`; } else { - const execIdx = execs.length; - execs.push(nestedSealed); - const awaitKw = ctx.isAsync ? 'await ' : ''; - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}, opts);\n`; - code += generateValidateNestedResult(fieldKey, `${GEN.result}${sk}`, collectErrors, ctx.pathPrefix); + const awaitKwS = this.isAsync ? 'await ' : ''; + code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; + code += ` var ${GEN.result}${sk} = ${awaitKwS}execs[${execIdx}].deserialize(${varName}, opts);\n`; + code += generateNestedResultCode(fieldKey, `${GEN.result}${sk}`, collectErrors, this.pathPrefix); + code += `} else { ${emitCtx.fail('isObject')}; }\n`; } - - code += `} else { ${emitCtx.fail('isObject')}; }\n`; } - } - return code; -} -/** Generate validate-mode nested result handling (null check instead of isErr) */ -function generateValidateNestedResult(fieldKey: string, resultVar: string, collectErrors: boolean, pathPrefix?: string): string { - const sk = sanitizeKey(fieldKey); - const ppVar = `__bk$pp${sk}`; - // Prepend the current scope's path prefix (see generateNestedResultCode). - const ppValue = pathPrefix ? `${pathPrefix}+${JSON.stringify(fieldKey + '.')}` : JSON.stringify(fieldKey + '.'); - if (collectErrors) { - const errItem = `${resultVar}[${GEN.nestedIdx}${sk}]`; - return ( - ` if (${resultVar} !== null) {\n` + - ` var ${ppVar} = ${ppValue};\n` + - ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${resultVar}.length; ${GEN.nestedIdx}${sk}++) {\n` + - ` ` + - nestedErrPush(GEN.errList, `${ppVar}+${errItem}.path`, errItem, `__ne${sk}`) + - ` }\n` + - ` }\n` - ); + return code; } - const errFirst = `${resultVar}[0]`; - return ( - ` if (${resultVar} !== null) {\n` + - ` var ${ppVar} = ${ppValue};\n` + - ` ` + - nestedErrReturn(`${ppVar}+${errFirst}.path`, errFirst, `__ne${sk}`, true) + - ` }\n` - ); -} -// ───────────────────────────────────────────────────────────────────────────── -// generateCollectionCodeValidateOnly — validate-only collection (no Set/Map creation) -// ───────────────────────────────────────────────────────────────────────────── + // ── generateNestedCodeValidateOnly — validate-only nested (inline when possible) ── + + // Inline-eligibility predicate: a nested DTO can be inlined unless it is already in the + // active inline-set (circular reference). Inlined directly at the three call sites below + // — no extra function call at seal time. + + /** + * Emit inline validation code for all fields of a nested DTO via a CHILD builder. + * The child shares the parent's reference arrays and inline-tracking set but overrides + * pathPrefix/varPrefix/inputExpr. + */ + private emitInlineNestedBlock( + nestedMerged: RawClassMeta, + nestedClass: Function, + inputExpr: string, + pathPrefixExpr: string, + varPrefix: string, + ): string { + const inlinedSet = this.inlineNestedClasses!; + inlinedSet.add(nestedClass); + + // Stamp a unique id into this block's varPrefix so every generated name in the child scope is + // globally unique — two nested scopes can never collide even if their field-name shapes would + // otherwise concatenate to the same prefix. + const child = this.createChild(pathPrefixExpr, `${varPrefix}${this.inlineCounter.n++}_`, inputExpr); + + let code = ''; + for (const [fieldKey, meta] of Object.entries(nestedMerged)) { + code += child.generateFieldCode(fieldKey, meta); + } -function generateCollectionCodeValidateOnly( - fieldKey: string, - varName: string, - meta: RawPropertyMeta, - ctx: FieldCodeContext, - emitCtx: EmitContext, -): string { - const { collectErrors, execs } = ctx; - const sk = (ctx.varPrefix || '') + sanitizeKey(fieldKey); - const collection = meta.type!.collection!; - const awaitKw = ctx.isAsync ? 'await ' : ''; - - if (!ctx.inlineNestedClasses) { - ctx.inlineNestedClasses = new Set(); + inlinedSet.delete(nestedClass); + return code; } - // Resolve nested DTO for collection values - let nestedCls: Function | undefined; - let nestedSealed: SealedExecutors | undefined; - let nestedMerged: RawClassMeta | undefined; - if (meta.type!.resolvedCollectionValue) { - nestedCls = meta.type!.resolvedCollectionValue; - nestedSealed = ctx.resolve(nestedCls) as SealedExecutors; - nestedMerged = nestedSealed.merged; + /** + * generateDiscriminatorEachCodeValidateOnly — validate an ARRAY of discriminated DTOs. The validate + * executor returns `null` on success or an error-array on failure (no Result wrapper), so each + * element's result is iterated directly. Element errors report `field[i].` / `field[i]` paths. + */ + private generateDiscriminatorEachCodeValidateOnly( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + sk: string, + ): string { + const { collectErrors, execs } = this; + const disc = meta.type!.discriminator!; + const discProp = JSON.stringify(disc.property); + const awaitKwD = this.isAsync ? 'await ' : ''; + const iVar = `${GEN.index}${sk}`; + const itemVar = `__bk$di${sk}`; + const discVar = `${GEN.disc}${sk}`; + const resVar = `${GEN.result}${sk}`; + const ppBase = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); + const elemPathPrefix = `${ppBase}+'['+${iVar}+'].'`; + const elemPath = `${ppBase}+'['+${iVar}+']'`; + const validNamesJson = JSON.stringify(disc.subTypes.map(s => s.name)); + + let code = `if (Array.isArray(${varName})) {\n`; + const nonEachRules = meta.validation.filter(rd => !rd.each); + code += this.emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ' '); + code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; + code += ` var ${itemVar} = ${varName}[${iVar}];\n`; + code += ` var ${discVar} = ${itemVar} && ${itemVar}[${discProp}];\n`; + code += ` switch (${discVar}) {\n`; + for (const sub of disc.subTypes) { + const subSealed = this.resolveExecutor(sub.value); + const execIdx = execs.length; + execs.push(subSealed); + code += ` case ${JSON.stringify(sub.name)}: {\n`; + code += ` var ${resVar} = ${awaitKwD}execs[${execIdx}].validate(${itemVar}, opts);\n`; + code += generateValidateNestedEachResultCode(resVar, elemPathPrefix, sk, collectErrors, ' '); + code += ` break;\n`; + code += ` }\n`; + } + const discCtx = `{path:${elemPath},code:'invalidDiscriminator',context:{received:${discVar},validSubTypes:${validNamesJson}}}`; + code += collectErrors ? ` default: ${GEN.errList}.push(${discCtx});\n` : ` default: return [${discCtx}];\n`; + code += ` }\n`; + code += ` }\n`; + code += `} else { ${emitCtx.fail('isArray')}; }\n`; + return code; } - const useInline = nestedCls && nestedMerged && !ctx.inlineNestedClasses.has(nestedCls); - let code = ''; + private generateNestedCodeValidateOnly(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { + const { collectErrors, execs } = this; + if (!meta.type) { + return ''; + } + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); + let code = ''; - if (collection === CollectionType.Set) { - code += `if (Array.isArray(${varName})) {\n`; - const nonEachRules = meta.validation.filter(rd => !rd.each); - code += emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ctx, ' '); - - if (nestedSealed) { - const iVar = `${GEN.index}${sk}`; - code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; - - if (useInline) { - // Cache per-iteration path prefix into a single local var — itemInvalidPathExpr was - // identical to ppExpr (two copies of the same 3-string concat in the emitted body). - const itemVar = `__il$${sk}ci`; - const ppVar = `__bk$pp${sk}`; - const ppInit = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` - : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; - const vpPrefix = `${sk}c_`; - code += ` var ${itemVar} = ${varName}[${iVar}];\n`; - code += ` var ${ppVar} = ${ppInit};\n`; - code += ` if (${itemVar} == null || typeof ${itemVar} !== 'object' || Array.isArray(${itemVar})) `; - if (collectErrors) { - code += `${GEN.errList}.push({path:${ppVar},code:'invalidInput'});\n`; + // Initialize inline tracking set if not present + if (!this.inlineNestedClasses) { + this.inlineNestedClasses = new Set(); + } + + if (meta.type.discriminator) { + // Array of discriminated DTOs — validate the switch per element (see generateNestedCode). + const discHasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); + if (discHasEach) { + return this.generateDiscriminatorEachCodeValidateOnly(fieldKey, varName, meta, emitCtx, sk); + } + // Discriminator — inline each subType's validation + const discProp = JSON.stringify(meta.type.discriminator.property); + code += `var ${GEN.disc}${sk} = ${varName} && ${varName}[${discProp}];\n`; + code += `switch (${GEN.disc}${sk}) {\n`; + for (const sub of meta.type.discriminator.subTypes) { + const subSealed = this.resolveExecutor(sub.value); + const subMerged = subSealed.merged; + const canInline = subMerged && !this.inlineNestedClasses.has(sub.value); + code += ` case ${JSON.stringify(sub.name)}:\n`; + if (canInline) { + const ppExpr = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey + '.')}` + : JSON.stringify(fieldKey + '.'); + const vpPrefix = `${sk}_d${sanitizeKey(sub.name)}_`; + code += this.emitInlineNestedBlock(subMerged!, sub.value, varName, ppExpr, vpPrefix); } else { - code += `return [{path:${ppVar},code:'invalidInput'}];\n`; + const execIdx = execs.length; + execs.push(subSealed); + const awaitKw = this.isAsync ? 'await ' : ''; + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}, opts);\n`; + code += generateValidateNestedResult(fieldKey, `${GEN.result}${sk}`, collectErrors, this.pathPrefix); } - code += ` else {\n`; - code += emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppVar, vpPrefix, ctx); - code += ` }\n`; + code += ` break;\n`; + } + const validSubTypeNamesJsonV = JSON.stringify(meta.type.discriminator.subTypes.map(s => s.name)); + const discPathExprV = emitCtx.pathExpr ?? JSON.stringify(fieldKey); + const discValueExprV = `${GEN.disc}${sk}`; + if (collectErrors) { + code += ` default: ${GEN.errList}.push({path:${discPathExprV},code:'invalidDiscriminator',context:{received:${discValueExprV},validSubTypes:${validSubTypeNamesJsonV}}});\n`; } else { - const execIdx = execs.length; - execs.push(nestedSealed); - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}[${iVar}], opts);\n`; - code += ` if (${GEN.result}${sk} !== null) {\n`; - const ppVar = `__bk$pp${sk}`; - const ppInit = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` - : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; - code += ` var ${ppVar} = ${ppInit};\n`; - if (collectErrors) { - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.result}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; - code += - ` ` + - nestedErrPush( - GEN.errList, - `${ppVar}+${GEN.result}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.result}${sk}[${GEN.nestedIdx}${sk}]`, - `__ne${sk}`, - ); - code += ` }\n`; + code += ` default: return [{path:${discPathExprV},code:'invalidDiscriminator',context:{received:${discValueExprV},validSubTypes:${validSubTypeNamesJsonV}}}];\n`; + } + code += `}\n`; + } else { + const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); + const nestedSealed = this.resolveExecutor(nestedCls); + const nestedMerged = nestedSealed.merged; + const hasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); + + // Decide: inline or function call + const useInline = nestedMerged && !this.inlineNestedClasses.has(nestedCls); + + if (hasEach) { + const iVar = `${GEN.index}${sk}`; + code += `if (Array.isArray(${varName})) {\n`; + const nonEachRules = meta.validation.filter(rd => !rd.each); + code += this.emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ' '); + + code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; + + if (useInline) { + // INLINE: generate validation code directly in the loop body. + // The per-iteration path prefix is emitted as an EXPRESSION (not a precomputed var) so it is + // built only at the cold error-push sites — the happy path allocates no path string per + // element (measured ~4x faster on large valid arrays). + const itemVar = `__il$${sk}item`; + const ppExpr = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` + : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; + const vpPrefix = `${sk}i_`; + + code += ` var ${itemVar} = ${varName}[${iVar}];\n`; + code += ` if (${itemVar} == null || typeof ${itemVar} !== 'object' || Array.isArray(${itemVar})) `; + if (collectErrors) { + code += `${GEN.errList}.push({path:${ppExpr},code:'invalidInput'});\n`; + } else { + code += `return [{path:${ppExpr},code:'invalidInput'}];\n`; + } + code += ` else {\n`; + code += this.emitInlineNestedBlock(nestedMerged!, nestedCls, itemVar, ppExpr, vpPrefix); + code += ` }\n`; } else { - code += ` ` + nestedErrReturn(`${ppVar}+${GEN.result}${sk}[0].path`, `${GEN.result}${sk}[0]`, `__ne${sk}`, true); + // FALLBACK: function call to validate + const execIdx = execs.length; + execs.push(nestedSealed); + const awaitKw = this.isAsync ? 'await ' : ''; + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}[${iVar}], opts);\n`; + const ppInit = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` + : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; + code += generateValidateNestedEachResultCode(`${GEN.result}${sk}`, ppInit, sk, collectErrors, ' '); } - code += ` }\n`; + + code += ` }\n`; + code += `} else { ${emitCtx.fail('isArray')}; }\n`; + } else { + // Single nested object — arrays are objects by `typeof` but are not valid nested DTOs; + // reject them here (matching the deserialize path) instead of descending into their fields. + code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; + + if (useInline) { + const ppExpr = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey + '.')}` + : JSON.stringify(fieldKey + '.'); + const vpPrefix = `${sk}_`; + code += this.emitInlineNestedBlock(nestedMerged!, nestedCls, varName, ppExpr, vpPrefix); + } else { + const execIdx = execs.length; + execs.push(nestedSealed); + const awaitKw = this.isAsync ? 'await ' : ''; + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}, opts);\n`; + code += generateValidateNestedResult(fieldKey, `${GEN.result}${sk}`, collectErrors, this.pathPrefix); + } + + code += `} else { ${emitCtx.fail('isObject')}; }\n`; } + } + return code; + } - code += ` }\n`; + // ── generateCollectionCodeValidateOnly — validate-only collection (no Set/Map creation) ── + + private generateCollectionCodeValidateOnly( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + fieldGroups: string[] | undefined, + ): string { + const { collectErrors, execs } = this; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); + const type = meta.type!; + const collection = type.collection!; + const awaitKw = this.isAsync ? 'await ' : ''; + + if (!this.inlineNestedClasses) { + this.inlineNestedClasses = new Set(); } - // each validation — iterate input array directly - const eachRules = meta.validation.filter(rd => rd.each); - if (eachRules.length > 0) { - const eiVar = `${GEN.index}${sk}e`; - code += ` for (var ${eiVar}=0; ${eiVar}<${varName}.length; ${eiVar}++) {\n`; - for (const rd of eachRules) { - const prefixVar = `__bk$ep_${sk}`; - const extra = computeRuleExtras(rd, fieldKey, varName, ctx); - const failFn = (c: string) => - collectErrors - ? `${GEN.errList}.push({path:${prefixVar}+${eiVar}+']',code:${JSON.stringify(c)}${extra}})` - : `return [{path:${prefixVar}+${eiVar}+']',code:${JSON.stringify(c)}${extra}}]`; - const colEmitCtx: EmitContext = { ...emitCtx, fail: failFn }; - if (!code.includes(`var ${prefixVar}`)) { - const prefixInit = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['` - : `${JSON.stringify(fieldKey)}+'['`; - code += ` var ${prefixVar} = ${prefixInit};\n`; - } - code += ` ${rd.rule.emit(`${varName}[${eiVar}]`, colEmitCtx)}\n`; - } - code += ` }\n`; + // Resolve nested DTO for collection values + let nestedCls: Function | undefined; + let nestedSealed: SealedExecutors | undefined; + let nestedMerged: RawClassMeta | undefined; + if (type.resolvedCollectionValue) { + nestedCls = type.resolvedCollectionValue; + nestedSealed = this.resolveExecutor(nestedCls); + nestedMerged = nestedSealed.merged; } + const useInline = nestedCls && nestedMerged && !this.inlineNestedClasses.has(nestedCls); - code += `} else { ${emitCtx.fail('isArray')}; }\n`; - } else { - // Map: validate object values - code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; - - if (nestedSealed) { - const kVar = `${GEN.key}${sk}`; - const ksVar = `__bk$vk${sk}`; - const iVar = `__bk$vi${sk}`; - code += ` var ${ksVar} = Object.keys(${varName});\n`; - code += ` for (var ${iVar}=0; ${iVar}<${ksVar}.length; ${iVar}++) {\n`; - code += ` var ${kVar} = ${ksVar}[${iVar}];\n`; - - if (useInline) { - const itemVar = `__il$${sk}mi`; - const ppExpr = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${kVar}+'].'` - : `${JSON.stringify(fieldKey)}+'['+${kVar}+'].'`; - const vpPrefix = `${sk}m_`; - const itemInvalidPathExpr = ppExpr; - code += ` var ${itemVar} = ${varName}[${kVar}];\n`; - code += ` if (${itemVar} == null || typeof ${itemVar} !== 'object' || Array.isArray(${itemVar})) `; - if (collectErrors) { - code += `${GEN.errList}.push({path:${itemInvalidPathExpr},code:'invalidInput'});\n`; + let code = ''; + + if (collection === CollectionType.Set) { + code += `if (Array.isArray(${varName})) {\n`; + const nonEachRules = meta.validation.filter(rd => !rd.each); + code += this.emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ' '); + + if (nestedSealed) { + const iVar = `${GEN.index}${sk}`; + code += ` for (var ${iVar}=0; ${iVar}<${varName}.length; ${iVar}++) {\n`; + + if (useInline) { + // Per-iteration path prefix is emitted as an EXPRESSION (not a precomputed var) so it is built + // only at the cold error-push sites — the happy path allocates no path string per element. + const itemVar = `__il$${sk}ci`; + const ppExpr = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` + : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; + const vpPrefix = `${sk}c_`; + code += ` var ${itemVar} = ${varName}[${iVar}];\n`; + code += ` if (${itemVar} == null || typeof ${itemVar} !== 'object' || Array.isArray(${itemVar})) `; + if (collectErrors) { + code += `${GEN.errList}.push({path:${ppExpr},code:'invalidInput'});\n`; + } else { + code += `return [{path:${ppExpr},code:'invalidInput'}];\n`; + } + code += ` else {\n`; + code += this.emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppExpr, vpPrefix); + code += ` }\n`; } else { - code += `return [{path:${itemInvalidPathExpr},code:'invalidInput'}];\n`; + const execIdx = execs.length; + execs.push(nestedSealed); + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}[${iVar}], opts);\n`; + const ppInit = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${iVar}+'].'` + : `${JSON.stringify(fieldKey)}+'['+${iVar}+'].'`; + code += generateValidateNestedEachResultCode(`${GEN.result}${sk}`, ppInit, sk, collectErrors, ' '); } - code += ` else {\n`; - code += emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppExpr, vpPrefix, ctx); - code += ` }\n`; - } else { - const execIdx = execs.length; - execs.push(nestedSealed); - code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}[${kVar}], opts);\n`; - code += ` if (${GEN.result}${sk} !== null) {\n`; - const ppVar = `__bk$pp${sk}`; - const ppInit = ctx.pathPrefix - ? `${ctx.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${kVar}+'].'` - : `${JSON.stringify(fieldKey)}+'['+${kVar}+'].'`; - code += ` var ${ppVar} = ${ppInit};\n`; - if (collectErrors) { - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.result}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; - code += - ` ` + - nestedErrPush( - GEN.errList, - `${ppVar}+${GEN.result}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.result}${sk}[${GEN.nestedIdx}${sk}]`, - `__ne${sk}`, - ); - code += ` }\n`; + + code += ` }\n`; + } + + // each validation — iterate the input array directly + const eachRules = meta.validation.filter(rd => rd.each); + code += this.emitDeclaredEachRules(fieldKey, eachRules, varName, sk, emitCtx, fieldGroups, ' '); + + code += `} else { ${emitCtx.fail('isArray')}; }\n`; + } else { + // Map: validate object values + code += `if (${varName} != null && typeof ${varName} === 'object' && !Array.isArray(${varName})) {\n`; + + if (nestedSealed) { + const kVar = `${GEN.key}${sk}`; + const ksVar = `__bk$vk${sk}`; + const iVar = `__bk$vi${sk}`; + code += ` var ${ksVar} = Object.keys(${varName});\n`; + code += ` for (var ${iVar}=0; ${iVar}<${ksVar}.length; ${iVar}++) {\n`; + code += ` var ${kVar} = ${ksVar}[${iVar}];\n`; + + if (useInline) { + const itemVar = `__il$${sk}mi`; + const ppExpr = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${kVar}+'].'` + : `${JSON.stringify(fieldKey)}+'['+${kVar}+'].'`; + const vpPrefix = `${sk}m_`; + const itemInvalidPathExpr = ppExpr; + code += ` var ${itemVar} = ${varName}[${kVar}];\n`; + code += ` if (${itemVar} == null || typeof ${itemVar} !== 'object' || Array.isArray(${itemVar})) `; + if (collectErrors) { + code += `${GEN.errList}.push({path:${itemInvalidPathExpr},code:'invalidInput'});\n`; + } else { + code += `return [{path:${itemInvalidPathExpr},code:'invalidInput'}];\n`; + } + code += ` else {\n`; + code += this.emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppExpr, vpPrefix); + code += ` }\n`; } else { - code += ` ` + nestedErrReturn(`${ppVar}+${GEN.result}${sk}[0].path`, `${GEN.result}${sk}[0]`, `__ne${sk}`, true); + const execIdx = execs.length; + execs.push(nestedSealed); + code += ` var ${GEN.result}${sk} = ${awaitKw}execs[${execIdx}].validate(${varName}[${kVar}], opts);\n`; + const ppInit = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['+${kVar}+'].'` + : `${JSON.stringify(fieldKey)}+'['+${kVar}+'].'`; + code += generateValidateNestedEachResultCode(`${GEN.result}${sk}`, ppInit, sk, collectErrors, ' '); } - code += ` }\n`; + + code += ` }\n`; } - code += ` }\n`; + // each validation rules (per value) — iterate the input object's values + const eachRules = meta.validation.filter(rd => rd.each); + code += this.emitDeclaredEachRules(fieldKey, eachRules, `Object.values(${varName})`, sk, emitCtx, fieldGroups, ' '); + + code += `} else { ${emitCtx.fail('isObject')}; }\n`; } - code += `} else { ${emitCtx.fail('isObject')}; }\n`; + return code; } - return code; + // ── makeEmitCtx — create per-field EmitContext ── + + private makeEmitCtx(fieldKey: string, fieldExtras = ''): EmitContext { + const { collectErrors, regexes, refs, execs, validateOnly, pathPrefix } = this; + const pathExpr = pathPrefix ? `${pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); + return { + addRegex(re: RegExp): number { + regexes.push(re); + return regexes.length - 1; + }, + addRef(fn: unknown): number { + refs.push(fn); + return refs.length - 1; + }, + addExecutor(executor: SealedExecutors): number { + execs.push(executor); + return execs.length - 1; + }, + fail(code: string): string { + if (collectErrors) { + return `${GEN.errList}.push({path:${pathExpr},code:${JSON.stringify(code)}${fieldExtras}})`; + } else if (validateOnly) { + return `return [{path:${pathExpr},code:${JSON.stringify(code)}${fieldExtras}}]`; + } + return `return err([{path:${pathExpr},code:${JSON.stringify(code)}${fieldExtras}}])`; + }, + collectErrors, + pathExpr: pathExpr, + }; + } } // ───────────────────────────────────────────────────────────────────────────── -// makeEmitCtx — create per-field EmitContext +// Exported entry functions — thin wrappers over DeserializeBuilder (signatures unchanged) // ───────────────────────────────────────────────────────────────────────────── -function makeEmitCtx(fieldKey: string, ctx: FieldCodeContext, fieldExtras = ''): EmitContext { - const { collectErrors, regexes, refs, execs, validateOnly, pathPrefix } = ctx; - const pathExpr = pathPrefix ? `${pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); - return { - addRegex(re: RegExp): number { - regexes.push(re); - return regexes.length - 1; - }, - addRef(fn: unknown): number { - refs.push(fn); - return refs.length - 1; - }, - addExecutor(executor: SealedExecutors): number { - execs.push(executor); - return execs.length - 1; - }, - fail(code: string): string { - if (collectErrors) { - return `${GEN.errList}.push({path:${pathExpr},code:${JSON.stringify(code)}${fieldExtras}})`; - } else if (validateOnly) { - return `return [{path:${pathExpr},code:${JSON.stringify(code)}${fieldExtras}}]`; - } - return `return err([{path:${pathExpr},code:${JSON.stringify(code)}${fieldExtras}}])`; - }, - collectErrors, - pathExpr: pathExpr, - }; +function buildDeserializeCode( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + needsCircularCheck: boolean, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, +): DeserializeExecutor; +function buildDeserializeCode( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + needsCircularCheck: boolean, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, + validateOnly: true, +): ValidateExecutor; +function buildDeserializeCode( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + needsCircularCheck: boolean, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, + validateOnly = false, +): DeserializeExecutor | ValidateExecutor { + return new DeserializeBuilder(Class, merged, options, needsCircularCheck, isAsync, resolve, validateOnly).build(); } + +function buildValidateCode( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + needsCircularCheck: boolean, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, +): ValidateExecutor { + return buildDeserializeCode(Class, merged, options, needsCircularCheck, isAsync, resolve, true); +} + export { buildDeserializeCode, buildValidateCode }; diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts new file mode 100644 index 0000000..90ef4ec --- /dev/null +++ b/src/seal/deserialize-codegen.ts @@ -0,0 +1,368 @@ +import type { RawPropertyMeta, RuleDef } from '../metadata'; +import type { EmitContext } from '../rules'; +import type { CategorizedRules } from './interfaces'; + +import { BakerError } from '../common'; +import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; +import { DES_GEN as GEN } from './constants'; +import { GuardKey } from './enums'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers — code generation utilities (pure, module-level) +// ───────────────────────────────────────────────────────────────────────────── + +/** Generate nested error push code that propagates message/context fields */ +export function nestedErrPush(errList: string, pathExpr: string, errItemExpr: string, tmpVar: string): string { + // Cache errItemExpr once — avoids repeated property reads in the generated body + const eVar = `${tmpVar}_e`; + return ( + `var ${eVar}=${errItemExpr};\n` + + ` if(${eVar}.message===undefined&&${eVar}.context===undefined){${errList}.push({path:${pathExpr},code:${eVar}.code});}\n` + + ` else{var ${tmpVar}={path:${pathExpr},code:${eVar}.code};\n` + + ` if(${eVar}.message!==undefined)${tmpVar}.message=${eVar}.message;\n` + + ` if(${eVar}.context!==undefined)${tmpVar}.context=${eVar}.context;\n` + + ` ${errList}.push(${tmpVar});}\n` + ); +} + +/** Generate nested error return code that propagates message/context fields */ +export function nestedErrReturn(pathExpr: string, errItemExpr: string, tmpVar: string, validateOnly?: boolean): string { + const ret = (arr: string) => (validateOnly ? `return ${arr};\n` : `return err(${arr});\n`); + // Cache errItemExpr once — mirrors nestedErrPush, avoids repeated property reads in the generated body. + const eVar = `${tmpVar}_e`; + return ( + `var ${eVar}=${errItemExpr};\n` + + ` if(${eVar}.message===undefined&&${eVar}.context===undefined)${ret(`[{path:${pathExpr},code:${eVar}.code}]`)}` + + ` var ${tmpVar}={path:${pathExpr},code:${eVar}.code};\n` + + ` if(${eVar}.message!==undefined)${tmpVar}.message=${eVar}.message;\n` + + ` if(${eVar}.context!==undefined)${tmpVar}.context=${eVar}.context;\n` + + ` ${ret(`[${tmpVar}]`)}` + ); +} + +/** Convert field name to a safe JS variable name (includes prefix to prevent internal variable collisions) */ +export function toVarName(key: string, prefix?: string): string { + return GEN.field + (prefix || '') + sanitizeKey(key); +} + +// Field rename + expose-group resolution (both directions) live in codegen-utils as the single +// source of truth — see resolveExposeName / resolveExposeGroups. + +// ───────────────────────────────────────────────────────────────────────────── +// nullable/optional guard — truth-table strategy pattern (D-3) +// ───────────────────────────────────────────────────────────────────────────── + +export function resolveGuardKey(isNullable: boolean, useOptionalGuard: boolean): GuardKey { + if (isNullable && useOptionalGuard) { + return GuardKey.NullableOptional; + } + if (isNullable) { + return GuardKey.Nullable; + } + if (useOptionalGuard) { + return GuardKey.Optional; + } + return GuardKey.Default; +} + +// GuardParams and TypeGateConfig stay in this internal (non-barrel) module rather than seal/interfaces.ts: +// both reference rules' EmitContext, and seal/interfaces.ts is imported by rules/interfaces.ts (for +// EmitContext.addExecutor → SealedExecutors), so housing them in the barrel-exported file would close a +// rules ↔ seal cycle. The rules-free codegen types (CategorizedRules/ResolvedTypeGate) live in interfaces.ts. +export interface GuardParams { + varName: string; + emitCtx: EmitContext; + assignNull: string; + validationCode: string; +} + +export const GUARD_STRATEGIES: Record string> = { + // Case 4: nullable + optional — assign null, skip undefined + [GuardKey.NullableOptional]({ varName, assignNull, validationCode }) { + let code = `if (${varName} === null) { ${assignNull}}\n`; + code += `else if (${varName} !== undefined) {\n`; + code += validationCode; + code += '}\n'; + return code; + }, + // Case 3: nullable — reject undefined, assign and accept null + [GuardKey.Nullable]({ varName, emitCtx, assignNull, validationCode }) { + let code = `if (${varName} === undefined) ${emitCtx.fail('isDefined')};\n`; + code += `else if (${varName} !== null) {\n`; + code += validationCode; + code += `} else { ${assignNull}}\n`; + return code; + }, + // Case 2: optional — skip entirely on undefined/null + [GuardKey.Optional]({ varName, validationCode }) { + let code = `if (${varName} !== undefined && ${varName} !== null) {\n`; + code += validationCode; + code += '}\n'; + return code; + }, + // Case 1: no flags (default) — reject undefined/null + [GuardKey.Default]({ varName, emitCtx, validationCode }) { + let code = `if (${varName} === undefined || ${varName} === null) ${emitCtx.fail('isDefined')};\n`; + code += `else {\n`; + code += validationCode; + code += '}\n'; + return code; + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// wrapGroupsGuard — per-rule validation groups check wrapper +// ───────────────────────────────────────────────────────────────────────────── + +/** + * When rd.groups is set, only execute code if there is an intersection with runtime __bk$groups. + * Rules without groups always execute (preserves existing behavior). + */ +export function wrapGroupsGuard(rd: RuleDef, code: string): string { + if (!rd.groups || rd.groups.length === 0) { + return code; + } + return `if ((${GEN.group0} === null && !${GEN.groupsSet}) || ${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, rd.groups)}) {\n${code}\n}\n`; +} + +export function sameGroups(a?: string[], b?: string[]): boolean { + if (!a || a.length === 0) { + return !b || b.length === 0; + } + if (!b || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// generateConversionCode — enableImplicitConversion conversion code generation +// ───────────────────────────────────────────────────────────────────────────── + +export function generateConversionCode( + targetType: string, + varName: string, + fieldKey: string, + skipVar: string | null, // null = stopAtFirstError + collectErrors: boolean, + emitCtx: EmitContext, +): string { + const failCode = collectErrors + ? `${emitCtx.fail('conversionFailed')}; ${skipVar} = true;` + : emitCtx.fail('conversionFailed') + ';'; + + switch (targetType) { + case 'string': + return ` ${varName} = String(${varName});\n`; + case 'number': + return ` ${varName} = Number(${varName});\n if (isNaN(${varName})) { ${failCode} }\n`; + case 'boolean': + return ( + ` if (${varName} === 'true' || ${varName} === '1' || ${varName} === 1) ${varName} = true;\n` + + ` else if (${varName} === 'false' || ${varName} === '0' || ${varName} === 0) ${varName} = false;\n` + + ` else { ${failCode} }\n` + ); + case 'date': + return ` ${varName} = new Date(${varName});\n if (isNaN(${varName}.getTime())) { ${failCode} }\n`; + default: + throw new BakerError(`Unknown implicit conversion type: "${targetType}" for field "${fieldKey}"`); + } +} + +/** Result of categorizeRules — each/nonEach split and typed dependency classification */ +/** categorizeRules — separate each/nonEach rules, detect mixed gate conflicts (pure) */ +export function categorizeRules(fieldKey: string, validation: RawPropertyMeta['validation']): CategorizedRules { + // Single-pass partition — was 9 separate .filter() passes over the same array, each allocating + // a fresh intermediate. For a field with N rules, runs at seal time only but adds up across DTOs. + const each: RuleDef[] = []; + const generalRules: RuleDef[] = []; + const typedBuckets: Record = { + string: [], + number: [], + boolean: [], + date: [], + array: [], + object: [], + }; + for (const rd of validation) { + if (rd.each) { + each.push(rd); + continue; + } + const reqType = rd.rule.requiresType; + if (reqType !== undefined) { + typedBuckets[reqType]!.push(rd); + } else { + generalRules.push(rd); + } + } + + // Mixed gate conflict detection — at most one bucket should be non-empty + let chosen: CategorizedRules['typedDeps'] = undefined; + let activeTypes: string[] | null = null; + for (const t of ['string', 'number', 'boolean', 'date', 'array', 'object'] as const) { + const deps = typedBuckets[t]!; + if (deps.length === 0) { + continue; + } + if (chosen) { + // Late allocation: only build the array when we actually need to report a conflict + if (activeTypes === null) { + activeTypes = [chosen.type]; + } + activeTypes.push(t); + } else { + chosen = { type: t, deps }; + } + } + if (activeTypes) { + throw new BakerError(`Field "${fieldKey}" has conflicting requiresType: ${activeTypes.join(', ')}`); + } + + return { each, generalRules, typedDeps: chosen }; +} + +/** Config object for emitTypedRules — bundles closure-captured vars into explicit parameter */ +export interface TypeGateConfig { + effectiveGateType: string; + gateCondition: string; + gateErrorCode: string; + gateEmitCtx: EmitContext; + otherGeneral: RuleDef[]; + gateDeps: RuleDef[]; + typeAsserter: RuleDef | undefined; + enableConversion: boolean; +} + +/** Generate nested-result handling for deserialize mode (pure) */ +export function generateNestedResultCode( + fieldKey: string, + resultVar: string, + collectErrors: boolean, + pathPrefix?: string, +): string { + const sk = sanitizeKey(fieldKey); + // Prepend the current scope's path prefix so an executor reached from inside an inlined block + // (e.g. a circular nested DTO) keeps the full path, not just `fieldKey.`. + const ppValue = pathPrefix ? `${pathPrefix}+${JSON.stringify(fieldKey + '.')}` : JSON.stringify(fieldKey + '.'); + if (collectErrors) { + const errItem = `${GEN.errors}${sk}[${GEN.nestedIdx}${sk}]`; + return ( + ` if (isErr(${resultVar})) {\n` + + ` var ${GEN.errors}${sk} = ${resultVar}.data;\n` + + ` var __bk$pp${sk} = ${ppValue};\n` + + ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.errors}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n` + + ` ` + + nestedErrPush(GEN.errList, `__bk$pp${sk}+${errItem}.path`, errItem, `__ne${sk}`) + + ` }\n` + + ` } else { ${GEN.out}[${JSON.stringify(fieldKey)}] = ${resultVar}; }\n` + ); + } + const errFirst = `${GEN.errors}${sk}[0]`; + return ( + ` if (isErr(${resultVar})) {\n` + + ` var ${GEN.errors}${sk} = ${resultVar}.data;\n` + + ` var __bk$pp${sk} = ${ppValue};\n` + + ` ` + + nestedErrReturn(`__bk$pp${sk}+${errFirst}.path`, errFirst, `__ne${sk}`) + + ` } else { ${GEN.out}[${JSON.stringify(fieldKey)}] = ${resultVar}; }\n` + ); +} + +/** + * Nested-executor result handling inside a per-element loop (Set / Map / array / discriminator-each). + * Single source for the `if (isErr(result)) { …re-path nested errors… } else { }` block that + * every collection loop repeats — only the element path expression (`ppExpr`), the success statement + * (`arr.push` / `map.set` / `set.add`), and the base indent differ. The single-object case keeps using + * {@link generateNestedResultCode} (it writes straight to `out[field]`). + */ +export function generateNestedEachResultCode( + resultVar: string, + ppExpr: string, + sk: string, + collectErrors: boolean, + successStmt: string, + indent: string, +): string { + const errs = `${GEN.errors}${sk}`; + const ppVar = `__bk$pp${sk}`; + const decls = `${indent} var ${errs} = ${resultVar}.data;\n${indent} var ${ppVar} = ${ppExpr};\n`; + let inner: string; + if (collectErrors) { + const ni = `${GEN.nestedIdx}${sk}`; + inner = + `${indent} for (var ${ni}=0; ${ni}<${errs}.length; ${ni}++) {\n` + + `${indent} ` + + nestedErrPush(GEN.errList, `${ppVar}+${errs}[${ni}].path`, `${errs}[${ni}]`, `__ne${sk}`) + + `${indent} }\n`; + } else { + inner = `${indent} ` + nestedErrReturn(`${ppVar}+${errs}[0].path`, `${errs}[0]`, `__ne${sk}`); + } + return `${indent}if (isErr(${resultVar})) {\n${decls}${inner}${indent}} else { ${successStmt} }\n`; +} + +/** Generate validate-mode nested result handling (null check instead of isErr) (pure) */ +export function generateValidateNestedResult( + fieldKey: string, + resultVar: string, + collectErrors: boolean, + pathPrefix?: string, +): string { + const sk = sanitizeKey(fieldKey); + const ppVar = `__bk$pp${sk}`; + // Prepend the current scope's path prefix (see generateNestedResultCode). + const ppValue = pathPrefix ? `${pathPrefix}+${JSON.stringify(fieldKey + '.')}` : JSON.stringify(fieldKey + '.'); + if (collectErrors) { + const errItem = `${resultVar}[${GEN.nestedIdx}${sk}]`; + return ( + ` if (${resultVar} !== null) {\n` + + ` var ${ppVar} = ${ppValue};\n` + + ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${resultVar}.length; ${GEN.nestedIdx}${sk}++) {\n` + + ` ` + + nestedErrPush(GEN.errList, `${ppVar}+${errItem}.path`, errItem, `__ne${sk}`) + + ` }\n` + + ` }\n` + ); + } + const errFirst = `${resultVar}[0]`; + return ( + ` if (${resultVar} !== null) {\n` + + ` var ${ppVar} = ${ppValue};\n` + + ` ` + + nestedErrReturn(`${ppVar}+${errFirst}.path`, errFirst, `__ne${sk}`, true) + + ` }\n` + ); +} + +/** + * Validate-mode counterpart of {@link generateNestedEachResultCode}: the per-element `if (result !== + * null) { …re-path the returned issue array… }` block shared by the Set / Map / array / discriminator + * validate-each loops. The element path expression and base indent are the only per-site differences. + */ +export function generateValidateNestedEachResultCode( + resultVar: string, + ppExpr: string, + sk: string, + collectErrors: boolean, + indent: string, +): string { + const ppVar = `__bk$pp${sk}`; + let code = `${indent}if (${resultVar} !== null) {\n${indent} var ${ppVar} = ${ppExpr};\n`; + if (collectErrors) { + const ni = `${GEN.nestedIdx}${sk}`; + code += + `${indent} for (var ${ni}=0; ${ni}<${resultVar}.length; ${ni}++) {\n` + + `${indent} ` + + nestedErrPush(GEN.errList, `${ppVar}+${resultVar}[${ni}].path`, `${resultVar}[${ni}]`, `__ne${sk}`) + + `${indent} }\n`; + } else { + code += `${indent} ` + nestedErrReturn(`${ppVar}+${resultVar}[0].path`, `${resultVar}[0]`, `__ne${sk}`, true); + } + code += `${indent}}\n`; + return code; +} diff --git a/src/seal/enums.ts b/src/seal/enums.ts index 0c7c94d..fa88392 100644 --- a/src/seal/enums.ts +++ b/src/seal/enums.ts @@ -3,11 +3,10 @@ // String-valued so generated-code branching stays identical. // ───────────────────────────────────────────────────────────────────────────── -/** Null/undefined guard strategy selected per field from its optional/nullable/defined flags. */ +/** Null/undefined guard strategy selected per field from its optional/nullable flags. */ export enum GuardKey { NullableOptional = 'nullable+optional', Nullable = 'nullable', - Defined = 'defined', Optional = 'optional', Default = 'default', } diff --git a/src/seal/expose-validator.spec.ts b/src/seal/expose-validator.spec.ts index 33e06d4..799de58 100644 --- a/src/seal/expose-validator.spec.ts +++ b/src/seal/expose-validator.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'bun:test'; -import type { RawClassMeta } from '../types'; +import type { RawClassMeta } from '../metadata/interfaces'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { validateExposeStacks } from './expose-validator'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/expose-validator.ts b/src/seal/expose-validator.ts index 698455d..28ca19c 100644 --- a/src/seal/expose-validator.ts +++ b/src/seal/expose-validator.ts @@ -1,10 +1,10 @@ -import type { RawClassMeta, ExposeDef } from '../types'; +import type { RawClassMeta, ExposeDef } from '../metadata'; -import { Direction } from '../enums'; -import { BakerError } from '../errors'; +import { Direction, BakerError } from '../common'; +import { RESERVED_PROPERTY_NAMES } from './constants'; /** - * Static validation of @Expose stacks (§4.1, §3.3) + * Static validation of @Expose stacks * * Check 1: same @Expose entry has deserializeOnly: true + serializeOnly: true → excluded from both directions * Check 2: if 2+ @Expose entries in the same direction have overlapping groups → BakerError @@ -24,9 +24,9 @@ function validateExposeStacks(merged: RawClassMeta, className?: string): void { } // Reserved output keys would corrupt the serialized object (e.g. a '__proto__' key sets the // prototype instead of an own property) — reject them as wire names, matching banned field names. - if (exp.name === '__proto__' || exp.name === 'constructor' || exp.name === 'prototype') { + if (exp.name !== undefined && RESERVED_PROPERTY_NAMES.has(exp.name)) { throw new BakerError( - `Invalid @Field name on '${prefix}${key}': '${exp.name}' is a reserved property name and cannot be used as a serialized key.`, + `Invalid @Expose name on '${prefix}${key}': '${exp.name}' is a reserved property name and cannot be used as a serialized key.`, ); } } @@ -52,7 +52,7 @@ function checkDirectionOverlap(key: string, entries: ExposeDef[], direction: Dir const bGroups = entries[j]!.groups ?? []; if (groupsOverlap(aGroups, bGroups)) { const bSet = new Set(bGroups); - const overlapping = aGroups.length === 0 ? [] : aGroups.filter(g => bSet.has(g)); + const overlapping = aGroups.filter(g => bSet.has(g)); throw new BakerError( `@Expose conflict on '${key}': 2 @Expose stacks with '${direction}' direction and overlapping groups [${overlapping.join(', ')}]. Each direction must have at most one @Expose per group set.`, ); diff --git a/src/seal/index.ts b/src/seal/index.ts new file mode 100644 index 0000000..6327c40 --- /dev/null +++ b/src/seal/index.ts @@ -0,0 +1,4 @@ +// Directory barrel — the compile stage's output, options, and entry point. +export type { SealedExecutors, SealOptions } from './interfaces'; +export { sealRegistry } from './seal'; +export { SEAL_OPTION_KEYS } from './constants'; diff --git a/src/seal/inheritance-merger.spec.ts b/src/seal/inheritance-merger.spec.ts new file mode 100644 index 0000000..3bb2927 --- /dev/null +++ b/src/seal/inheritance-merger.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'bun:test'; + +import type { RawPropertyMeta } from '../metadata'; + +import { metaStore } from '../metadata'; +import { isString, isInt } from '../rules/typechecker'; +import { InheritanceMerger } from './inheritance-merger'; + +const merger = new InheritanceMerger(metaStore); + +function prop(over: Partial = {}): RawPropertyMeta { + return { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {}, ...over }; +} + +describe('InheritanceMerger', () => { + it('returns a class own metadata when there is no decorated parent', () => { + class A {} + metaStore.set(A, { name: prop({ validation: [{ rule: isString }] }) }); + const merged = merger.merge(A); + expect(Object.keys(merged)).toEqual(['name']); + expect(merged.name!.validation).toHaveLength(1); + }); + + it('union-merges validation rules across the prototype chain', () => { + class Base {} + metaStore.set(Base, { name: prop({ validation: [{ rule: isString }] }) }); + class Child extends Base {} + metaStore.set(Child, { name: prop({ validation: [{ rule: isInt }] }) }); + const names = merger.merge(Child).name!.validation.map(rd => rd.rule.ruleName); + expect(names).toContain('isString'); + expect(names).toContain('isInt'); + }); + + it('does not duplicate a rule with the same ruleName (child wins)', () => { + class Base {} + metaStore.set(Base, { name: prop({ validation: [{ rule: isString }] }) }); + class Child extends Base {} + const childRule = { rule: isString }; + metaStore.set(Child, { name: prop({ validation: [childRule] }) }); + const merged = merger.merge(Child); + expect(merged.name!.validation).toHaveLength(1); + expect(merged.name!.validation[0]).toBe(childRule); + }); + + it('inherits transform/exclude/type from the parent when absent in the child', () => { + class Base {} + metaStore.set(Base, { + f: prop({ transform: [{ fn: v => v }], exclude: { serializeOnly: true }, type: { fn: () => class {} } }), + }); + class Child extends Base {} + metaStore.set(Child, { f: prop({ validation: [{ rule: isString }] }) }); + const merged = merger.merge(Child); + expect(merged.f!.transform).toHaveLength(1); + expect(merged.f!.exclude).toEqual({ serializeOnly: true }); + expect(merged.f!.type).not.toBeNull(); + }); + + it('keeps the child transform when present (child priority)', () => { + const childFn = (v: unknown): unknown => v; + class Base {} + metaStore.set(Base, { f: prop({ transform: [{ fn: v => v }] }) }); + class Child extends Base {} + metaStore.set(Child, { f: prop({ transform: [{ fn: childFn }] }) }); + expect(merger.merge(Child).f!.transform[0]!.fn).toBe(childFn); + }); + + it('supplements only missing flags from the parent', () => { + class Base {} + metaStore.set(Base, { f: prop({ flags: { isOptional: true, isNullable: true } }) }); + class Child extends Base {} + metaStore.set(Child, { f: prop({ flags: { isOptional: false } }) }); + const flags = merger.merge(Child).f!.flags; + expect(flags.isOptional).toBe(false); // child wins + expect(flags.isNullable).toBe(true); // supplemented from parent + }); + + it('returns deep copies so mutating the merged result never touches pristine RAW', () => { + class A {} + const raw = { f: prop({ validation: [{ rule: isString }] }) }; + metaStore.set(A, raw); + merger.merge(A).f!.validation.push({ rule: isInt }); + expect(raw.f.validation).toHaveLength(1); + }); +}); diff --git a/src/seal/inheritance-merger.ts b/src/seal/inheritance-merger.ts new file mode 100644 index 0000000..baa968e --- /dev/null +++ b/src/seal/inheritance-merger.ts @@ -0,0 +1,118 @@ +import type { RawClassMeta, MetaStore } from '../metadata'; + +/** + * Merges RAW metadata child-first along the prototype chain of a class. Holds the {@link MetaStore} it + * reads RAW through as an injected collaborator. + * + * Merge rules: + * - validation: union by ruleName — child wins on a same-ruleName collision; otherwise parent rules are appended + * - transform: child takes priority, inherits from parent if absent in child + * - expose: child takes priority, inherits from parent if absent in child + * - exclude: child takes priority, inherits from parent if absent in child + * - type: child takes priority, inherits from parent if absent in child + * - flags: child takes priority, only missing flags are supplemented from parent + */ +export class InheritanceMerger { + readonly #meta: MetaStore; + + constructor(meta: MetaStore) { + this.#meta = meta; + } + + merge(Class: Function): RawClassMeta { + // Collect classes with RAW along the prototype chain (array order: child first) + const chain: Function[] = []; + let current: Function | null = Class; + while (current && current !== Object) { + if (this.#meta.hasOwn(current)) { + chain.push(current); + } + const proto = Object.getPrototypeOf(current); + current = proto === current ? null : proto; + } + + // child-first merge + const merged: RawClassMeta = Object.create(null) as RawClassMeta; + + for (const ctor of chain) { + const raw = this.#meta.get(ctor)!; + for (const [key, meta] of Object.entries(raw)) { + if (!merged[key]) { + // Always copy each meta (incl. a fresh `flags` object and fresh arrays). RAW is shared + // across bakers and re-sealed per baker; normalization in sealOne mutates `meta.flags`, + // so it must operate on a copy and never touch the pristine RAW. + merged[key] = { + ...meta, + validation: [...meta.validation], + transform: [...meta.transform], + expose: [...meta.expose], + exclude: meta.exclude, + type: meta.type, + flags: { ...meta.flags }, + }; + } else { + // Already exists in child → independent merge per category + const m = merged[key]; + const p = meta; + + // validation: union merge by ruleName — child overrides parent for the same rule name (N-6) + for (const rd of p.validation) { + if (!m.validation.some(d => d.rule.ruleName === rd.rule.ruleName)) { + m.validation.push(rd); + } + } + + // transform: inherit from parent if absent in child + if (m.transform.length === 0 && p.transform.length > 0) { + m.transform = [...p.transform]; + } + + // expose: inherit from parent if absent in child + if (m.expose.length === 0 && p.expose.length > 0) { + m.expose = [...p.expose]; + } + + // exclude: inherit from parent if absent in child + if (m.exclude === null && p.exclude !== null) { + m.exclude = p.exclude; + } + + // type: inherit from parent if absent in child + if (m.type === null && p.type !== null) { + m.type = p.type; + } + + // message/context: field-level options inherit from parent if absent in child (same + // child-priority rule as the categories above — otherwise an override silently drops them). + if (m.message === undefined && p.message !== undefined) { + m.message = p.message; + } + if (m.context === undefined && p.context !== undefined) { + m.context = p.context; + } + + // flags: child takes priority, only supplement missing flags from parent + const mf = m.flags; + const pf = p.flags; + if (pf.isOptional !== undefined && mf.isOptional === undefined) { + mf.isOptional = pf.isOptional; + } + if (pf.validateIf !== undefined && mf.validateIf === undefined) { + mf.validateIf = pf.validateIf; + } + if (pf.isNullable !== undefined && mf.isNullable === undefined) { + mf.isNullable = pf.isNullable; + } + if (pf.validateNested !== undefined && mf.validateNested === undefined) { + mf.validateNested = pf.validateNested; + } + if (pf.validateNestedEach !== undefined && mf.validateNestedEach === undefined) { + mf.validateNestedEach = pf.validateNestedEach; + } + } + } + } + + return merged; + } +} diff --git a/src/seal/interfaces.ts b/src/seal/interfaces.ts new file mode 100644 index 0000000..69b6e94 --- /dev/null +++ b/src/seal/interfaces.ts @@ -0,0 +1,120 @@ +import type { Result, ResultAsync } from '@zipbul/result'; + +import type { BakerIssue, RuntimeOptions } from '../common'; +import type { CollectionType, RawClassMeta, RuleDef } from '../metadata'; + +// ───────────────────────────────────────────────────────────────────────────── +// SealOptions — seal-time options resolved from a Baker's config +// ───────────────────────────────────────────────────────────────────────────── + +export interface SealOptions { + /** Automatic conversion using validation decorators as type hints. @default false */ + enableImplicitConversion?: boolean; + /** Use class default values when the key is missing from input. @default false */ + exposeDefaultValues?: boolean; + /** true: return immediately on first error. false (default): collect all errors. @default false */ + stopAtFirstError?: boolean; + /** + * true: reject undeclared fields. Uses the key set from mergeInheritance(Class) as the allowlist. + * `@Exclude` fields are also included in the whitelist — present but excluded from the result. + * @default false + */ + whitelist?: boolean; + /** true: include field exclusion reasons as comments in generated code. @default false */ + debug?: boolean; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SealedExecutors — Dual executor stored in the Baker's per-instance executor map +// ───────────────────────────────────────────────────────────────────────────── + +// NOTE: deserialize/serialize/validate are declared with METHOD syntax (not arrow-property aliases) +// on purpose — methods are bivariant in their parameters, which is what lets a concrete +// `SealedExecutors` be stored as `SealedExecutors` (the type used throughout the +// executor maps and `execs[]`). Switching to the `DeserializeExecutor`/`ValidateExecutor` aliases +// (arrow types) would make the parameters contravariant and break that upcast. +export interface SealedExecutors { + /** Internal executor — Result pattern. deserialize() wraps and converts to throw */ + deserialize(input: unknown, options?: RuntimeOptions): Result | ResultAsync; + /** Internal executor — always succeeds. serialize assumes no validation */ + serialize(instance: T, options?: RuntimeOptions): Record | Promise>; + /** Internal executor — validate-only (no object creation). Returns null on success, BakerIssue[] on failure */ + validate(input: unknown, options?: RuntimeOptions): BakerIssue[] | null | Promise; + /** true if the deserialize direction has async rules/transforms/nested */ + isAsync: boolean; + /** true if the serialize direction has async transforms/nested */ + isSerializeAsync: boolean; + /** Inheritance-resolved metadata — read during codegen to wire nested DTO fields and async analysis */ + merged?: RawClassMeta; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ClassifiedType — result of reading a `@Type`/`@Field` type thunk's return value +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Classification of a `@Type`/`@Field` `type` thunk's return value. The single reading of the + * Map/Set marker + array-unwrap that seal normalization, circular analysis, and async analysis all + * share — each caller then applies its OWN primitive-exclusion and error policy to `resolved` (seal + * throws on a non-constructor; the analyzers skip it), so only the classification lives here. + */ +export interface ClassifiedType { + /** Set when the thunk returned the `Map` or `Set` constructor (a collection field). */ + collection?: CollectionType; + /** True when the thunk returned the array form `[Element]`. */ + isArray: boolean; + /** The element value (array-unwrapped), or `undefined` for a Map/Set collection. */ + resolved: unknown; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ChildScope — inline-nested scope a parent DeserializeBuilder hands to a child +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Inline-nested scope a parent builder hands to a child: the shared mutable accumulator (reference + * arrays + circular-tracking set) plus the child's own path/var/input expression overrides. + */ +export interface ChildScope { + regexes: RegExp[]; + refs: unknown[]; + execs: SealedExecutors[]; + inlineCounter: { n: number }; + inlineNestedClasses: Set | undefined; + pathPrefix: string; + varPrefix: string; + inputExpr: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Deserialize codegen rule-shaping types — shared between deserialize-builder and +// deserialize-codegen. Only the rules-free ones live here; the EmitContext-coupled +// codegen types (GuardParams/TypeGateConfig) stay internal to deserialize-codegen.ts +// to keep this barrel-exported file free of any `rules` edge (rules → seal already +// exists via EmitContext.addExecutor, so a seal/interfaces → rules edge would cycle). +// ───────────────────────────────────────────────────────────────────────────── + +/** Partitioned validation rules for a field — produced by categorizeRules. */ +export interface CategorizedRules { + each: RuleDef[]; + generalRules: RuleDef[]; + /** The single typed dependency group (if any) after conflict check */ + typedDeps: { type: 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object'; deps: RuleDef[] } | undefined; +} + +/** Result of resolveTypeGate — effective gate type and related metadata. */ +export interface ResolvedTypeGate { + effectiveGateType: string | null; + /** The typed dependency rules (from requiresType) */ + gateDeps: RuleDef[]; + /** Index of the type asserter within generalRules (-1 if none) */ + typeAsserterIdx: number; + /** The type asserter rule def (if found) */ + typeAsserter: RuleDef | undefined; + /** Whether conversion is enabled for this field */ + enableConversion: boolean; + /** Whether this gate was inferred from asserter only (no typed deps) */ + asserterInferredGate: string | null; + /** Whether this gate was inferred from @Type hint */ + typeHintGate: string | null; +} diff --git a/src/seal/meta-validator.spec.ts b/src/seal/meta-validator.spec.ts new file mode 100644 index 0000000..f864e5c --- /dev/null +++ b/src/seal/meta-validator.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'bun:test'; + +import type { ClassCtor } from '../common'; +import type { RawClassMeta, RawPropertyMeta } from '../metadata'; + +import { BakerError } from '../common'; +import { CollectionType, metaStore } from '../metadata'; +import { MetaValidator } from './meta-validator'; + +const validator = new MetaValidator(metaStore); + +function prop(over: Partial = {}): RawPropertyMeta { + return { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {}, ...over }; +} + +// A class WITH @Field metadata, used as a valid discriminator subtype / collection value target. +class Sub {} +metaStore.set(Sub, { x: prop() }); + +function disc(d: { property: string; subTypes: { value: ClassCtor; name: string }[] }): RawClassMeta { + return { f: prop({ type: { fn: () => Sub, discriminator: d } }) }; +} + +describe('MetaValidator.validateShape', () => { + class Host {} + + it('passes a valid discriminator', () => { + expect(() => + validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: Sub, name: 'sub' }] })), + ).not.toThrow(); + }); + + it('rejects an empty discriminator property', () => { + expect(() => validator.validateShape(Host, disc({ property: '', subTypes: [{ value: Sub, name: 's' }] }))).toThrow( + BakerError, + ); + }); + + it('rejects a reserved discriminator property (prototype-pollution vector)', () => { + expect(() => validator.validateShape(Host, disc({ property: '__proto__', subTypes: [{ value: Sub, name: 's' }] }))).toThrow( + /reserved/, + ); + }); + + it('rejects empty subTypes', () => { + expect(() => validator.validateShape(Host, disc({ property: 'type', subTypes: [] }))).toThrow(/non-empty array/); + }); + + it('rejects a subType with a non-string name', () => { + expect(() => validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: Sub, name: '' }] }))).toThrow( + /name must be/, + ); + }); + + it('rejects a subType whose value is not a constructor', () => { + expect(() => + validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: 123 as never, name: 'x' }] })), + ).toThrow(/class constructor/); + }); + + it('rejects duplicate subType names', () => { + const subTypes = [ + { value: Sub, name: 'dup' }, + { value: Sub, name: 'dup' }, + ]; + expect(() => validator.validateShape(Host, disc({ property: 'type', subTypes }))).toThrow(/duplicate name/); + }); + + it('rejects a subType class without @Field metadata', () => { + class Bare {} + expect(() => validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: Bare, name: 'bare' }] }))).toThrow( + /no @Field/, + ); + }); + + it('rejects a Set value-class target without @Field metadata', () => { + class Bare {} + const merged: RawClassMeta = { + f: prop({ type: { fn: () => Set, collection: CollectionType.Set, resolvedCollectionValue: Bare } }), + }; + expect(() => validator.validateShape(Host, merged)).toThrow(/no @Field/); + }); + + it('passes when a Set value-class target has @Field metadata', () => { + const merged: RawClassMeta = { + f: prop({ type: { fn: () => Set, collection: CollectionType.Set, resolvedCollectionValue: Sub } }), + }; + expect(() => validator.validateShape(Host, merged)).not.toThrow(); + }); +}); diff --git a/src/seal/meta-validator.ts b/src/seal/meta-validator.ts new file mode 100644 index 0000000..0543ff4 --- /dev/null +++ b/src/seal/meta-validator.ts @@ -0,0 +1,83 @@ +import type { RawClassMeta, MetaStore } from '../metadata'; + +import { BakerError } from '../common'; +import { CollectionType } from '../metadata'; +import { RESERVED_PROPERTY_NAMES } from './constants'; + +/** + * Seal-time invariant checks on the merged metadata, run from sealOne after merge + type normalization + * and before codegen. Holds the {@link MetaStore} (for @Field-presence checks) as an injected collaborator. + * Throws BakerError on the first violation. + * + * Covers W2 (D7 + D9): + * - Discriminator shape: empty subTypes / invalid subType entry / name collision / missing/reserved property + * - Set/Map pairing: when a setValue/mapValue thunk is present, its target class must have @Field metadata + * (a primitive Set/Map with no value thunk is valid and intentionally not flagged) + */ +export class MetaValidator { + readonly #meta: MetaStore; + + constructor(meta: MetaStore) { + this.#meta = meta; + } + + validateShape(Class: Function, merged: RawClassMeta): void { + const className = Class.name; + + for (const [key, meta] of Object.entries(merged)) { + // ─── Discriminator shape ───────────────────────────────────────────── + if (meta.type?.discriminator) { + const disc = meta.type.discriminator; + if (typeof disc.property !== 'string' || disc.property.length === 0) { + throw new BakerError(`${className}.${key}: discriminator.property must be a non-empty string.`); + } + // The discriminator property is written back onto the result object (keepDiscriminatorProperty), + // so a reserved name there is a prototype-pollution vector — reject it like any banned field key. + if (RESERVED_PROPERTY_NAMES.has(disc.property)) { + throw new BakerError( + `${className}.${key}: discriminator.property '${disc.property}' is a reserved property name and cannot be used.`, + ); + } + if (!Array.isArray(disc.subTypes) || disc.subTypes.length === 0) { + throw new BakerError( + `${className}.${key}: discriminator.subTypes must be a non-empty array of { value, name } entries.`, + ); + } + const seenNames = new Set(); + for (let i = 0; i < disc.subTypes.length; i++) { + const sub = disc.subTypes[i]!; + if (typeof sub.name !== 'string' || sub.name.length === 0) { + throw new BakerError(`${className}.${key}: discriminator.subTypes[${i}].name must be a non-empty string.`); + } + if (typeof sub.value !== 'function') { + throw new BakerError( + `${className}.${key}: discriminator.subTypes[${i}].value must be a class constructor (got ${typeof sub.value}).`, + ); + } + if (seenNames.has(sub.name)) { + throw new BakerError( + `${className}.${key}: discriminator.subTypes has duplicate name '${sub.name}'. Each subType must have a unique name.`, + ); + } + seenNames.add(sub.name); + // subType class must have @Field metadata (RAW) — otherwise codegen will fail with a less clear error + if (!this.#meta.hasOwn(sub.value)) { + throw new BakerError( + `${className}.${key}: discriminator.subTypes[${i}].value (${sub.value.name}) has no @Field decorators.`, + ); + } + } + } + + // ─── Set/Map collection pairing — unified single-pass check ────────── + const collection = meta.type?.collection; + if (collection !== undefined && meta.type?.resolvedCollectionValue) { + const target = meta.type.resolvedCollectionValue; + if (!this.#meta.hasOwn(target)) { + const accessor = collection === CollectionType.Set ? 'setValue' : 'mapValue'; + throw new BakerError(`${className}.${key}: ${accessor} target (${target.name}) has no @Field decorators.`); + } + } + } + } +} diff --git a/src/seal/seal.spec.ts b/src/seal/seal.spec.ts index 4565fd2..ec766f8 100644 --- a/src/seal/seal.spec.ts +++ b/src/seal/seal.spec.ts @@ -1,15 +1,20 @@ import { describe, it, expect, afterEach, spyOn } from 'bun:test'; -import type { RawClassMeta, RuleDef } from '../types'; +import type { RawClassMeta, RuleDef } from '../metadata/interfaces'; +import type { SealOptions, SealedExecutors } from './interfaces'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { sealClass } from '../../test/integration/helpers/seal'; import { unseal } from '../../test/integration/helpers/unseal'; -import { BakerError, isBakerIssueSet } from '../errors'; -import { setRaw } from '../meta-access'; +import { BakerError, isBakerIssueSet } from '../common/errors'; +import { metaStore } from '../metadata'; import { min, max } from '../rules/number'; import { isString } from '../rules/typechecker'; -import { circularPlaceholder, mergeInheritance } from './seal'; +import { CircularPlaceholder } from './circular-placeholder'; +import { InheritanceMerger } from './inheritance-merger'; +import { sealRegistry } from './seal'; + +const merger = new InheritanceMerger(metaStore); // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -50,7 +55,7 @@ describe('sealClass', () => { it('should register the class in the baker after sealing', () => { // Arrange class UserDto {} - setRaw(UserDto, makeStringField('name')); + metaStore.set(UserDto, makeStringField('name')); // Act const b = sealClass(UserDto); // Assert — the baker can run the sealed class without throwing "not sealed by this baker" @@ -61,7 +66,7 @@ describe('sealClass', () => { it('should seal a DTO with @IsString field — deserialize returns instance for valid input', async () => { // Arrange class PersonDto {} - setRaw(PersonDto, makeStringField('name')); + metaStore.set(PersonDto, makeStringField('name')); const b = sealClass(PersonDto); // Act const result = await b.deserialize(PersonDto, { name: 'Alice' }); @@ -74,7 +79,7 @@ describe('sealClass', () => { it('should seal a DTO with @IsString field — deserialize returns error for invalid input', async () => { // Arrange class PersonDto2 {} - setRaw(PersonDto2, makeStringField('name')); + metaStore.set(PersonDto2, makeStringField('name')); const b = sealClass(PersonDto2); // Act const result = await b.deserialize(PersonDto2, { name: 42 }); @@ -87,10 +92,10 @@ describe('sealClass', () => { it('should seal @Type nested DTO so nested class is also sealed', () => { // Arrange class AddressDto {} - setRaw(AddressDto, makeStringField('city')); + metaStore.set(AddressDto, makeStringField('city')); class OrderDto {} - setRaw(OrderDto, { + metaStore.set(OrderDto, { address: { validation: [], transform: [], @@ -111,7 +116,7 @@ describe('sealClass', () => { it('should throw BakerError when @Expose has both deserializeOnly and serializeOnly', () => { // Arrange class BadExposeDto {} - setRaw(BadExposeDto, { + metaStore.set(BadExposeDto, { field: { validation: [{ rule: isString }], transform: [], @@ -130,7 +135,7 @@ describe('sealClass', () => { it('should handle circular @Type via placeholder without infinite recursion', () => { // Arrange — self-referencing DTO class TreeDto {} - setRaw(TreeDto, { + metaStore.set(TreeDto, { value: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, child: { validation: [], @@ -150,7 +155,7 @@ describe('sealClass', () => { it('should succeed when DTO has no fields (empty metadata)', () => { // Arrange class EmptyDto {} - setRaw(EmptyDto, makeEmptyMeta()); + metaStore.set(EmptyDto, makeEmptyMeta()); // Act / Assert let b!: ReturnType; expect(() => { @@ -164,7 +169,7 @@ describe('sealClass', () => { it('should produce equivalent executors after seal → unseal → seal cycle', async () => { // Arrange class IdempDto {} - setRaw(IdempDto, makeStringField('name')); + metaStore.set(IdempDto, makeStringField('name')); const b1 = sealClass(IdempDto); const firstResult = await b1.deserialize(IdempDto, { name: 'Bob' }); @@ -183,8 +188,8 @@ describe('sealClass', () => { // Arrange — parent DTO with a polymorphic discriminator field class DogDto {} class CatDto {} - setRaw(DogDto, makeEmptyMeta()); - setRaw(CatDto, makeEmptyMeta()); + metaStore.set(DogDto, makeEmptyMeta()); + metaStore.set(CatDto, makeEmptyMeta()); class AnimalContainerDto {} const raw: RawClassMeta = { @@ -206,7 +211,7 @@ describe('sealClass', () => { flags: { validateNested: true }, }, }; - setRaw(AnimalContainerDto, raw); + metaStore.set(AnimalContainerDto, raw); // Act const b = sealClass(AnimalContainerDto); @@ -222,10 +227,10 @@ describe('sealClass', () => { it('should auto-set validateNested when @Type points to a DTO class (replaces DX-5 warning)', () => { // Arrange class NestedTarget {} - setRaw(NestedTarget, makeEmptyMeta()); + metaStore.set(NestedTarget, makeEmptyMeta()); class AutoNestedDto {} - setRaw(AutoNestedDto, { + metaStore.set(AutoNestedDto, { nested: { validation: [], transform: [], @@ -249,10 +254,10 @@ describe('sealClass', () => { it('should recursively seal nested DTOs', () => { // Arrange — parent + nested DTO class Nested {} - setRaw(Nested, makeStringField('val')); + metaStore.set(Nested, makeStringField('val')); class Parent {} - setRaw(Parent, { + metaStore.set(Parent, { child: { validation: [], transform: [], @@ -274,7 +279,7 @@ describe('sealClass', () => { it('should throw BakerError when @Type returns invalid value (null/non-function)', () => { // Arrange class BadTypeDto {} - setRaw(BadTypeDto, { + metaStore.set(BadTypeDto, { field: { validation: [], transform: [], @@ -293,10 +298,10 @@ describe('sealClass', () => { class BrokenNested {} const brokenRaw: RawClassMeta = Object.create(null) as RawClassMeta; brokenRaw['constructor'] = { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }; - setRaw(BrokenNested, brokenRaw); + metaStore.set(BrokenNested, brokenRaw); class ParentDto {} - setRaw(ParentDto, { + metaStore.set(ParentDto, { child: { validation: [], transform: [], @@ -317,10 +322,10 @@ describe('sealClass', () => { it('should auto-set validateNested even when @Type field has serializeOnly-only transforms', () => { // Arrange — transform has serializeOnly, @Type points to DTO → auto nested class NestedA {} - setRaw(NestedA, makeEmptyMeta()); + metaStore.set(NestedA, makeEmptyMeta()); class AutoNestedTransformDto {} - setRaw(AutoNestedTransformDto, { + metaStore.set(AutoNestedTransformDto, { nested: { validation: [], transform: [{ fn: () => 'x', options: { serializeOnly: true } }], @@ -342,10 +347,10 @@ describe('sealClass', () => { it('should invoke transform.filter callback and skip warn when @Type field has bidirectional transform', () => { // Arrange — bidirectional transform → filter returns true → length=1 → no warn class NestedB {} - setRaw(NestedB, makeEmptyMeta()); + metaStore.set(NestedB, makeEmptyMeta()); class NoWarnTransformDto {} - setRaw(NoWarnTransformDto, { + metaStore.set(NoWarnTransformDto, { nested: { validation: [], transform: [{ fn: () => 'x' }], @@ -373,9 +378,9 @@ describe('mergeInheritance', () => { // Arrange class StandaloneDto {} const raw = makeStringField('name'); - setRaw(StandaloneDto, raw); + metaStore.set(StandaloneDto, raw); // Act - const merged = mergeInheritance(StandaloneDto); + const merged = merger.merge(StandaloneDto); // Assert expect(merged.name).toBeDefined(); expect(merged.name!.validation.length).toBe(1); @@ -384,16 +389,16 @@ describe('mergeInheritance', () => { it('should union-merge validation rules from parent and child', () => { // Arrange class BaseDto {} - setRaw(BaseDto, { + metaStore.set(BaseDto, { name: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); class ChildDto extends BaseDto {} - setRaw(ChildDto, { + metaStore.set(ChildDto, { name: { validation: [{ rule: min(1) }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildDto); + const merged = merger.merge(ChildDto); // Assert — both isString and min(1) should be present expect(merged.name!.validation.length).toBe(2); }); @@ -404,15 +409,15 @@ describe('mergeInheritance', () => { const childFn = ({ value }: { value: unknown }): unknown => (value as string).toLowerCase(); class BaseTr {} - setRaw(BaseTr, { + metaStore.set(BaseTr, { name: { validation: [], transform: [{ fn: parentFn }], expose: [], exclude: null, type: null, flags: {} }, }); class ChildTr extends BaseTr {} - setRaw(ChildTr, { + metaStore.set(ChildTr, { name: { validation: [], transform: [{ fn: childFn }], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildTr); + const merged = merger.merge(ChildTr); // Assert — only child transform expect(merged.name!.transform.length).toBe(1); expect(merged.name!.transform[0]!.fn).toBe(childFn); @@ -422,15 +427,15 @@ describe('mergeInheritance', () => { // Arrange const parentFn2 = ({ value }: { value: unknown }): unknown => value; class BaseTr2 {} - setRaw(BaseTr2, { + metaStore.set(BaseTr2, { x: { validation: [], transform: [{ fn: parentFn2 }], expose: [], exclude: null, type: null, flags: {} }, }); class ChildTr2 extends BaseTr2 {} - setRaw(ChildTr2, { + metaStore.set(ChildTr2, { x: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildTr2); + const merged = merger.merge(ChildTr2); // Assert — parent transform inherited expect(merged.x!.transform.length).toBe(1); expect(merged.x!.transform[0]!.fn).toBe(parentFn2); @@ -439,15 +444,15 @@ describe('mergeInheritance', () => { it('should override parent expose with child expose when child has @Expose', () => { // Arrange class BaseEx {} - setRaw(BaseEx, { + metaStore.set(BaseEx, { field: { validation: [], transform: [], expose: [{ name: 'parent_name' }], exclude: null, type: null, flags: {} }, }); class ChildEx extends BaseEx {} - setRaw(ChildEx, { + metaStore.set(ChildEx, { field: { validation: [], transform: [], expose: [{ name: 'child_name' }], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildEx); + const merged = merger.merge(ChildEx); // Assert — child name used, not parent expect(merged.field!.expose[0]!.name).toBe('child_name'); }); @@ -455,15 +460,15 @@ describe('mergeInheritance', () => { it('should inherit parent expose when child has no @Expose', () => { // Arrange class BaseEx2 {} - setRaw(BaseEx2, { + metaStore.set(BaseEx2, { field: { validation: [], transform: [], expose: [{ name: 'parent_exposed' }], exclude: null, type: null, flags: {} }, }); class ChildEx2 extends BaseEx2 {} - setRaw(ChildEx2, { + metaStore.set(ChildEx2, { field: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildEx2); + const merged = merger.merge(ChildEx2); // Assert — parent expose inherited expect(merged.field!.expose.length).toBe(1); expect(merged.field!.expose[0]!.name).toBe('parent_exposed'); @@ -472,15 +477,15 @@ describe('mergeInheritance', () => { it('should inherit parent exclude when child has no exclude', () => { // Arrange class BaseExcl {} - setRaw(BaseExcl, { + metaStore.set(BaseExcl, { secret: { validation: [], transform: [], expose: [], exclude: { serializeOnly: true }, type: null, flags: {} }, }); class ChildExcl extends BaseExcl {} - setRaw(ChildExcl, { + metaStore.set(ChildExcl, { secret: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildExcl); + const merged = merger.merge(ChildExcl); // Assert expect(merged.secret!.exclude).toEqual({ serializeOnly: true }); }); @@ -489,15 +494,15 @@ describe('mergeInheritance', () => { // Arrange class NestedDto {} class BaseType {} - setRaw(BaseType, { + metaStore.set(BaseType, { nested: { validation: [], transform: [], expose: [], exclude: null, type: { fn: () => NestedDto }, flags: {} }, }); class ChildType extends BaseType {} - setRaw(ChildType, { + metaStore.set(ChildType, { nested: { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildType); + const merged = merger.merge(ChildType); // Assert expect(merged.nested!.type?.fn()).toBe(NestedDto); }); @@ -505,15 +510,15 @@ describe('mergeInheritance', () => { it('should apply child-first flag merge (isOptional)', () => { // Arrange class BaseFlag {} - setRaw(BaseFlag, { + metaStore.set(BaseFlag, { age: { validation: [], transform: [], expose: [], exclude: null, type: null, flags: { isOptional: true } }, }); class ChildFlag extends BaseFlag {} - setRaw(ChildFlag, { + metaStore.set(ChildFlag, { age: { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildFlag); + const merged = merger.merge(ChildFlag); // Assert — parent flag inherited (child has none) expect(merged.age!.flags.isOptional).toBe(true); }); @@ -522,15 +527,15 @@ describe('mergeInheritance', () => { // Arrange — same rule instance in both parent and child const sharedRule = isString; class BaseDup {} - setRaw(BaseDup, { + metaStore.set(BaseDup, { f: { validation: [{ rule: sharedRule }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); class ChildDup extends BaseDup {} - setRaw(ChildDup, { + metaStore.set(ChildDup, { f: { validation: [{ rule: sharedRule }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(ChildDup); + const merged = merger.merge(ChildDup); // Assert — deduplicated expect(merged.f!.validation.length).toBe(1); }); @@ -540,11 +545,11 @@ describe('mergeInheritance', () => { it('should not include child in chain when child has no own RAW (inherits via prototype)', () => { // Arrange class ParentNR {} - setRaw(ParentNR, makeStringField('x')); + metaStore.set(ParentNR, makeStringField('x')); class ChildNR extends ParentNR {} // ChildNR has NO own RAW — inherits ParentNR[RAW] via prototype chain // Act - const merged = mergeInheritance(ChildNR); + const merged = merger.merge(ChildNR); // Assert — parent field accessible, not double-merged expect(merged.x).toBeDefined(); expect(merged.x!.validation.length).toBe(1); @@ -553,13 +558,13 @@ describe('mergeInheritance', () => { it('should not double-merge parent rules when child inherits RAW via prototype', () => { // Arrange class BaseNR2 {} - setRaw(BaseNR2, { + metaStore.set(BaseNR2, { name: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); class ChildNR2 extends BaseNR2 {} // ChildNR2 has no own RAW — rule must appear exactly once // Act - const merged = mergeInheritance(ChildNR2); + const merged = merger.merge(ChildNR2); // Assert expect(merged.name!.validation.length).toBe(1); }); @@ -567,13 +572,13 @@ describe('mergeInheritance', () => { it('should skip intermediate class without own RAW in 3-level chain', () => { // Arrange class GrandNR {} - setRaw(GrandNR, makeStringField('a')); + metaStore.set(GrandNR, makeStringField('a')); class MidNR extends GrandNR {} // MidNR has no own RAW class ChildNR3 extends MidNR {} - setRaw(ChildNR3, makeStringField('b')); + metaStore.set(ChildNR3, makeStringField('b')); // Act - const merged = mergeInheritance(ChildNR3); + const merged = merger.merge(ChildNR3); // Assert — both fields present, each exactly once expect(merged.a).toBeDefined(); expect(merged.b).toBeDefined(); @@ -584,19 +589,19 @@ describe('mergeInheritance', () => { it('should handle 3-level inheritance chain correctly', () => { // Arrange class GrandParent {} - setRaw(GrandParent, { + metaStore.set(GrandParent, { x: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); class ParentLevel extends GrandParent {} - setRaw(ParentLevel, { + metaStore.set(ParentLevel, { x: { validation: [{ rule: min(1) }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); class Child3 extends ParentLevel {} - setRaw(Child3, { + metaStore.set(Child3, { x: { validation: [{ rule: max(100) }], transform: [], expose: [], exclude: null, type: null, flags: {} }, }); // Act - const merged = mergeInheritance(Child3); + const merged = merger.merge(Child3); // Assert — all 3 rules in union expect(merged.x!.validation.length).toBe(3); }); @@ -623,7 +628,7 @@ describe('sealOne — banned field names (C5)', () => { it('should throw BakerError when a field is named __proto__', () => { // Arrange class BannedProtoDto {} - setRaw(BannedProtoDto, makeRawWithBannedKey('__proto__')); + metaStore.set(BannedProtoDto, makeRawWithBannedKey('__proto__')); // Act / Assert expect(() => sealClass(BannedProtoDto)).toThrow(BakerError); }); @@ -633,7 +638,7 @@ describe('sealOne — banned field names (C5)', () => { class BannedConstructorDto {} const raw: RawClassMeta = Object.create(null) as RawClassMeta; raw['constructor'] = { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }; - setRaw(BannedConstructorDto, raw); + metaStore.set(BannedConstructorDto, raw); // Act / Assert expect(() => sealClass(BannedConstructorDto)).toThrow(BakerError); }); @@ -643,7 +648,7 @@ describe('sealOne — banned field names (C5)', () => { class BannedPrototypeDto {} const raw: RawClassMeta = Object.create(null) as RawClassMeta; raw['prototype'] = { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }; - setRaw(BannedPrototypeDto, raw); + metaStore.set(BannedPrototypeDto, raw); // Act / Assert expect(() => sealClass(BannedPrototypeDto)).toThrow(BakerError); }); @@ -664,7 +669,7 @@ describe('sealOne — banned field names (C5)', () => { writable: true, configurable: true, }); - setRaw(MixedBannedDto, raw); + metaStore.set(MixedBannedDto, raw); // Act / Assert expect(() => sealClass(MixedBannedDto)).toThrow(BakerError); }); @@ -672,10 +677,55 @@ describe('sealOne — banned field names (C5)', () => { it('should not throw BakerError for __PROTO__ (uppercase — not a banned name)', () => { // Arrange — same letters but different case, not a reserved name class UpperCaseDto {} - setRaw(UpperCaseDto, makeRawWithBannedKey('__PROTO__')); + metaStore.set(UpperCaseDto, makeRawWithBannedKey('__PROTO__')); // Act / Assert expect(() => sealClass(UpperCaseDto)).not.toThrow(); }); + + it('should throw BakerError when discriminator.property is a reserved name (__proto__)', () => { + // Arrange — the discriminator property is written back onto the result; a reserved name there is + // the one spot the banned-name gate previously missed. + class DiscSub {} + metaStore.set(DiscSub, makeStringField('x')); + class DiscReservedDto {} + metaStore.set(DiscReservedDto, { + pet: { + validation: [], + transform: [], + expose: [], + exclude: null, + type: { fn: () => DiscSub, discriminator: { property: '__proto__', subTypes: [{ name: 'a', value: DiscSub }] } }, + flags: { validateNested: true }, + }, + }); + // Act / Assert + expect(() => sealClass(DiscReservedDto)).toThrow(BakerError); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// sealRegistry — transactional rollback is precise (only this run's insertions) +// ───────────────────────────────────────────────────────────────────────────── + +describe('sealRegistry — transactional rollback', () => { + it('rolls back only this run’s insertions, preserving pre-existing executors', () => { + // Arrange — a pre-existing executor (e.g. from a prior successful seal) already sits in the map. + const preExisting = new CircularPlaceholder('PreExistingDto'); + class PreExistingDto {} + const executors = new Map>([[PreExistingDto, preExisting]]); + + // A registry whose only class fails to seal (banned field name) → the run throws. + class FailingDto {} + const raw = Object.create(null) as RawClassMeta; + raw['constructor'] = { validation: [], transform: [], expose: [], exclude: null, type: null, flags: {} }; + metaStore.set(FailingDto, raw); + + // Act / Assert — seal fails... + expect(() => sealRegistry(new Set([FailingDto]), {} as SealOptions, executors)).toThrow(BakerError); + // ...but the pre-existing executor must survive (rollback removes only what this run inserted). + expect(executors.get(PreExistingDto)).toBe(preExisting); + expect(executors.has(FailingDto)).toBe(false); + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -687,7 +737,7 @@ describe('analyzeAsync — discriminator', () => { // Arrange — SubA has an async transform class SubA {} const asyncFn = async ({ value }: { value: unknown }): Promise => value; - setRaw(SubA, { + metaStore.set(SubA, { val: { validation: [{ rule: isString }], transform: [{ fn: asyncFn }], @@ -699,10 +749,10 @@ describe('analyzeAsync — discriminator', () => { }); class SubB {} - setRaw(SubB, makeStringField('val')); + metaStore.set(SubB, makeStringField('val')); class ParentDisc {} - setRaw(ParentDisc, { + metaStore.set(ParentDisc, { child: { validation: [], transform: [], @@ -735,7 +785,7 @@ describe('analyzeAsync — discriminator', () => { class CircSubA {} class CircSubB {} - setRaw(CircSubA, { + metaStore.set(CircSubA, { name: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, other: { validation: [], @@ -746,7 +796,7 @@ describe('analyzeAsync — discriminator', () => { flags: { validateNested: true }, }, }); - setRaw(CircSubB, { + metaStore.set(CircSubB, { name: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, other: { validation: [], @@ -759,7 +809,7 @@ describe('analyzeAsync — discriminator', () => { }); class CircParent {} - setRaw(CircParent, { + metaStore.set(CircParent, { child: { validation: [], transform: [], @@ -795,7 +845,7 @@ describe('analyzeAsync — discriminator', () => { class DiscB {} class DiscC {} - setRaw(DiscB, { + metaStore.set(DiscB, { name: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, ref: { validation: [], @@ -806,7 +856,7 @@ describe('analyzeAsync — discriminator', () => { flags: { validateNested: true }, }, }); - setRaw(DiscC, { + metaStore.set(DiscC, { name: { validation: [{ rule: isString }], transform: [], expose: [], exclude: null, type: null, flags: {} }, ref: { validation: [], @@ -817,7 +867,7 @@ describe('analyzeAsync — discriminator', () => { flags: { validateNested: true }, }, }); - setRaw(DiscA, { + metaStore.set(DiscA, { child: { validation: [], transform: [], @@ -857,19 +907,19 @@ describe('analyzeAsync — discriminator', () => { describe('circularPlaceholder', () => { it('returns an executor whose deserialize/serialize/validate all throw BakerError', () => { - const ph = circularPlaceholder('PendingDto'); + const ph = new CircularPlaceholder('PendingDto'); expect(() => ph.deserialize({}, undefined)).toThrow(BakerError); expect(() => ph.serialize({}, undefined)).toThrow(BakerError); expect(() => ph.validate({}, undefined)).toThrow(BakerError); }); it('names the still-sealing class in the thrown message', () => { - const ph = circularPlaceholder('PendingDto'); + const ph = new CircularPlaceholder('PendingDto'); expect(() => ph.deserialize({}, undefined)).toThrow(/PendingDto is still being sealed/); }); it('is marked synchronous (isAsync / isSerializeAsync both false)', () => { - const ph = circularPlaceholder('PendingDto'); + const ph = new CircularPlaceholder('PendingDto'); expect(ph.isAsync).toBe(false); expect(ph.isSerializeAsync).toBe(false); }); diff --git a/src/seal/seal.ts b/src/seal/seal.ts index 259779c..0f6a528 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -1,495 +1,193 @@ -import type { SealOptions } from '../interfaces'; -import type { ClassCtor, RawClassMeta, RawPropertyMeta, SealedExecutors } from '../types'; - -import { CollectionType, Direction } from '../enums'; -import { BakerError } from '../errors'; -import { getRaw, hasRawOwn } from '../meta-access'; -import { isAsyncFunction } from '../utils'; -import { analyzeCircular } from './circular-analyzer'; +import type { MetaStore } from '../metadata'; +import type { SealOptions, SealedExecutors } from './interfaces'; + +import { Direction, BakerError } from '../common'; +import { metaStore } from '../metadata'; +import { AsyncAnalyzer } from './async-analyzer'; +import { CircularAnalyzer } from './circular-analyzer'; +import { CircularPlaceholder } from './circular-placeholder'; +import { compileCache } from './compile-cache'; +import { RESERVED_PROPERTY_NAMES } from './constants'; import { buildDeserializeCode, buildValidateCode } from './deserialize-builder'; import { validateExposeStacks } from './expose-validator'; +import { InheritanceMerger } from './inheritance-merger'; +import { MetaValidator } from './meta-validator'; import { buildSerializeCode } from './serialize-builder'; -import { validateMeta } from './validate-meta'; - -const BANNED_FIELD_NAMES = new Set(['__proto__', 'constructor', 'prototype']); -const PRIMITIVE_CTORS = new Set([Number, String, Boolean, Date]); - -/** @internal Placeholder executor for circular dependency detection during seal */ -function circularPlaceholder(className: string): SealedExecutors { - const msg = `Circular dependency during seal: ${className} is still being sealed`; - return { - deserialize() { - throw new BakerError(msg); - }, - serialize() { - throw new BakerError(msg); - }, - validate() { - throw new BakerError(msg); - }, - isAsync: false, - isSerializeAsync: false, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// analyzeAsync — static analysis to determine if a sealed DTO requires an async executor (C1) -// ───────────────────────────────────────────────────────────────────────────── - -function analyzeAsync( - merged: RawClassMeta, - direction: Direction, - resolve: (cls: Function) => SealedExecutors | undefined, - visited?: Set, -): boolean { - const flag = direction === Direction.Deserialize ? 'isAsync' : 'isSerializeAsync'; - const seen = visited ?? new Set(); - - // sealOne seals every nested DTO (step 4) before this runs (step 5). For a fully-sealed nested - // class its `isAsync`/`isSerializeAsync` flag is authoritative and already accounts for ITS nested - // classes — so trusting the flag propagates async through any nesting depth (re-deriving from - // metadata would lose `resolvedClass` past depth 1). A class still being sealed carries a - // placeholder executor (no `merged`); that only happens on a circular back-edge, where the flag - // is not yet known — there we recurse into the class's own metadata, guarded by `seen`. - const nestedIsAsync = (cls: Function): boolean => { - if (seen.has(cls)) { - return false; - } - seen.add(cls); - const sealed = resolve(cls); - if (sealed?.merged) { - return sealed[flag] === true; - } - return analyzeAsync(mergeInheritance(cls), direction, resolve, seen); - }; - - for (const meta of Object.values(merged)) { - // 1. createRule may return Promise even without `async` syntax (deserialize only). - if (direction === Direction.Deserialize && meta.validation.some(rd => rd.rule.isAsync)) { - return true; - } - // 2. @Transform async — single-pass scan, avoids intermediate filter[] allocation - for (const td of meta.transform) { - if (direction === Direction.Deserialize ? td.options?.serializeOnly : td.options?.deserializeOnly) { - continue; - } - if (td.isAsync ?? isAsyncFunction(td.fn)) { - return true; - } - } - // 3. nested DTOs (direct, Set/Map value, discriminator subtypes) - if (nestedClassesOf(meta).some(nestedIsAsync)) { - return true; - } - } - return false; -} +import { normalizeTypeDefs } from './type-normalizer'; /** - * Nested DTO classes referenced by a field's type. Prefers normalized `resolved*` slots, but - * falls back to resolving the raw `type.fn()` thunk — needed when `analyzeAsync` recurses into a - * still-being-sealed class on a circular back-edge whose metadata was never normalized. + * One seal operation. Holds the per-operation state — the calling Baker's executor map, the resolved + * options, the config fingerprint, and the set of classes compiled by this run — as fields, so the + * recursive nested-DTO sealing reads from a single source of truth instead of threading them through + * every call. Created fresh per `new Baker().seal()` (via {@link sealRegistry}). + * + * Executors are written into the Baker's own map, never onto the class, so two bakers sealing the same + * class each compile their own executor with their own options — apps never mix. Within one run an + * already-present class is reused as-is (circular-ref guard + shared nested DTO dedup for that baker). */ -function nestedClassesOf(meta: RawPropertyMeta): Function[] { - const t = meta.type; - if (!t) { - return []; - } - const out: Function[] = []; - if (t.resolvedClass) { - out.push(t.resolvedClass); - } - if (t.resolvedCollectionValue) { - out.push(t.resolvedCollectionValue); +class SealRun { + private readonly fp: string; + /** Classes compiled by THIS run (excludes cache hits) → their executor, committed to the cache on success. */ + private readonly sealed = new Map>(); + /** Every class THIS run inserted into `executors` (fresh placeholders + cache reuses) — for precise rollback. */ + private readonly inserted = new Set(); + private readonly resolve = (cls: Function): SealedExecutors | undefined => this.executors.get(cls); + readonly #merger: InheritanceMerger; + readonly #circular: CircularAnalyzer; + readonly #async: AsyncAnalyzer; + readonly #validator: MetaValidator; + + constructor( + private readonly executors: Map>, + private readonly options: SealOptions, + meta: MetaStore = metaStore, + ) { + this.fp = compileCache.fingerprint(options); + // Composition root for one seal run: the analyzers/merger/validator are constructed here in the + // constructor body (after parameter properties + the `resolve` field initializer exist) so each + // collaborator receives its dependency. Order matters — merger before its dependents. + this.#merger = new InheritanceMerger(meta); + this.#circular = new CircularAnalyzer(this.#merger); + this.#async = new AsyncAnalyzer(this.resolve, this.#merger); + this.#validator = new MetaValidator(meta); } - if (t.discriminator) { - for (const sub of t.discriminator.subTypes) { - out.push(sub.value); - } - } - if (out.length === 0 && t.fn) { - const result = t.fn(); - if (result === Map || result === Set) { - const cv = t.collectionValue?.(); - if (typeof cv === 'function' && !PRIMITIVE_CTORS.has(cv)) { - out.push(cv); + + /** + * Seal every class in `registry`. Transactional: on any failure every class sealed by this run is + * rolled back. Clears `registry` on success. + */ + run(registry: Set): void { + try { + for (const Class of registry) { + this.sealOne(Class); } - } else { - const resolved = Array.isArray(result) ? (result as unknown[])[0] : result; - if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { - out.push(resolved as Function); + } catch (e) { + // Roll back exactly what this run inserted (fresh placeholders + cache reuses), leaving any + // pre-existing executor untouched — a self-contained transaction that does not assume the map + // was empty at entry. + for (const Class of this.inserted) { + this.executors.delete(Class); } + throw e; } - } - return out; -} - -/** - * Seal every class in `registry` with `options`. The core used by `new Baker().seal()`. - * Transactional: on any failure every class sealed by this call is rolled back. Clears `registry` - * on success. - * - * Executors are written into `executors` (the calling Baker's own map), never onto the class, so two - * bakers sealing the same class each compile their own executor with their own options — apps never - * mix. Within one baker's seal, an already-present class is reused as-is (circular-ref guard + shared - * nested DTO dedup for that baker). - */ -// ───────────────────────────────────────────────────────────────────────────── -// (class, config) executor cache — content-addressed sharing across bakers -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A class's generated executor is a pure function of (its RAW metadata, the seal config). So two - * bakers with the SAME config compile byte-identical executors — memoize globally by - * `(class, configFingerprint)` so they share one executor (compiled once) instead of N copies, while - * different-config bakers stay isolated (distinct fingerprint → distinct entry). Behaviour is - * unchanged either way: executors are pure (no per-call mutable state), so sharing is invisible. - * - * `WeakMap` so an entry is reclaimed when its class is GC'd. The inner `Map` retains one - * executor per (class, config) for the class's lifetime — bounded for a fixed DTO/config set (the - * intended "seal once at startup" usage); a program that dynamically generates classes/configs would - * grow it without eviction. - */ -let compileCache = new WeakMap>>(); - -/** Canonical fingerprint of a SealOptions — the 5 booleans in fixed order. `{}` and a fully-defaulted - * object both map to "00000", so `new Baker()` and `new Baker({})` share a cache key. */ -function configFingerprint(o: SealOptions): string { - return ( - (o.enableImplicitConversion ? '1' : '0') + - (o.exposeDefaultValues ? '1' : '0') + - (o.stopAtFirstError ? '1' : '0') + - (o.whitelist ? '1' : '0') + - (o.debug ? '1' : '0') - ); -} - -function getCached(cls: Function, fp: string): SealedExecutors | undefined { - return compileCache.get(cls)?.get(fp); -} - -/** Test-only: drop a single class's cached executors so a re-seal recompiles it. */ -function clearCached(cls: Function): void { - compileCache.delete(cls); -} -/** - * Test-only: drop the ENTIRE cache. Used by `unseal()` so a test that re-seals classes starts from a - * clean slate — a whole-cache reset (vs per-class) is the only way to avoid the partial-clear state - * where a cached root still references a nested whose entry was dropped (a root + its nested are always - * compiled together, so they must be invalidated together). - */ -function clearAllCached(): void { - compileCache = new WeakMap(); -} - -function setCached(cls: Function, fp: string, exec: SealedExecutors): void { - let m = compileCache.get(cls); - if (m === undefined) { - m = new Map(); - compileCache.set(cls, m); - } - m.set(fp, exec); -} - -function sealRegistry( - registry: Set, - options: SealOptions, - executors: Map>, -): void { - const fp = configFingerprint(options); - const sealed = new Set(); - try { - for (const Class of registry) { - sealOne(Class, executors, fp, options, sealed); + // Commit only the classes compiled by THIS run to the shared cache (cache hits are already there). + for (const [Class, executor] of this.sealed) { + compileCache.set(Class, this.fp, executor); } - } catch (e) { - // Roll back the whole map — seal is one-shot, so `executors` was empty at entry; clearing it - // removes both freshly-compiled placeholders and any cache-reused entries from this attempt. - executors.clear(); - throw e; + registry.clear(); } - // Commit only the classes compiled by THIS seal to the shared cache (cache hits are already there). - for (const Class of sealed) { - setCached(Class, fp, executors.get(Class)!); - } - registry.clear(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// sealOne() — seal an individual class (§4.1) -// ───────────────────────────────────────────────────────────────────────────── + // ─────────────────────────────────────────────────────────────────────────── + // sealOne() — seal an individual class + // ─────────────────────────────────────────────────────────────────────────── -function sealOne( - Class: Function, - executors: Map>, - fp: string, - options?: SealOptions, - sealedAcc?: Set, -): void { - if (executors.has(Class)) { - // Already in THIS baker's map (placeholder mid-seal, freshly compiled, or cache-reused). Prevents - // infinite recursion on circular references and dedups a shared nested DTO within this seal. - return; - } + private sealOne(Class: Function): void { + if (this.executors.has(Class)) { + // Already in THIS baker's map (placeholder mid-seal, freshly compiled, or cache-reused). Prevents + // infinite recursion on circular references and dedups a shared nested DTO within this seal. + return; + } - // Cache hit: another baker already compiled this class under the SAME config — reuse its executor. - const cached = getCached(Class, fp); - if (cached !== undefined) { - executors.set(Class, cached); - // Seed this baker's map with the transitive nested classes too, so resolving a nested-only DTO as - // a TOP-LEVEL argument (app.deserialize(Nested, …)) behaves identically whether this baker compiled - // fresh or hit the cache. Each nested is itself a cache hit (it was committed when the root was - // first sealed); the executors.has guard above terminates circular graphs. - if (cached.merged) { - for (const meta of Object.values(cached.merged)) { - for (const nested of nestedClassesOf(meta)) { - sealOne(nested, executors, fp, options, sealedAcc); + // Cache hit: another baker already compiled this class under the SAME config — reuse its executor. + const cached = compileCache.get(Class, this.fp); + if (cached !== undefined) { + this.executors.set(Class, cached); + this.inserted.add(Class); + // Seed this baker's map with the transitive nested classes too, so resolving a nested-only DTO as + // a TOP-LEVEL argument (app.deserialize(Nested, …)) behaves identically whether this baker compiled + // fresh or hit the cache. Each nested is itself a cache hit (it was committed when the root was + // first sealed); the executors.has guard above terminates circular graphs. + if (cached.merged) { + for (const meta of Object.values(cached.merged)) { + for (const nested of this.#async.nestedClassesOf(meta)) { + this.sealOne(nested); + } } } + return; } - return; - } - - // 0. Register placeholder — prevent infinite recursion on circular references - const placeholder = circularPlaceholder(Class.name); - executors.set(Class, placeholder); - const resolve = (cls: Function): SealedExecutors | undefined => executors.get(cls); + // 0. Register placeholder — prevent infinite recursion on circular references + const placeholder = new CircularPlaceholder(Class.name); + this.executors.set(Class, placeholder); + this.inserted.add(Class); - try { - // 1. Merge inheritance metadata - const merged = mergeInheritance(Class); + try { + // 1. Merge inheritance metadata + const merged = this.#merger.merge(Class); - // 1a. Banned field name check — prevent prototype pollution (C5) - for (const key of Object.keys(merged)) { - if (BANNED_FIELD_NAMES.has(key)) { - throw new BakerError(`${Class.name}: field name '${key}' is not allowed (reserved property name)`); - } - } - - // 1b. TypeDef normalization — resolve @Type/@Field type fn(), detect arrays, auto-infer nested DTOs - // Prevent original RAW mutation: copy type/flags before mutating (C-16 root fix) - for (const [key, meta] of Object.entries(merged)) { - if (!meta.type?.fn) { - continue; - } - let typeResult: unknown; - try { - typeResult = meta.type.fn(); - } catch (e) { - throw new BakerError(`${Class.name}.${key}: type function threw: ${(e as Error).message}`, { cause: e }); - } - - // Detect Map/Set collection - if (typeResult === Map || typeResult === Set) { - const collection = typeResult === Map ? CollectionType.Map : CollectionType.Set; - const typeCopy = { ...meta.type, collection, isArray: false }; - // collectionValue thunk → cache resolvedCollectionValue - if (meta.type.collectionValue) { - let valCls: unknown; - try { - valCls = meta.type.collectionValue(); - } catch (e) { - throw new BakerError(`${Class.name}.${key}: collectionValue function threw: ${(e as Error).message}`, { cause: e }); - } - if (valCls != null && typeof valCls === 'function' && !PRIMITIVE_CTORS.has(valCls as Function)) { - typeCopy.resolvedCollectionValue = valCls as ClassCtor; - } + // 1a. Banned field name check — prevent prototype pollution (C5) + for (const key of Object.keys(merged)) { + if (RESERVED_PROPERTY_NAMES.has(key)) { + throw new BakerError(`${Class.name}: field name '${key}' is not allowed (reserved property name)`); } - merged[key] = { ...meta, type: typeCopy }; - continue; } - const isArray = Array.isArray(typeResult); - const resolved = isArray ? (typeResult as unknown[])[0] : typeResult; - if (resolved == null || typeof resolved !== 'function') { - throw new BakerError( - `${Class.name}: @Type/@Field type must return a constructor or [constructor], got ${String(resolved)}`, - ); - } - // Copy type object before mutating — preserve original RAW type reference - const typeCopy = { ...meta.type, isArray }; - if (!PRIMITIVE_CTORS.has(resolved)) { - typeCopy.resolvedClass = resolved as ClassCtor; - // Automatically set validateNested flags for DTO classes - if (!meta.flags.validateNested || !meta.flags.validateNestedEach) { - meta.flags = { ...meta.flags }; - if (!meta.flags.validateNested) { - meta.flags.validateNested = true; - } - if (isArray && !meta.flags.validateNestedEach) { - meta.flags.validateNestedEach = true; - } - } - } - merged[key] = { ...meta, type: typeCopy }; - } + // 1b. TypeDef normalization — resolve @Type/@Field type fn(), detect arrays, auto-infer nested DTOs. + // Copies the shared RAW `type` before mutating (C-16) and mutates the per-seal-cloned `flags`. + normalizeTypeDefs(merged, Class.name); - // 2. Static validation of @Expose stacks (throws BakerError on failure) - validateExposeStacks(merged, Class.name); + // 2. Static validation of @Expose stacks (throws BakerError on failure) + validateExposeStacks(merged, Class.name); - // 2b. W2: seal-time invariant checks (D7 discriminator/Set·Map + D9 async-in-sync) - validateMeta(Class, merged); + // 2b. W2: seal-time invariant checks (D7 discriminator/Set·Map + D9 async-in-sync) + this.#validator.validateShape(Class, merged); - // 3. Static analysis for circular references - const needsCircularCheck = analyzeCircular(Class); + // 3. Static analysis for circular references + const needsCircularCheck = this.#circular.analyze(Class); - // 4. Seal nested @Type referenced DTOs first (recursive) — uses resolvedClass / resolvedCollectionValue - for (const meta of Object.values(merged)) { - if (meta.type?.resolvedClass) { - sealOne(meta.type.resolvedClass, executors, fp, options, sealedAcc); - } - if (meta.type?.resolvedCollectionValue) { - sealOne(meta.type.resolvedCollectionValue, executors, fp, options, sealedAcc); - } - if (meta.type?.discriminator) { - for (const sub of meta.type.discriminator.subTypes) { - sealOne(sub.value, executors, fp, options, sealedAcc); + // 4. Seal nested @Type referenced DTOs first (recursive). `nestedClassesOf` is the single source + // of truth for "which classes does a field reference" — the same helper analyzeAsync uses in + // step 5, so the two traversals cannot drift (e.g. a new reference kind added in one only). + for (const meta of Object.values(merged)) { + for (const nested of this.#async.nestedClassesOf(meta)) { + this.sealOne(nested); } } - } - - // 5. Async analysis - const isAsync = analyzeAsync(merged, Direction.Deserialize, resolve); - const isSerializeAsync = analyzeAsync(merged, Direction.Serialize, resolve); - // 6. Generate deserialize executor code - const deserializeExecutor = buildDeserializeCode(Class, merged, options, needsCircularCheck, isAsync, resolve); - - // 6b. Generate validate-only executor code (no Object.create, no assignments) - const validateExecutor = buildValidateCode(Class, merged, options, needsCircularCheck, isAsync, resolve); - - // 7. Generate serialize executor code - const serializeExecutor = buildSerializeCode(Class, merged, options, isSerializeAsync, resolve); + // 5. Async analysis + const isAsync = this.#async.analyze(merged, Direction.Deserialize); + const isSerializeAsync = this.#async.analyze(merged, Direction.Serialize); + + // 6. Generate deserialize executor code + const deserializeExecutor = buildDeserializeCode(Class, merged, this.options, needsCircularCheck, isAsync, this.resolve); + + // 6b. Generate validate-only executor code (no Object.create, no assignments) + const validateExecutor = buildValidateCode(Class, merged, this.options, needsCircularCheck, isAsync, this.resolve); + + // 7. Generate serialize executor code + const serializeExecutor = buildSerializeCode(Class, merged, this.options, isSerializeAsync, this.resolve); + + // 8. Replace placeholder with actual executor in-place (Object.assign preserves reference integrity) + Object.assign(placeholder, { + deserialize: deserializeExecutor, + serialize: serializeExecutor, + validate: validateExecutor, + isAsync: isAsync, + isSerializeAsync: isSerializeAsync, + merged: merged, + }); + } catch (e) { + // Self-clean this class's placeholder so a failed seal leaves no broken state — + // including nested DTOs reached by recursion that are not in the registry. + this.executors.delete(Class); + throw e; + } - // 8. Replace placeholder with actual executor in-place (Object.assign preserves reference integrity) - Object.assign(placeholder, { - deserialize: deserializeExecutor, - serialize: serializeExecutor, - validate: validateExecutor, - isAsync: isAsync, - isSerializeAsync: isSerializeAsync, - merged: merged, - }); - } catch (e) { - // Self-clean this class's placeholder so a failed seal leaves no broken state — - // including nested DTOs reached by recursion that are not in the registry. - executors.delete(Class); - throw e; + // Record success (class → its now-filled executor) so the run can commit every sealed class + // (including nested DTOs reached by recursion) once the whole operation succeeds. Committing here + // would be premature: a later failure must roll back. + this.sealed.set(Class, placeholder); } - - // Record success so the caller can freeze + track every sealed class (including nested - // DTOs reached by recursion) once the whole operation succeeds. Freezing here would be - // premature: a later failure must roll back, and a frozen RAW cannot be re-sealed. - sealedAcc?.add(Class); } -// ───────────────────────────────────────────────────────────────────────────── -// mergeInheritance() — merge inheritance metadata (§4.2) -// ───────────────────────────────────────────────────────────────────────────── - /** - * Merges RAW metadata child-first along the prototype chain of Class. - * - * Merge rules: - * - validation: union merge (both parent and child apply, duplicate rules removed) - * - transform: child takes priority, inherits from parent if absent in child - * - expose: child takes priority, inherits from parent if absent in child - * - exclude: child takes priority, inherits from parent if absent in child - * - type: child takes priority, inherits from parent if absent in child - * - flags: child takes priority, only missing flags are supplemented from parent + * Seal every class in `registry` with `options`, writing executors into `executors`. The core used by + * `new Baker().seal()` — a thin entry point over one {@link SealRun}. */ -function mergeInheritance(Class: Function): RawClassMeta { - // Collect classes with RAW along the prototype chain (array order: child first) - const chain: Function[] = []; - let current: Function | null = Class; - while (current && current !== Object) { - if (hasRawOwn(current)) { - chain.push(current); - } - const proto = Object.getPrototypeOf(current); - current = proto === current ? null : proto; - } - - // child-first merge - const merged: RawClassMeta = Object.create(null) as RawClassMeta; - - for (const ctor of chain) { - const raw = getRaw(ctor)!; - for (const [key, meta] of Object.entries(raw)) { - if (!merged[key]) { - // Always copy each meta (incl. a fresh `flags` object and fresh arrays). RAW is shared - // across bakers and re-sealed per baker; normalization in sealOne mutates `meta.flags`, - // so it must operate on a copy and never touch the pristine RAW. - merged[key] = { - ...meta, - validation: [...meta.validation], - transform: [...meta.transform], - expose: [...meta.expose], - exclude: meta.exclude, - type: meta.type, - flags: { ...meta.flags }, - }; - } else { - // Already exists in child → independent merge per category (§4.2) - const m = merged[key]; - const p = meta; - - // validation: union merge by ruleName — child overrides parent for the same rule name (N-6) - for (const rd of p.validation) { - if (!m.validation.some(d => d.rule.ruleName === rd.rule.ruleName)) { - m.validation.push(rd); - } - } - - // transform: inherit from parent if absent in child - if (m.transform.length === 0 && p.transform.length > 0) { - m.transform = [...p.transform]; - } - - // expose: inherit from parent if absent in child - if (m.expose.length === 0 && p.expose.length > 0) { - m.expose = [...p.expose]; - } - - // exclude: inherit from parent if absent in child - if (m.exclude === null && p.exclude !== null) { - m.exclude = p.exclude; - } - - // type: inherit from parent if absent in child - if (m.type === null && p.type !== null) { - m.type = p.type; - } - - // flags: child takes priority, only supplement missing flags from parent - const mf = m.flags; - const pf = p.flags; - if (pf.isOptional !== undefined && mf.isOptional === undefined) { - mf.isOptional = pf.isOptional; - } - if (pf.isDefined !== undefined && mf.isDefined === undefined) { - mf.isDefined = pf.isDefined; - } - if (pf.validateIf !== undefined && mf.validateIf === undefined) { - mf.validateIf = pf.validateIf; - } - if (pf.isNullable !== undefined && mf.isNullable === undefined) { - mf.isNullable = pf.isNullable; - } - if (pf.validateNested !== undefined && mf.validateNested === undefined) { - mf.validateNested = pf.validateNested; - } - if (pf.validateNestedEach !== undefined && mf.validateNestedEach === undefined) { - mf.validateNestedEach = pf.validateNestedEach; - } - } - } - } - - return merged; +function sealRegistry(registry: Set, options: SealOptions, executors: Map>): void { + new SealRun(executors, options).run(registry); } -export { sealRegistry, mergeInheritance, circularPlaceholder, getCached, configFingerprint, clearCached, clearAllCached }; +export { sealRegistry }; diff --git a/src/seal/serialize-builder.spec.ts b/src/seal/serialize-builder.spec.ts index 5341652..88ad58d 100644 --- a/src/seal/serialize-builder.spec.ts +++ b/src/seal/serialize-builder.spec.ts @@ -1,9 +1,10 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { RuntimeOptions } from '../interfaces'; -import type { RawClassMeta, SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { RawClassMeta } from '../metadata/interfaces'; +import type { SealedExecutors } from './interfaces'; -import { CollectionType } from '../enums'; +import { CollectionType } from '../metadata/enums'; import { isString } from '../rules/typechecker'; import { buildSerializeCode } from './serialize-builder'; @@ -318,7 +319,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => AddressDto }, + type: { fn: () => AddressDto, resolvedClass: AddressDto }, flags: { validateNested: true }, }, }; @@ -352,7 +353,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => ItemDto }, + type: { fn: () => ItemDto, resolvedClass: ItemDto }, flags: { validateNested: true }, }, }; @@ -385,7 +386,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => ProfileDto }, + type: { fn: () => ProfileDto, resolvedClass: ProfileDto }, flags: { validateNested: true, isOptional: true }, }, }; @@ -442,7 +443,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => AsyncItemDto }, + type: { fn: () => AsyncItemDto, resolvedClass: AsyncItemDto }, flags: { validateNested: true }, }, }; @@ -476,7 +477,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => AsyncItemDto2 }, + type: { fn: () => AsyncItemDto2, resolvedClass: AsyncItemDto2 }, flags: { validateNested: true }, }, }; diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index d7fcaab..8bcf722 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -1,446 +1,432 @@ -import type { SealOptions, RuntimeOptions } from '../interfaces'; -import type { RawClassMeta, RawPropertyMeta, SealedExecutors, TransformDef } from '../types'; - -import { CollectionType } from '../enums'; -import { BakerError } from '../errors'; -import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; +import type { RuntimeOptions } from '../common'; +import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata'; +import type { SealOptions, SealedExecutors } from './interfaces'; + +import { BakerError, Direction } from '../common'; +import { CollectionType } from '../metadata'; +import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; +import { SER_GEN as GEN } from './constants'; + +// Field rename + expose-group resolution (both directions) live in codegen-utils as the single +// source of truth — see resolveExposeName / resolveExposeGroups. + +/** Length of a constructor's prototype chain — used to order discriminator subtypes most-derived first. */ +function prototypeDepth(ctor: Function): number { + let depth = 0; + let proto: unknown = Object.getPrototypeOf(ctor); + while (typeof proto === 'function') { + depth += 1; + proto = Object.getPrototypeOf(proto); + } + return depth; +} // ───────────────────────────────────────────────────────────────────────────── -// Generated variable name prefixes — centralised to prevent typo-related bugs +// SerializeBuilder — new Function-based serialize executor generation (serialize pipeline) // ───────────────────────────────────────────────────────────────────────────── -const GEN = { - out: '__bk$out', - fieldVal: '__bk$fv_', - groups: '__bk$groups', - group0: '__bk$group0', - groupsSet: '__bk$groupsSet', - setArr: '__bk$sa', - setItem: '__bk$si', - mapObj: '__bk$m', - mapEntry: '__bk$me', - serResult: '__bk$sr', - outItem: '__bk$out_item', - discArr: '__bk$da', - discIdx: '__bk$di', - nestedArr: '__bk$na', - nestedIdx: '__bk$ni', - nestedItem: '__bk$nitem', -} as const; +/** + * Builds a serialize executor for a single sealed class. + * + * State threaded through codegen (refs/execs/options/isAsync/resolve/…) lives as + * instance fields; the per-field/per-expression generators are methods that read + * from `this`, so data flows from a single source of truth rather than being + * passed around. + * + * Assumes no validation — the generated executor always returns + * Record. + */ +class SerializeBuilder { + /** Runtime references injected into the generated function (transform fns, classes). */ + private readonly refs: unknown[] = []; + /** Nested sealed executors injected into the generated function. */ + private readonly execs: SealedExecutors[] = []; + + private readonly Class: Function; + private readonly merged: RawClassMeta; + private readonly options: SealOptions | undefined; + private readonly isAsync: boolean; + private readonly resolve: (cls: Function) => SealedExecutors | undefined; + + constructor( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, + ) { + this.Class = Class; + this.merged = merged; + this.options = options; + this.isAsync = isAsync; + this.resolve = resolve; + } -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── + /** Generate and instantiate the serialize executor. */ + build(): (instance: T, opts?: RuntimeOptions) => Record | Promise> { + // ── Code generation ──────────────────────────────────────────────────────── -/** Determine the output key for serialize direction */ -function getSerializeOutputKey(fieldKey: string, exposeStack: RawPropertyMeta['expose']): string { - // serializeOnly @Expose with name → use that name - const serDef = exposeStack.find(e => e.serializeOnly && e.name); - if (serDef) { - return serDef.name!; - } - // Non-directional @Expose with name → use for both directions - const biDef = exposeStack.find(e => !e.deserializeOnly && !e.serializeOnly && e.name); - if (biDef) { - return biDef.name!; - } - return fieldKey; -} + let body = "'use strict';\n"; + body += `var ${GEN.out} = {};\n`; -/** Determine expose groups for serialize direction — returns undefined (no restriction) if any unconditional expose entry exists */ -function getSerializeExposeGroups(exposeStack: RawPropertyMeta['expose']): string[] | undefined { - // Single-pass: scan once, build set of groups; bail out as soon as we see an unconditional entry. - let all: Set | null = null; - for (const e of exposeStack) { - if (e.deserializeOnly) { - continue; - } - if (!e.groups || e.groups.length === 0) { - return undefined; + // Groups variable — only when fields referencing groups exist. for-in + early break (matches the + // deserialize builder): no Object.values array or per-element closure allocation at seal time. + let hasGroupsField = false; + for (const fk in this.merged) { + const meta = this.merged[fk]; + if (meta === undefined) { + continue; + } + const groups = resolveExposeGroups(meta.expose, Direction.Serialize); + if (groups && groups.length > 0) { + hasGroupsField = true; + break; + } } - if (all === null) { - all = new Set(); + if (hasGroupsField) { + body += `var ${GEN.groups} = opts && opts.groups;\n`; + body += `var ${GEN.group0} = ${GEN.groups} && ${GEN.groups}.length === 1 ? ${GEN.groups}[0] : null;\n`; + body += `var ${GEN.groupsSet} = ${GEN.groups} && ${GEN.groups}.length > 1 ? new Set(${GEN.groups}) : null;\n`; } - for (const g of e.groups) { - all.add(g); + + for (const [fieldKey, meta] of Object.entries(this.merged)) { + body += this.generateFieldCode(fieldKey, meta); } - } - return all === null ? undefined : [...all]; -} -/** - * Build serialize-direction transform expression. - * Serialize direction reverses declaration order (codec stack unwrapping). - */ -function buildSerializeTransformExpr( - inputExpr: string, - fieldKey: string, - serTransforms: TransformDef[], - refs: unknown[], -): string | null { - if (serTransforms.length === 0) { - return null; - } - if (serTransforms.length === 1) { - const td = serTransforms[0]!; - const refIdx = refs.length; - refs.push(td.fn); - const callExpr = `refs[${refIdx}]({value:${inputExpr},key:${JSON.stringify(fieldKey)},obj:instance})`; - return td.isAsync ? `(await ${callExpr})` : callExpr; - } - if (serTransforms.length === 2) { - const td1 = serTransforms[1]!; - const td0 = serTransforms[0]!; - const refIdx1 = refs.length; - refs.push(td1.fn); - const refIdx0 = refs.length; - refs.push(td0.fn); - const call1 = `refs[${refIdx1}]({value:${inputExpr},key:${JSON.stringify(fieldKey)},obj:instance})`; - const expr1 = td1.isAsync ? `(await ${call1})` : call1; - const call0 = `refs[${refIdx0}]({value:${expr1},key:${JSON.stringify(fieldKey)},obj:instance})`; - return td0.isAsync ? `(await ${call0})` : call0; - } + body += `return ${GEN.out};\n`; - // Walk serTransforms backwards in place — avoids [...arr].reverse() clone allocation - let valueExpr = inputExpr; - for (let k = serTransforms.length - 1; k >= 0; k -= 1) { - const td = serTransforms[k]!; - const refIdx = refs.length; - refs.push(td.fn); - const callExpr = `refs[${refIdx}]({value:${valueExpr},key:${JSON.stringify(fieldKey)},obj:instance})`; - valueExpr = td.isAsync ? `(await ${callExpr})` : callExpr; - } - return valueExpr; -} + // sourceURL + // Sanitize class name so it cannot inject newlines / */ that would break out of the comment. + const safeClsName = this.Class.name.replace(/[^\w$.-]/g, '_'); + body += `//# sourceURL=baker://${safeClsName}/serialize\n`; -/** - * Generate transform chain code to apply after nested/collection serialize. - * Reads the current value from outputTarget, chains transforms, writes back. - */ -function buildPostNestedTransformCode( - outputTarget: string, - fieldKey: string, - serTransforms: TransformDef[], - refs: unknown[], -): string { - const transformed = buildSerializeTransformExpr(outputTarget, fieldKey, serTransforms, refs); - return transformed ? `\n${outputTarget} = ${transformed};` : ''; -} + // ── Execute new Function ─────────────────────────────────────────────────── -// ───────────────────────────────────────────────────────────────────────────── -// buildSerializeCode — new Function-based serialize executor generation (§4.3 serialize pipeline) -// ───────────────────────────────────────────────────────────────────────────── + const fnKeyword = this.isAsync ? 'async function' : 'function'; + const executor = new Function('refs', 'execs', 'BakerError', `return ${fnKeyword}(instance, opts) { ` + body + ' }')( + this.refs, + this.execs, + BakerError, + ) as (instance: T, opts?: RuntimeOptions) => Record | Promise>; -/** - * Generate serialize executor code. - * Assumes no validation — always returns Record (§4.3). - */ -function buildSerializeCode( - Class: Function, - merged: RawClassMeta, - options: SealOptions | undefined, - isAsync: boolean, - resolve: (cls: Function) => SealedExecutors | undefined, -): (instance: T, opts?: RuntimeOptions) => Record | Promise> { - const refs: unknown[] = []; - const execs: SealedExecutors[] = []; - - // ── Code generation ──────────────────────────────────────────────────────── - - let body = "'use strict';\n"; - body += `var ${GEN.out} = {};\n`; - - // Groups variable — only when fields referencing groups exist - const hasGroupsField = Object.values(merged).some(meta => { - const groups = getSerializeExposeGroups(meta.expose); - return groups && groups.length > 0; - }); - if (hasGroupsField) { - body += `var ${GEN.groups} = opts && opts.groups;\n`; - body += `var ${GEN.group0} = ${GEN.groups} && ${GEN.groups}.length === 1 ? ${GEN.groups}[0] : null;\n`; - body += `var ${GEN.groupsSet} = ${GEN.groups} && ${GEN.groups}.length > 1 ? new Set(${GEN.groups}) : null;\n`; + return executor; } - for (const [fieldKey, meta] of Object.entries(merged)) { - body += generateSerializeFieldCode(fieldKey, meta, refs, execs, isAsync, resolve, options, Class.name); + /** + * Resolve a nested class's sealed executor. seal() seals every nested DTO (step 4) before serialize + * codegen (step 7), so this is always present; throwing on `undefined` turns a would-be runtime + * "Cannot read 'serialize' of undefined" into a clear seal-time error and removes the cast at call sites. + */ + private resolveExecutor(cls: Function): SealedExecutors { + const sealed = this.resolve(cls); + if (sealed === undefined) { + throw new BakerError(`${this.Class.name}: nested class '${cls.name}' was not sealed before serialize codegen.`); + } + return sealed; } - body += `return ${GEN.out};\n`; - - // sourceURL (§4.9) - // Sanitize class name so it cannot inject newlines / */ that would break out of the comment. - const safeClsName = Class.name.replace(/[^\w$.-]/g, '_'); - body += `//# sourceURL=baker://${safeClsName}/serialize\n`; - - // ── Execute new Function ─────────────────────────────────────────────────── + // ─────────────────────────────────────────────────────────────────────────── + // Per-field serialize code generation + // ─────────────────────────────────────────────────────────────────────────── - const fnKeyword = isAsync ? 'async function' : 'function'; - const executor = new Function('refs', 'execs', 'BakerError', `return ${fnKeyword}(instance, opts) { ` + body + ' }')( - refs, - execs, - BakerError, - ) as (instance: T, opts?: RuntimeOptions) => Record | Promise>; + private generateFieldCode(fieldKey: string, meta: RawPropertyMeta): string { + const className = this.Class.name; + const options = this.options; - return executor; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Per-field serialize code generation -// ───────────────────────────────────────────────────────────────────────────── + // ⓪ Exclude serializeOnly / bidirectional → skip + if (meta.exclude) { + if (!meta.exclude.deserializeOnly) { + if (options?.debug) { + const reason = meta.exclude.serializeOnly ? 'serializeOnly' : 'bidirectional'; + return `// [baker] field ${JSON.stringify(fieldKey)} excluded (${reason} @Exclude)\n`; + } + return ''; + } + } -function generateSerializeFieldCode( - fieldKey: string, - meta: RawPropertyMeta, - refs: unknown[], - execs: SealedExecutors[], - isAsync: boolean, - resolve: (cls: Function) => SealedExecutors | undefined, - options?: SealOptions, - className: string = '', -): string { - // ⓪ Exclude serializeOnly / bidirectional → skip - if (meta.exclude) { - if (!meta.exclude.deserializeOnly) { + // Expose: if all @Expose entries are deserializeOnly, skip for serialize + if (meta.expose.length > 0 && meta.expose.every(e => e.deserializeOnly)) { if (options?.debug) { - const reason = meta.exclude.serializeOnly ? 'serializeOnly' : 'bidirectional'; - return `// [baker] field ${JSON.stringify(fieldKey)} excluded (${reason} @Exclude)\n`; + return `// [baker] field ${JSON.stringify(fieldKey)} excluded (all @Expose entries are deserializeOnly)\n`; } return ''; } - } - // Expose: if all @Expose entries are deserializeOnly, skip for serialize - if (meta.expose.length > 0 && meta.expose.every(e => e.deserializeOnly)) { - if (options?.debug) { - return `// [baker] field ${JSON.stringify(fieldKey)} excluded (all @Expose entries are deserializeOnly)\n`; - } - return ''; - } - - const outputKey = getSerializeOutputKey(fieldKey, meta.expose); - const exposeGroups = getSerializeExposeGroups(meta.expose); - const sk = sanitizeKey(fieldKey); - const fieldVal = `${GEN.fieldVal}${sk}`; + const outputKey = resolveExposeName(fieldKey, meta.expose, Direction.Serialize); + const exposeGroups = resolveExposeGroups(meta.expose, Direction.Serialize); + const sk = sanitizeKey(fieldKey); + const fieldVal = `${GEN.fieldVal}${sk}`; - let fieldCode = ''; - fieldCode += `var ${fieldVal} = instance[${JSON.stringify(fieldKey)}];\n`; - - // groups check wrap (§4.5) - let fieldStart = ''; - let fieldEnd = ''; - if (exposeGroups && exposeGroups.length > 0) { - fieldStart = `if ((${GEN.group0} !== null || ${GEN.groupsSet}) && (${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, exposeGroups)})) {\n`; - fieldEnd = '}\n'; - } + let fieldCode = ''; + fieldCode += `var ${fieldVal} = instance[${JSON.stringify(fieldKey)}];\n`; - let innerCode = ''; - - // ② @IsOptional → skip output if undefined (§4.3 serialize step 2) - const useOptionalGuard = meta.flags.isOptional; - - // Collect serialize-direction transforms once - const serTransforms = meta.transform.filter(td => !td.options?.deserializeOnly); + // groups check wrap + let fieldStart = ''; + let fieldEnd = ''; + if (exposeGroups && exposeGroups.length > 0) { + fieldStart = `if ((${GEN.group0} !== null || ${GEN.groupsSet}) && (${buildGroupsHasExpr(GEN.group0, GEN.groupsSet, exposeGroups)})) {\n`; + fieldEnd = '}\n'; + } - // ③a Collection (Map/Set) serialize — Set → Array, Map → plain object - if (meta.type?.collection) { - const outputTarget = `${GEN.out}[${JSON.stringify(outputKey)}]`; - const collection = meta.type.collection; - let nestedCode: string; - - if (collection === CollectionType.Set) { - if (meta.type.resolvedCollectionValue) { - const nestedSealed = resolve(meta.type.resolvedCollectionValue) as SealedExecutors; - const execIdx = execs.length; - execs.push(nestedSealed); - if (isAsync) { - nestedCode = `{ var __ser_ps = []; for (var __ser_item of ${fieldVal}) { __ser_ps.push(__ser_item == null ? __ser_item : execs[${execIdx}].serialize(__ser_item, opts)); } ${outputTarget} = await Promise.all(__ser_ps); }`; + let innerCode = ''; + + // ② @IsOptional → skip output if undefined (serialize step 2) + const useOptionalGuard = meta.flags.isOptional; + + // Collect serialize-direction transforms once + const serTransforms = meta.transform.filter(td => !td.options?.deserializeOnly); + + // ③a Collection (Map/Set) serialize — Set → Array, Map → plain object + if (meta.type?.collection) { + const outputTarget = `${GEN.out}[${JSON.stringify(outputKey)}]`; + const collection = meta.type.collection; + let nestedCode: string; + + if (collection === CollectionType.Set) { + if (meta.type.resolvedCollectionValue) { + const nestedSealed = this.resolveExecutor(meta.type.resolvedCollectionValue); + const execIdx = this.execs.length; + this.execs.push(nestedSealed); + if (this.isAsync) { + nestedCode = `{ var __ser_ps${sk} = []; for (var __ser_item${sk} of ${fieldVal}) { __ser_ps${sk}.push(__ser_item${sk} == null ? __ser_item${sk} : execs[${execIdx}].serialize(__ser_item${sk}, opts)); } ${outputTarget} = await Promise.all(__ser_ps${sk}); }`; + } else { + nestedCode = `var ${GEN.setArr}${sk} = [];\n`; + nestedCode += ` for (var ${GEN.setItem}${sk} of ${fieldVal}) {\n`; + nestedCode += ` ${GEN.setArr}${sk}.push(${GEN.setItem}${sk} == null ? ${GEN.setItem}${sk} : execs[${execIdx}].serialize(${GEN.setItem}${sk}, opts));\n`; + nestedCode += ` }\n`; + nestedCode += ` ${outputTarget} = ${GEN.setArr}${sk};`; + } } else { - nestedCode = `var ${GEN.setArr} = [];\n`; - nestedCode += ` for (var ${GEN.setItem} of ${fieldVal}) {\n`; - nestedCode += ` ${GEN.setArr}.push(${GEN.setItem} == null ? ${GEN.setItem} : execs[${execIdx}].serialize(${GEN.setItem}, opts));\n`; - nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.setArr};`; + nestedCode = `${outputTarget} = [...${fieldVal}];`; } } else { - nestedCode = `${outputTarget} = [...${fieldVal}];`; + // Map → plain object (W8: keys must be strings — throw otherwise) + const keyCheck = `if (typeof ${GEN.mapEntry}${sk}[0] !== 'string') { throw new BakerError(${JSON.stringify(className)} + ': Map field ' + ${JSON.stringify(fieldKey)} + ' has non-string key (' + typeof ${GEN.mapEntry}${sk}[0] + '). Map serialization requires string keys.'); }\n `; + if (meta.type.resolvedCollectionValue) { + const nestedSealed = this.resolveExecutor(meta.type.resolvedCollectionValue); + const execIdx = this.execs.length; + this.execs.push(nestedSealed); + const awaitKw = this.isAsync ? 'await ' : ''; + nestedCode = `var ${GEN.mapObj}${sk} = Object.create(null);\n`; + nestedCode += ` for (var ${GEN.mapEntry}${sk} of ${fieldVal}) {\n`; + nestedCode += ` ${keyCheck}`; + nestedCode += `${GEN.mapObj}${sk}[${GEN.mapEntry}${sk}[0]] = ${GEN.mapEntry}${sk}[1] == null ? ${GEN.mapEntry}${sk}[1] : ${awaitKw}execs[${execIdx}].serialize(${GEN.mapEntry}${sk}[1], opts);\n`; + nestedCode += ` }\n`; + nestedCode += ` ${outputTarget} = ${GEN.mapObj}${sk};`; + } else { + nestedCode = `var ${GEN.mapObj}${sk} = Object.create(null);\n`; + nestedCode += ` for (var ${GEN.mapEntry}${sk} of ${fieldVal}) {\n`; + nestedCode += ` ${keyCheck}`; + nestedCode += `${GEN.mapObj}${sk}[${GEN.mapEntry}${sk}[0]] = ${GEN.mapEntry}${sk}[1];\n`; + nestedCode += ` }\n`; + nestedCode += ` ${outputTarget} = ${GEN.mapObj}${sk};`; + } } - } else { - // Map → plain object (W8: keys must be strings — throw otherwise) - const keyCheck = `if (typeof ${GEN.mapEntry}[0] !== 'string') { throw new BakerError(${JSON.stringify(className)} + ': Map field ' + ${JSON.stringify(fieldKey)} + ' has non-string key (' + typeof ${GEN.mapEntry}[0] + '). Map serialization requires string keys.'); }\n `; - if (meta.type.resolvedCollectionValue) { - const nestedSealed = resolve(meta.type.resolvedCollectionValue) as SealedExecutors; - const execIdx = execs.length; - execs.push(nestedSealed); - const awaitKw = isAsync ? 'await ' : ''; - nestedCode = `var ${GEN.mapObj} = Object.create(null);\n`; - nestedCode += ` for (var ${GEN.mapEntry} of ${fieldVal}) {\n`; - nestedCode += ` ${keyCheck}`; - nestedCode += `${GEN.mapObj}[${GEN.mapEntry}[0]] = ${GEN.mapEntry}[1] == null ? ${GEN.mapEntry}[1] : ${awaitKw}execs[${execIdx}].serialize(${GEN.mapEntry}[1], opts);\n`; - nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.mapObj};`; + + // Apply serialize transforms after collection serialize (nested → transform) + nestedCode += this.buildPostNestedTransformCode(outputTarget, fieldKey, serTransforms); + + if (useOptionalGuard) { + innerCode = `if (${fieldVal} !== undefined && ${fieldVal} !== null) {\n ${nestedCode}\n} else if (${fieldVal} === null) {\n ${outputTarget} = null;\n}\n`; } else { - nestedCode = `var ${GEN.mapObj} = Object.create(null);\n`; - nestedCode += ` for (var ${GEN.mapEntry} of ${fieldVal}) {\n`; - nestedCode += ` ${keyCheck}`; - nestedCode += `${GEN.mapObj}[${GEN.mapEntry}[0]] = ${GEN.mapEntry}[1];\n`; - nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.mapObj};`; + innerCode = `if (${fieldVal} != null) {\n ${nestedCode}\n} else {\n ${outputTarget} = ${fieldVal};\n}\n`; } - } - - // Apply serialize transforms after collection serialize (nested → transform) - nestedCode += buildPostNestedTransformCode(outputTarget, fieldKey, serTransforms, refs); - if (useOptionalGuard) { - innerCode = `if (${fieldVal} !== undefined && ${fieldVal} !== null) {\n ${nestedCode}\n} else if (${fieldVal} === null) {\n ${outputTarget} = null;\n}\n`; - } else { - innerCode = `if (${fieldVal} != null) {\n ${nestedCode}\n} else {\n ${outputTarget} = ${fieldVal};\n}\n`; + fieldCode += fieldStart + innerCode + fieldEnd; + return fieldCode; } - fieldCode += fieldStart + innerCode + fieldEnd; - return fieldCode; - } - - // ③b nested @Type handling (H4) — supports type + transform combination (nested serialize → transform) - if (meta.type?.resolvedClass || meta.type?.discriminator || (meta.type?.fn && meta.flags.validateNested)) { - // Determine array/each mode - const hasEach = meta.type?.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); - const outputTarget = `${GEN.out}[${JSON.stringify(outputKey)}]`; - - let nestedCode: string; - - if (meta.type!.discriminator) { - // §C-8 discriminator serialize — instanceof dispatch - const { property, subTypes } = meta.type!.discriminator; - const keepDisc = meta.type!.keepDiscriminatorProperty !== false; // default true for round-trip - - // Sort most-specific-first (subclasses take priority in inheritance relationships) - const sorted = [...subTypes].sort((a, b) => { - if ((a.value as Function).prototype instanceof b.value) { - return -1; - } - if ((b.value as Function).prototype instanceof a.value) { - return 1; - } - return 0; - }); - - // Helper for generating instanceof branch code - const buildInstanceofChain = (itemVar: string, awaitKw: string): string => { - let code = ''; - for (let i = 0; i < sorted.length; i++) { - const sub = sorted[i]!; - const nestedSealed = resolve(sub.value) as SealedExecutors; - const execIdx = execs.length; - execs.push(nestedSealed); - const refIdx = refs.length; - refs.push(sub.value); - const prefix = i === 0 ? 'if' : '} else if'; - code += `${prefix} (${itemVar} instanceof refs[${refIdx}]) {\n`; - code += ` var ${GEN.serResult} = ${awaitKw}execs[${execIdx}].serialize(${itemVar}, opts);\n`; - if (keepDisc) { - code += ` ${GEN.serResult}[${JSON.stringify(property)}] = ${JSON.stringify(sub.name)};\n`; + // ③b nested @Type handling (H4) — supports type + transform combination (nested serialize → transform) + const type = meta.type; + if (type && (type.resolvedClass || type.discriminator || (type.fn !== undefined && meta.flags.validateNested))) { + // Determine array/each mode + const hasEach = type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); + const outputTarget = `${GEN.out}[${JSON.stringify(outputKey)}]`; + + let nestedCode: string; + + if (type.discriminator) { + // discriminator serialize — instanceof dispatch + const { property, subTypes } = type.discriminator; + const keepDisc = type.keepDiscriminatorProperty === true; // default drop — symmetric with deserialize (deserialize-builder PB-3) + + // Sort most-specific-first via a TOTAL order: deeper prototype chain (more derived) first, + // ties broken by declaration index. A pairwise instanceof comparator is only a partial order + // (unrelated subtypes compare equal), which is non-transitive and engine-dependent. + const sorted = subTypes + .map((sub, index) => ({ sub, index, depth: prototypeDepth(sub.value) })) + .sort((a, b) => b.depth - a.depth || a.index - b.index) + .map(entry => entry.sub); + + // Helper for generating instanceof branch code + const buildInstanceofChain = (itemVar: string, awaitKw: string): string => { + let code = ''; + for (let i = 0; i < sorted.length; i++) { + const sub = sorted[i]!; + const nestedSealed = this.resolveExecutor(sub.value); + const execIdx = this.execs.length; + this.execs.push(nestedSealed); + const refIdx = this.refs.length; + this.refs.push(sub.value); + const prefix = i === 0 ? 'if' : '} else if'; + code += `${prefix} (${itemVar} instanceof refs[${refIdx}]) {\n`; + code += ` var ${GEN.serResult}${sk} = ${awaitKw}execs[${execIdx}].serialize(${itemVar}, opts);\n`; + if (keepDisc) { + code += ` ${GEN.serResult}${sk}[${JSON.stringify(property)}] = ${JSON.stringify(sub.name)};\n`; + } + code += ` ${GEN.outItem}${sk} = ${GEN.serResult}${sk};\n`; + } + // No subtype matched — throw instead of leaking the raw (un-serialized) instance into the + // output, symmetric with the deserialize side rejecting an unknown discriminator value. + const validNamesJson = JSON.stringify(JSON.stringify(subTypes.map(s => s.name))); + const recvExpr = `(${itemVar} == null ? ${itemVar} : ${itemVar}[${JSON.stringify(property)}])`; + const msgPrefix = JSON.stringify( + `${className}.${fieldKey}: value matches no discriminator subtype (received discriminator=`, + ); + code += + `} else { throw new BakerError(${msgPrefix} + JSON.stringify(${recvExpr}) + ` + + `${JSON.stringify(', expected one of ')} + ${validNamesJson} + ${JSON.stringify(')')}); }\n`; + return code; + }; + + if (hasEach) { + const awaitKw = this.isAsync ? 'await ' : ''; + const discItem = `__ser_item${sk}`; + if (this.isAsync) { + nestedCode = `${outputTarget} = await Promise.all(${fieldVal}.map(async function(${discItem}) {\n`; + } else { + nestedCode = `var ${GEN.discArr}${sk} = [];\n`; + nestedCode += ` for (var ${GEN.discIdx}${sk}=0; ${GEN.discIdx}${sk}<${fieldVal}.length; ${GEN.discIdx}${sk}++) {\n`; + nestedCode += ` var ${discItem} = ${fieldVal}[${GEN.discIdx}${sk}];\n`; + } + nestedCode += ` var ${GEN.outItem}${sk};\n`; + nestedCode += buildInstanceofChain(discItem, awaitKw); + if (this.isAsync) { + nestedCode += ` return ${GEN.outItem}${sk};\n`; + nestedCode += `}));`; + } else { + nestedCode += ` ${GEN.discArr}${sk}.push(${GEN.outItem}${sk});\n`; + nestedCode += ` }\n`; + nestedCode += ` ${outputTarget} = ${GEN.discArr}${sk};`; } - code += ` ${GEN.outItem} = ${GEN.serResult};\n`; - } - code += `} else { ${GEN.outItem} = ` + itemVar + '; }\n'; - return code; - }; - - if (hasEach) { - const awaitKw = isAsync ? 'await ' : ''; - if (isAsync) { - nestedCode = `${outputTarget} = await Promise.all(${fieldVal}.map(async function(__ser_item) {\n`; } else { - nestedCode = `var ${GEN.discArr} = [];\n`; - nestedCode += ` for (var ${GEN.discIdx}=0; ${GEN.discIdx}<${fieldVal}.length; ${GEN.discIdx}++) {\n`; - nestedCode += ` var __ser_item = ${fieldVal}[${GEN.discIdx}];\n`; + const awaitKw = this.isAsync ? 'await ' : ''; + nestedCode = `var ${GEN.outItem}${sk};\n`; + nestedCode += buildInstanceofChain(fieldVal, awaitKw); + nestedCode += `${outputTarget} = ${GEN.outItem}${sk};`; } - nestedCode += ` var ${GEN.outItem};\n`; - nestedCode += buildInstanceofChain('__ser_item', awaitKw); - if (isAsync) { - nestedCode += ` return ${GEN.outItem};\n`; - nestedCode += `}));`; + } else { + // Existing simple nested logic. resolvedClass is always set on this branch — seal assigns + // resolvedClass and validateNested together, so reaching here guarantees the former. + const nestedCls = type.resolvedClass!; + const nestedSealed = this.resolveExecutor(nestedCls); + const execIdx = this.execs.length; + this.execs.push(nestedSealed); + + if (hasEach) { + if (this.isAsync) { + nestedCode = `${outputTarget} = await Promise.all(${fieldVal}.map(async function(__ser_item) { return __ser_item == null ? __ser_item : await execs[${execIdx}].serialize(__ser_item, opts); }));`; + } else { + nestedCode = `var ${GEN.nestedArr}${sk} = [];\n`; + nestedCode += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${fieldVal}.length; ${GEN.nestedIdx}${sk}++) {\n`; + nestedCode += ` var ${GEN.nestedItem}${sk} = ${fieldVal}[${GEN.nestedIdx}${sk}];\n`; + nestedCode += ` ${GEN.nestedArr}${sk}.push(${GEN.nestedItem}${sk} == null ? ${GEN.nestedItem}${sk} : execs[${execIdx}].serialize(${GEN.nestedItem}${sk}, opts));\n`; + nestedCode += ` }\n`; + nestedCode += ` ${outputTarget} = ${GEN.nestedArr}${sk};`; + } } else { - nestedCode += ` ${GEN.discArr}.push(${GEN.outItem});\n`; - nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.discArr};`; + const awaitKw = this.isAsync ? 'await ' : ''; + nestedCode = `${outputTarget} = ${awaitKw}execs[${execIdx}].serialize(${fieldVal}, opts);`; } + } + + // Apply serialize transforms after nested serialize (nested serialize → transform) + nestedCode += this.buildPostNestedTransformCode(outputTarget, fieldKey, serTransforms); + + if (useOptionalGuard) { + innerCode = `if (${fieldVal} !== undefined && ${fieldVal} !== null) {\n ${nestedCode}\n} else if (${fieldVal} === null) {\n ${outputTarget} = null;\n}\n`; } else { - const awaitKw = isAsync ? 'await ' : ''; - nestedCode = `var ${GEN.outItem};\n`; - nestedCode += buildInstanceofChain(fieldVal, awaitKw); - nestedCode += `${outputTarget} = ${GEN.outItem};`; + innerCode = `if (${fieldVal} != null) {\n ${nestedCode}\n} else {\n ${outputTarget} = ${fieldVal};\n}\n`; } } else { - // Existing simple nested logic - const nestedCls = meta.type!.resolvedClass ?? (meta.type!.fn() as Function); - const nestedSealed = resolve(nestedCls) as SealedExecutors; - const execIdx = execs.length; - execs.push(nestedSealed); - - if (hasEach) { - if (isAsync) { - nestedCode = `${outputTarget} = await Promise.all(${fieldVal}.map(async function(__ser_item) { return __ser_item == null ? __ser_item : await execs[${execIdx}].serialize(__ser_item, opts); }));`; - } else { - nestedCode = `var ${GEN.nestedArr} = [];\n`; - nestedCode += ` for (var ${GEN.nestedIdx}=0; ${GEN.nestedIdx}<${fieldVal}.length; ${GEN.nestedIdx}++) {\n`; - nestedCode += ` var ${GEN.nestedItem} = ${fieldVal}[${GEN.nestedIdx}];\n`; - nestedCode += ` ${GEN.nestedArr}.push(${GEN.nestedItem} == null ? ${GEN.nestedItem} : execs[${execIdx}].serialize(${GEN.nestedItem}, opts));\n`; - nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.nestedArr};`; - } + // Existing @Transform or direct assign handling + const outputExpr = this.buildOutputExpr(fieldKey, outputKey, fieldVal, meta); + + if (useOptionalGuard) { + innerCode += `if (${fieldVal} !== undefined) {\n`; + innerCode += ' ' + outputExpr + '\n'; + innerCode += '}\n'; } else { - const awaitKw = isAsync ? 'await ' : ''; - nestedCode = `${outputTarget} = ${awaitKw}execs[${execIdx}].serialize(${fieldVal}, opts);`; + innerCode += outputExpr + '\n'; } } - // Apply serialize transforms after nested serialize (nested serialize → transform) - nestedCode += buildPostNestedTransformCode(outputTarget, fieldKey, serTransforms, refs); + fieldCode += fieldStart + innerCode + fieldEnd; + return fieldCode; + } - if (useOptionalGuard) { - innerCode = `if (${fieldVal} !== undefined && ${fieldVal} !== null) {\n ${nestedCode}\n} else if (${fieldVal} === null) {\n ${outputTarget} = null;\n}\n`; - } else { - innerCode = `if (${fieldVal} != null) {\n ${nestedCode}\n} else {\n ${outputTarget} = ${fieldVal};\n}\n`; + /** + * Build serialize-direction transform expression. + * Serialize direction reverses declaration order (codec stack unwrapping). + */ + private buildTransformExpr(inputExpr: string, fieldKey: string, serTransforms: TransformDef[]): string | null { + if (serTransforms.length === 0) { + return null; } - } else { - // Existing @Transform or direct assign handling - const outputExpr = buildSerializeOutputExpr(fieldKey, outputKey, fieldVal, meta, refs); - - if (useOptionalGuard) { - innerCode += `if (${fieldVal} !== undefined) {\n`; - innerCode += ' ' + outputExpr + '\n'; - innerCode += '}\n'; - } else { - innerCode += outputExpr + '\n'; + const refs = this.refs; + // Walk serTransforms backwards in place (serialize reverses declaration order) — no clone allocation. + // The general loop already emits byte-identical code for 1 and 2 transforms, so no length fast-paths. + let valueExpr = inputExpr; + for (let k = serTransforms.length - 1; k >= 0; k -= 1) { + const td = serTransforms[k]!; + const refIdx = refs.length; + refs.push(td.fn); + const callExpr = `refs[${refIdx}]({value:${valueExpr},key:${JSON.stringify(fieldKey)},obj:instance})`; + valueExpr = td.isAsync ? `(await ${callExpr})` : callExpr; } + return valueExpr; } - fieldCode += fieldStart + innerCode + fieldEnd; - return fieldCode; + /** + * Generate transform chain code to apply after nested/collection serialize. + * Reads the current value from outputTarget, chains transforms, writes back. + */ + private buildPostNestedTransformCode(outputTarget: string, fieldKey: string, serTransforms: TransformDef[]): string { + const transformed = this.buildTransformExpr(outputTarget, fieldKey, serTransforms); + return transformed ? `\n${outputTarget} = ${transformed};` : ''; + } + + /** + * Build field output expression. + * If @Transform exists, call refs[i](params); otherwise, direct assignment. + */ + private buildOutputExpr(fieldKey: string, outputKey: string, fieldValueExpr: string, meta: RawPropertyMeta): string { + const outputTarget = `${GEN.out}[${JSON.stringify(outputKey)}]`; + + const serTransforms = meta.transform.filter(td => !td.options?.deserializeOnly); + + if (serTransforms.length > 0) { + const transformed = this.buildTransformExpr(fieldValueExpr, fieldKey, serTransforms)!; + return `${outputTarget} = ${transformed};`; + } + + return `${outputTarget} = ${fieldValueExpr};`; + } } /** - * Build field output expression. - * If @Transform exists, call refs[i](params); otherwise, direct assignment. + * Generate serialize executor code. + * Thin wrapper preserving the historical free-function entry point: instantiates + * SerializeBuilder and returns its built executor. */ -function buildSerializeOutputExpr( - fieldKey: string, - outputKey: string, - fieldValueExpr: string, - meta: RawPropertyMeta, - refs: unknown[], -): string { - const outputTarget = `${GEN.out}[${JSON.stringify(outputKey)}]`; - - const serTransforms = meta.transform.filter(td => !td.options?.deserializeOnly); - - if (serTransforms.length > 0) { - const transformed = buildSerializeTransformExpr(fieldValueExpr, fieldKey, serTransforms, refs)!; - return `${outputTarget} = ${transformed};`; - } - - return `${outputTarget} = ${fieldValueExpr};`; +function buildSerializeCode( + Class: Function, + merged: RawClassMeta, + options: SealOptions | undefined, + isAsync: boolean, + resolve: (cls: Function) => SealedExecutors | undefined, +): (instance: T, opts?: RuntimeOptions) => Record | Promise> { + return new SerializeBuilder(Class, merged, options, isAsync, resolve).build(); } + export { buildSerializeCode }; diff --git a/src/seal/type-normalizer.ts b/src/seal/type-normalizer.ts new file mode 100644 index 0000000..07b97cc --- /dev/null +++ b/src/seal/type-normalizer.ts @@ -0,0 +1,73 @@ +import type { ClassCtor } from '../common'; +import type { RawClassMeta } from '../metadata'; + +import { BakerError } from '../common'; +import { PRIMITIVE_CTORS } from './constants'; +import { classifyTypeResult } from './type-resolver'; + +/** + * Seal-time normalization of each field's `@Type`/`@Field` type thunk: resolve `type.fn()`, detect + * Map/Set collections and the `[Element]` array form, exclude primitive constructors, and auto-infer the + * `validateNested`/`validateNestedEach` flags for DTO classes. Mutates `merged` in place — it reassigns + * `merged[key]` with a copy-on-write `type` (never mutating the shared RAW `type`) and mutates the + * already-per-seal-cloned `meta.flags` directly. Stateless — a plain function (no instance needed). + */ +export function normalizeTypeDefs(merged: RawClassMeta, className: string): void { + for (const [key, meta] of Object.entries(merged)) { + if (!meta.type?.fn) { + continue; + } + let typeResult: unknown; + try { + typeResult = meta.type.fn(); + } catch (e) { + throw new BakerError(`${className}.${key}: type function threw: ${e instanceof Error ? e.message : String(e)}`, { + cause: e, + }); + } + + const { collection, isArray, resolved } = classifyTypeResult(typeResult); + + // Detect Map/Set collection + if (collection !== undefined) { + const typeCopy = { ...meta.type, collection, isArray: false }; + // collectionValue thunk → cache resolvedCollectionValue + if (meta.type.collectionValue) { + let valCls: unknown; + try { + valCls = meta.type.collectionValue(); + } catch (e) { + throw new BakerError( + `${className}.${key}: collectionValue function threw: ${e instanceof Error ? e.message : String(e)}`, + { + cause: e, + }, + ); + } + if (valCls != null && typeof valCls === 'function' && !PRIMITIVE_CTORS.has(valCls as Function)) { + typeCopy.resolvedCollectionValue = valCls as ClassCtor; + } + } + merged[key] = { ...meta, type: typeCopy }; + continue; + } + + if (resolved == null || typeof resolved !== 'function') { + throw new BakerError(`${className}: @Type/@Field type must return a constructor or [constructor], got ${String(resolved)}`); + } + // Copy type object before mutating — preserve original RAW type reference + const typeCopy = { ...meta.type, isArray }; + if (!PRIMITIVE_CTORS.has(resolved)) { + typeCopy.resolvedClass = resolved as ClassCtor; + // Automatically set validateNested flags for DTO classes. `meta.flags` is already a per-seal + // copy (mergeInheritance clones it), so mutate it directly — no second copy-on-write here. + if (!meta.flags.validateNested) { + meta.flags.validateNested = true; + } + if (isArray && !meta.flags.validateNestedEach) { + meta.flags.validateNestedEach = true; + } + } + merged[key] = { ...meta, type: typeCopy }; + } +} diff --git a/src/seal/type-resolver.ts b/src/seal/type-resolver.ts new file mode 100644 index 0000000..3bf1fbf --- /dev/null +++ b/src/seal/type-resolver.ts @@ -0,0 +1,14 @@ +import type { ClassifiedType } from './interfaces'; + +import { CollectionType } from '../metadata'; + +export function classifyTypeResult(result: unknown): ClassifiedType { + if (result === Map) { + return { collection: CollectionType.Map, isArray: false, resolved: undefined }; + } + if (result === Set) { + return { collection: CollectionType.Set, isArray: false, resolved: undefined }; + } + const isArray = Array.isArray(result); + return { isArray, resolved: isArray ? (result as unknown[])[0] : result }; +} diff --git a/src/seal/types.ts b/src/seal/types.ts new file mode 100644 index 0000000..4f2720b --- /dev/null +++ b/src/seal/types.ts @@ -0,0 +1,12 @@ +import type { Result, ResultAsync } from '@zipbul/result'; + +import type { RuntimeOptions, BakerIssue } from '../common'; + +/** Compiled deserialize executor — Result pattern (or its async variant), produced by the builder. */ +export type DeserializeExecutor = ( + input: unknown, + opts?: RuntimeOptions, +) => Result | ResultAsync; + +/** Compiled validate-only executor — null on success, BakerIssue[] on failure (or its async variant). */ +export type ValidateExecutor = (input: unknown, opts?: RuntimeOptions) => BakerIssue[] | null | Promise; diff --git a/src/seal/validate-meta.ts b/src/seal/validate-meta.ts deleted file mode 100644 index 0eb0800..0000000 --- a/src/seal/validate-meta.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { RawClassMeta } from '../types'; - -import { CollectionType } from '../enums'; -import { BakerError } from '../errors'; -import { hasRawOwn } from '../meta-access'; - -/** - * @internal — seal-time invariant checks invoked from sealOne after merge + type normalization, - * before codegen. Throws BakerError on the first violation. - * - * Covers W2 (D7 + D9): - * - Discriminator shape: empty subTypes / invalid subType entry / name collision / missing property - * - Set/Map pairing: Set without setValue, Map without mapValue, setValue/mapValue target missing @Field metadata - * - async-in-sync: a DTO that mixes async rules/transforms with sync rules/transforms in such a way - * that the caller cannot easily tell — this throws BakerError so the user makes the intent explicit. - * (Per W2 decision: throw, not warn.) - */ -export function validateMeta(Class: Function, merged: RawClassMeta): void { - const className = Class.name; - - for (const [key, meta] of Object.entries(merged)) { - // ─── Discriminator shape ───────────────────────────────────────────── - if (meta.type?.discriminator) { - const disc = meta.type.discriminator; - if (typeof disc.property !== 'string' || disc.property.length === 0) { - throw new BakerError(`${className}.${key}: discriminator.property must be a non-empty string.`); - } - if (!Array.isArray(disc.subTypes) || disc.subTypes.length === 0) { - throw new BakerError(`${className}.${key}: discriminator.subTypes must be a non-empty array of { value, name } entries.`); - } - const seenNames = new Set(); - for (let i = 0; i < disc.subTypes.length; i++) { - const sub = disc.subTypes[i]!; - if (typeof sub.name !== 'string' || sub.name.length === 0) { - throw new BakerError(`${className}.${key}: discriminator.subTypes[${i}].name must be a non-empty string.`); - } - if (typeof sub.value !== 'function') { - throw new BakerError( - `${className}.${key}: discriminator.subTypes[${i}].value must be a class constructor (got ${typeof sub.value}).`, - ); - } - if (seenNames.has(sub.name)) { - throw new BakerError( - `${className}.${key}: discriminator.subTypes has duplicate name '${sub.name}'. Each subType must have a unique name.`, - ); - } - seenNames.add(sub.name); - // subType class must have @Field metadata (RAW) — otherwise codegen will fail with a less clear error - if (!hasRawOwn(sub.value)) { - throw new BakerError( - `${className}.${key}: discriminator.subTypes[${i}].value (${(sub.value as Function).name}) has no @Field decorators.`, - ); - } - } - } - - // ─── Set/Map collection pairing — unified single-pass check ────────── - const collection = meta.type?.collection; - if (collection !== undefined && meta.type?.resolvedCollectionValue) { - const target = meta.type.resolvedCollectionValue; - if (!hasRawOwn(target)) { - const accessor = collection === CollectionType.Set ? 'setValue' : 'mapValue'; - throw new BakerError(`${className}.${key}: ${accessor} target (${target.name}) has no @Field decorators.`); - } - } - } - - // ─── async-in-sync: D9 ──────────────────────────────────────────────── - // Seal-time strict check for "mixed sync/async rules" was attempted but produces too many - // false positives — sync rules + async transform is a common, valid baker pattern. The - // remediation for D9 lives in W14's strict API: `validateSync(AsyncDto, x)` and the other - // `*Sync` variants throw BakerError at the call site after consulting `isAsync`/`isSerializeAsync`. - // No seal-time invariant added here. -} diff --git a/src/transformers/collection.transformer.ts b/src/transformers/collection.ts similarity index 92% rename from src/transformers/collection.transformer.ts rename to src/transformers/collection.ts index c492a24..30aca4f 100644 --- a/src/transformers/collection.transformer.ts +++ b/src/transformers/collection.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './interfaces'; export function csvTransformer(separator = ','): Transformer { return { diff --git a/src/transformers/constants.ts b/src/transformers/constants.ts new file mode 100644 index 0000000..360a408 --- /dev/null +++ b/src/transformers/constants.ts @@ -0,0 +1,3 @@ +export const LUXON_MISSING = "luxonTransformer requires the optional peer dependency 'luxon'. Install it with: bun add luxon"; + +export const MOMENT_MISSING = "momentTransformer requires the optional peer dependency 'moment'. Install it with: bun add moment"; diff --git a/src/transformers/date.spec.ts b/src/transformers/date.spec.ts new file mode 100644 index 0000000..0006b4f --- /dev/null +++ b/src/transformers/date.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'bun:test'; + +import { unixSecondsTransformer, unixMillisTransformer, isoStringTransformer } from './date'; + +describe('unixSecondsTransformer', () => { + it('deserialize converts a unix-seconds number to a Date', () => { + const d = unixSecondsTransformer.deserialize!({ value: 1623715200 } as never) as Date; + expect(d).toBeInstanceOf(Date); + expect(d.getTime()).toBe(1623715200 * 1000); + }); + + it('deserialize passes a non-number through untouched', () => { + expect(unixSecondsTransformer.deserialize!({ value: 'x' } as never)).toBe('x'); + }); + + it('deserialize passes a non-finite number (Infinity → Invalid Date) through untouched', () => { + expect(unixSecondsTransformer.deserialize!({ value: Infinity } as never)).toBe(Infinity); + }); + + it('serialize converts a Date to unix seconds', () => { + expect(unixSecondsTransformer.serialize!({ value: new Date(1623715200_000) } as never)).toBe(1623715200); + }); + + it('serialize passes a non-Date through untouched', () => { + expect(unixSecondsTransformer.serialize!({ value: 42 } as never)).toBe(42); + }); + + // Whole-second floor is the standard Unix-timestamp convention (matches `Math.floor(Date.now()/1000)` + // and `date +%s`): sub-second precision is dropped and a sub-second pre-epoch instant floors toward + // −∞, so -500ms → -1s (the second that contains it), NOT 0. Intentional, pinned against "fixes". + it('serialize floors sub-second precision (Unix-timestamp convention)', () => { + expect(unixSecondsTransformer.serialize!({ value: new Date(1500) } as never)).toBe(1); + expect(unixSecondsTransformer.serialize!({ value: new Date(-500) } as never)).toBe(-1); + }); +}); + +describe('unixMillisTransformer', () => { + it('deserialize converts a unix-millis number to a Date', () => { + const d = unixMillisTransformer.deserialize!({ value: 1623715200_000 } as never) as Date; + expect(d).toBeInstanceOf(Date); + expect(d.getTime()).toBe(1623715200_000); + }); + + it('deserialize passes a non-number through untouched', () => { + expect(unixMillisTransformer.deserialize!({ value: null } as never)).toBe(null); + }); + + it('deserialize passes a non-finite number through untouched', () => { + expect(unixMillisTransformer.deserialize!({ value: NaN } as never)).toBeNaN(); + }); + + it('serialize converts a Date to unix millis', () => { + expect(unixMillisTransformer.serialize!({ value: new Date(1623715200_000) } as never)).toBe(1623715200_000); + }); + + it('serialize passes a non-Date through untouched', () => { + expect(unixMillisTransformer.serialize!({ value: 'x' } as never)).toBe('x'); + }); +}); + +describe('isoStringTransformer', () => { + it('deserialize converts an ISO string to a Date', () => { + const d = isoStringTransformer.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never) as Date; + expect(d).toBeInstanceOf(Date); + expect(d.toISOString()).toBe('2021-06-15T00:00:00.000Z'); + }); + + it('deserialize passes a non-string through untouched', () => { + expect(isoStringTransformer.deserialize!({ value: 123 } as never)).toBe(123); + }); + + it('deserialize passes an unparseable string through untouched', () => { + expect(isoStringTransformer.deserialize!({ value: 'not-a-date' } as never)).toBe('not-a-date'); + }); + + it('serialize converts a Date to an ISO string', () => { + expect(isoStringTransformer.serialize!({ value: new Date('2021-06-15T00:00:00.000Z') } as never)).toBe( + '2021-06-15T00:00:00.000Z', + ); + }); + + it('serialize passes a non-Date through untouched', () => { + expect(isoStringTransformer.serialize!({ value: 'x' } as never)).toBe('x'); + }); +}); diff --git a/src/transformers/date.transformer.ts b/src/transformers/date.ts similarity index 50% rename from src/transformers/date.transformer.ts rename to src/transformers/date.ts index 7a637f8..a764e39 100644 --- a/src/transformers/date.transformer.ts +++ b/src/transformers/date.ts @@ -1,12 +1,26 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './interfaces'; export const unixSecondsTransformer: Transformer = { - deserialize: ({ value }) => (typeof value === 'number' ? new Date(value * 1000) : value), + // Pass non-numbers and non-finite numbers (NaN/Infinity → Invalid Date) through untouched, so the + // validator sees the original value — symmetric with isoStringTransformer. + deserialize: ({ value }) => { + if (typeof value !== 'number') { + return value; + } + const d = new Date(value * 1000); + return Number.isNaN(d.getTime()) ? value : d; + }, serialize: ({ value }) => (value instanceof Date ? Math.floor(value.getTime() / 1000) : value), }; export const unixMillisTransformer: Transformer = { - deserialize: ({ value }) => (typeof value === 'number' ? new Date(value) : value), + deserialize: ({ value }) => { + if (typeof value !== 'number') { + return value; + } + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d; + }, serialize: ({ value }) => (value instanceof Date ? value.getTime() : value), }; diff --git a/src/transformers/datetime-transformer.spec.ts b/src/transformers/datetime-transformer.spec.ts deleted file mode 100644 index ce0b8d7..0000000 --- a/src/transformers/datetime-transformer.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { luxonTransformer, momentTransformer } from './index'; - -// luxon and moment are installed as devDependencies so these happy paths execute the -// real DateTime/moment code. The missing-peer error branch cannot be co-tested here: -// in one bun process a module is either real or a throwing mock, not both. - -describe('luxonTransformer — happy path', () => { - it('deserialize parses an ISO string, serialize emits ISO', async () => { - const t = await luxonTransformer(); - const dt = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); - expect(String(t.serialize!({ value: dt } as never))).toContain('2021-06-15T00:00:00.000'); - }); - - it('deserialize accepts a Date', async () => { - const t = await luxonTransformer(); - const dt = t.deserialize!({ value: new Date('2021-06-15T00:00:00.000Z') } as never); - expect(String(t.serialize!({ value: dt } as never))).toContain('2021-06-15'); - }); - - it('serialize honours a custom format', async () => { - const t = await luxonTransformer({ format: 'yyyy/MM/dd' }); - const dt = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); - expect(t.serialize!({ value: dt } as never)).toBe('2021/06/15'); - }); - - it('passes through non-date values untouched', async () => { - const t = await luxonTransformer(); - expect(t.deserialize!({ value: 42 } as never)).toBe(42); - expect(t.serialize!({ value: 42 } as never)).toBe(42); - }); -}); - -describe('momentTransformer — happy path', () => { - it('deserialize parses a string, serialize emits ISO', async () => { - const t = await momentTransformer(); - const m = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); - expect(t.serialize!({ value: m } as never)).toBe('2021-06-15T00:00:00.000Z'); - }); - - it('deserialize accepts a Date', async () => { - const t = await momentTransformer(); - const m = t.deserialize!({ value: new Date('2021-06-15T00:00:00.000Z') } as never); - expect(t.serialize!({ value: m } as never)).toBe('2021-06-15T00:00:00.000Z'); - }); - - it('serialize honours a custom format', async () => { - const t = await momentTransformer({ format: 'YYYY/MM/DD' }); - const m = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); - expect(t.serialize!({ value: m } as never)).toBe('2021/06/15'); - }); - - it('passes through non-date values untouched', async () => { - const t = await momentTransformer(); - expect(t.deserialize!({ value: 42 } as never)).toBe(42); - expect(t.serialize!({ value: 42 } as never)).toBe(42); - }); -}); diff --git a/src/transformers/index.ts b/src/transformers/index.ts index 59dd306..512898b 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -1,8 +1,9 @@ -export { trimTransformer, toLowerCaseTransformer, toUpperCaseTransformer } from './string.transformer'; -export { roundTransformer } from './number.transformer'; -export { unixSecondsTransformer, unixMillisTransformer, isoStringTransformer } from './date.transformer'; -export { csvTransformer, jsonTransformer } from './collection.transformer'; -export { luxonTransformer } from './luxon.transformer'; -export type { LuxonTransformerOptions } from './luxon.transformer'; -export { momentTransformer } from './moment.transformer'; -export type { MomentTransformerOptions } from './moment.transformer'; +// Directory barrel — the FULL internal surface other domains import via `../transformers`. +// The published `./transformers` subpath points at `./public` (curated public surface) instead, so the +// internal `TransformFunction` re-export never leaks into the public API. + +export * from './public'; + +// Internal surface — consumed cross-domain but NOT necessarily part of the published `./transformers`. +export type { Transformer, TransformParams } from './interfaces'; +export type { TransformFunction } from './types'; diff --git a/src/transformers/interfaces.ts b/src/transformers/interfaces.ts new file mode 100644 index 0000000..09c5431 --- /dev/null +++ b/src/transformers/interfaces.ts @@ -0,0 +1,34 @@ +export interface TransformParams { + value: unknown; + key: string; + obj: Record; +} + +// A transform may return its value synchronously or as a Promise (awaited by the codegen when the +// field is async). The return type is `unknown` — `unknown` already subsumes `Promise`, so a +// `| Promise` union would collapse to `unknown` and signal nothing. +export interface Transformer { + deserialize(params: TransformParams): unknown; + serialize(params: TransformParams): unknown; +} + +export interface LuxonTransformerOptions { + format?: string; + zone?: string; +} + +/** Structural shape of a Luxon DateTime — both methods required so an unrelated object isn't mangled. */ +export interface LuxonLike { + toISO(): string; + toFormat(f: string): string; +} + +export interface MomentTransformerOptions { + format?: string; +} + +/** Structural shape of a Moment — both methods required so an unrelated object isn't mangled. */ +export interface MomentLike { + toISOString(): string; + format(f: string): string; +} diff --git a/src/transformers/luxon.spec.ts b/src/transformers/luxon.spec.ts new file mode 100644 index 0000000..b8242c9 --- /dev/null +++ b/src/transformers/luxon.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'bun:test'; + +import { luxonTransformer } from './index'; + +// luxon is installed as a devDependency so this happy path executes the real DateTime code. +// The missing-peer error branch cannot be co-tested here: in one bun process a module is +// either real or a throwing mock, not both. + +describe('luxonTransformer — happy path', () => { + it('deserialize parses an ISO string, serialize emits ISO', async () => { + const t = await luxonTransformer(); + const dt = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); + expect(String(t.serialize!({ value: dt } as never))).toContain('2021-06-15T00:00:00.000'); + }); + + it('deserialize accepts a Date', async () => { + const t = await luxonTransformer(); + const dt = t.deserialize!({ value: new Date('2021-06-15T00:00:00.000Z') } as never); + expect(String(t.serialize!({ value: dt } as never))).toContain('2021-06-15'); + }); + + it('serialize honours a custom format', async () => { + const t = await luxonTransformer({ format: 'yyyy/MM/dd' }); + const dt = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); + expect(t.serialize!({ value: dt } as never)).toBe('2021/06/15'); + }); + + it('passes through non-date values untouched', async () => { + const t = await luxonTransformer(); + expect(t.deserialize!({ value: 42 } as never)).toBe(42); + expect(t.serialize!({ value: 42 } as never)).toBe(42); + }); + + it('passes an unparseable date string through untouched (no Invalid-DateTime laundering)', async () => { + // Mirrors momentTransformer's contract: a bad input must NOT become a fake-valid DateTime that + // later serializes to null / "Invalid DateTime". + const t = await luxonTransformer(); + expect(t.deserialize!({ value: 'not-a-date' } as never)).toBe('not-a-date'); + }); + + it('passes an unparseable Date through untouched', async () => { + const t = await luxonTransformer(); + const invalid = new Date('not-a-date'); + expect(t.deserialize!({ value: invalid } as never)).toBe(invalid); + }); +}); diff --git a/src/transformers/luxon.transformer.ts b/src/transformers/luxon.transformer.ts deleted file mode 100644 index 48bde1c..0000000 --- a/src/transformers/luxon.transformer.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Transformer } from '../types'; - -import { BakerError } from '../errors'; - -interface LuxonTransformerOptions { - format?: string; - zone?: string; -} - -interface LuxonLike { - toISO(): string; - toFormat(f: string): string; -} - -const LUXON_MISSING = "luxonTransformer requires the optional peer dependency 'luxon'. Install it with: bun add luxon"; - -async function luxonTransformer(opts?: LuxonTransformerOptions): Promise { - let luxon: typeof import('luxon'); - try { - luxon = await import('luxon'); - } catch (e) { - throw new BakerError(LUXON_MISSING, { cause: e }); - } - const { DateTime } = luxon; - const zone = opts?.zone ?? 'utc'; - // Hoist format option once so the serialize closure doesn't re-read opts per call - const format = opts?.format; - - return { - deserialize: ({ value }) => { - if (typeof value === 'string') { - return DateTime.fromISO(value, { zone }); - } - if (value instanceof Date) { - return DateTime.fromJSDate(value, { zone }); - } - return value; - }, - serialize: ({ value }) => { - if (value && typeof value === 'object' && typeof (value as LuxonLike).toISO === 'function') { - const v = value as LuxonLike; - return format ? v.toFormat(format) : v.toISO(); - } - return value; - }, - }; -} - -export type { LuxonTransformerOptions }; -export { luxonTransformer }; diff --git a/src/transformers/luxon.ts b/src/transformers/luxon.ts new file mode 100644 index 0000000..cfeeb54 --- /dev/null +++ b/src/transformers/luxon.ts @@ -0,0 +1,51 @@ +import type { LuxonLike, LuxonTransformerOptions, Transformer } from './interfaces'; + +import { BakerError } from '../common'; +import { LUXON_MISSING } from './constants'; + +async function luxonTransformer(opts?: LuxonTransformerOptions): Promise { + let luxon: typeof import('luxon'); + try { + luxon = await import('luxon'); + } catch (e) { + // Only ERR_MODULE_NOT_FOUND ("not installed") maps to the peer-dep hint; any other error (a module + // that IS installed but threw during evaluation) surfaces untouched, not a misleading "install it". + throw (e as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' ? new BakerError(LUXON_MISSING, { cause: e }) : e; + } + const { DateTime } = luxon; + const zone = opts?.zone ?? 'utc'; + // Hoist format option once so the serialize closure doesn't re-read opts per call + const format = opts?.format; + + return { + deserialize: ({ value }) => { + // Mirror momentTransformer: an unparseable input must pass through untouched, never become an + // Invalid DateTime (which would serialize to null / "Invalid DateTime" and corrupt the data). + if (typeof value === 'string') { + const dt = DateTime.fromISO(value, { zone }); + return dt.isValid ? dt : value; + } + if (value instanceof Date) { + const dt = DateTime.fromJSDate(value, { zone }); + return dt.isValid ? dt : value; + } + return value; + }, + serialize: ({ value }) => { + // Require both methods (like the moment transformer) so an unrelated object exposing only a + // `toISO` method is not mistaken for a Luxon DateTime and mangled. + if ( + value && + typeof value === 'object' && + typeof (value as LuxonLike).toISO === 'function' && + typeof (value as LuxonLike).toFormat === 'function' + ) { + const v = value as LuxonLike; + return format ? v.toFormat(format) : v.toISO(); + } + return value; + }, + }; +} + +export { luxonTransformer }; diff --git a/src/transformers/moment.spec.ts b/src/transformers/moment.spec.ts new file mode 100644 index 0000000..8cfc1d6 --- /dev/null +++ b/src/transformers/moment.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'bun:test'; + +import { momentTransformer } from './index'; + +// moment is installed as a devDependency so this happy path executes the real moment code. +// The missing-peer error branch cannot be co-tested here: in one bun process a module is +// either real or a throwing mock, not both. + +describe('momentTransformer — happy path', () => { + it('deserialize parses a string, serialize emits ISO', async () => { + const t = await momentTransformer(); + const m = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); + expect(t.serialize!({ value: m } as never)).toBe('2021-06-15T00:00:00.000Z'); + }); + + it('deserialize accepts a Date', async () => { + const t = await momentTransformer(); + const m = t.deserialize!({ value: new Date('2021-06-15T00:00:00.000Z') } as never); + expect(t.serialize!({ value: m } as never)).toBe('2021-06-15T00:00:00.000Z'); + }); + + it('serialize honours a custom format', async () => { + const t = await momentTransformer({ format: 'YYYY/MM/DD' }); + const m = t.deserialize!({ value: '2021-06-15T00:00:00.000Z' } as never); + expect(t.serialize!({ value: m } as never)).toBe('2021/06/15'); + }); + + it('passes through non-date values untouched', async () => { + const t = await momentTransformer(); + expect(t.deserialize!({ value: 42 } as never)).toBe(42); + expect(t.serialize!({ value: 42 } as never)).toBe(42); + }); + + it('parses input in UTC mode so a zoneless string is machine-independent (matches luxon)', async () => { + // A zoneless string must resolve to the same instant on every host; local-time parsing makes the + // serialized output depend on the machine timezone. `bun test` forces TZ=UTC, which would hide an + // output-based assertion — so assert the parse mode (moment.utc → isUTC()===true) instead, which is + // timezone-independent. Mirrors luxonTransformer's `zone: 'utc'` default. + const t = await momentTransformer(); + const m = t.deserialize!({ value: '2021-06-15T12:00:00' } as never) as { isUTC(): boolean }; + expect(m.isUTC()).toBe(true); + }); +}); diff --git a/src/transformers/moment.transformer.ts b/src/transformers/moment.transformer.ts deleted file mode 100644 index ec70704..0000000 --- a/src/transformers/moment.transformer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Transformer } from '../types'; - -import { BakerError } from '../errors'; - -interface MomentTransformerOptions { - format?: string; -} - -interface MomentLike { - toISOString(): string; - format(f: string): string; -} - -const MOMENT_MISSING = "momentTransformer requires the optional peer dependency 'moment'. Install it with: bun add moment"; - -async function momentTransformer(opts?: MomentTransformerOptions): Promise { - let moment: typeof import('moment'); - try { - moment = (await import('moment')).default; - } catch (e) { - throw new BakerError(MOMENT_MISSING, { cause: e }); - } - // Hoist format option once so the serialize closure doesn't re-read opts per call - const format = opts?.format; - - return { - deserialize: ({ value }) => { - if (typeof value === 'string' || value instanceof Date) { - return moment(value); - } - return value; - }, - serialize: ({ value }) => { - if ( - value && - typeof value === 'object' && - typeof (value as MomentLike).toISOString === 'function' && - typeof (value as MomentLike).format === 'function' - ) { - const v = value as MomentLike; - return format ? v.format(format) : v.toISOString(); - } - return value; - }, - }; -} - -export type { MomentTransformerOptions }; -export { momentTransformer }; diff --git a/src/transformers/moment.ts b/src/transformers/moment.ts new file mode 100644 index 0000000..50cd299 --- /dev/null +++ b/src/transformers/moment.ts @@ -0,0 +1,45 @@ +import type { MomentLike, MomentTransformerOptions, Transformer } from './interfaces'; + +import { BakerError } from '../common'; +import { MOMENT_MISSING } from './constants'; + +async function momentTransformer(opts?: MomentTransformerOptions): Promise { + let moment: typeof import('moment'); + try { + moment = (await import('moment')).default; + } catch (e) { + // Only ERR_MODULE_NOT_FOUND ("not installed") maps to the peer-dep hint; any other error (a module + // that IS installed but threw during evaluation) surfaces untouched, not a misleading "install it". + throw (e as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' ? new BakerError(MOMENT_MISSING, { cause: e }) : e; + } + // Hoist format option once so the serialize closure doesn't re-read opts per call + const format = opts?.format; + + return { + deserialize: ({ value }) => { + if (typeof value === 'string' || value instanceof Date) { + // Parse in UTC (moment.utc) so a zoneless string resolves to the same instant on every host — + // local-time parsing would make serialized output machine-dependent. Matches luxon's UTC default. + // Pass an unparseable value through untouched (symmetric with isoStringTransformer) rather + // than returning an Invalid moment the validator cannot distinguish from a real one. + const m = moment.utc(value); + return m.isValid() ? m : value; + } + return value; + }, + serialize: ({ value }) => { + if ( + value && + typeof value === 'object' && + typeof (value as MomentLike).toISOString === 'function' && + typeof (value as MomentLike).format === 'function' + ) { + const v = value as MomentLike; + return format ? v.format(format) : v.toISOString(); + } + return value; + }, + }; +} + +export { momentTransformer }; diff --git a/src/transformers/number.transformer.ts b/src/transformers/number.ts similarity index 87% rename from src/transformers/number.transformer.ts rename to src/transformers/number.ts index 0d5ef00..926c714 100644 --- a/src/transformers/number.transformer.ts +++ b/src/transformers/number.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './interfaces'; export function roundTransformer(precision = 0): Transformer { const factor = Math.pow(10, precision); diff --git a/src/transformers/public.ts b/src/transformers/public.ts new file mode 100644 index 0000000..206bda6 --- /dev/null +++ b/src/transformers/public.ts @@ -0,0 +1,7 @@ +export { trimTransformer, toLowerCaseTransformer, toUpperCaseTransformer } from './string'; +export { roundTransformer } from './number'; +export { unixSecondsTransformer, unixMillisTransformer, isoStringTransformer } from './date'; +export { csvTransformer, jsonTransformer } from './collection'; +export { luxonTransformer } from './luxon'; +export { momentTransformer } from './moment'; +export type { LuxonTransformerOptions, MomentTransformerOptions } from './interfaces'; diff --git a/src/transformers/string.transformer.ts b/src/transformers/string.ts similarity index 93% rename from src/transformers/string.transformer.ts rename to src/transformers/string.ts index 6c47dc2..92d3fef 100644 --- a/src/transformers/string.transformer.ts +++ b/src/transformers/string.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './interfaces'; export const trimTransformer: Transformer = { deserialize: ({ value }) => (typeof value === 'string' ? value.trim() : value), diff --git a/src/transformers/types.ts b/src/transformers/types.ts new file mode 100644 index 0000000..af4235f --- /dev/null +++ b/src/transformers/types.ts @@ -0,0 +1,4 @@ +import type { TransformParams } from './interfaces'; + +/** Internal — direction-specific transform function stored after @Field processing */ +export type TransformFunction = (params: TransformParams) => unknown; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 362c49b..0000000 --- a/src/types.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { Result, ResultAsync } from '@zipbul/result'; - -import type { CacheKey, CollectionType, RequiredType, RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; -import type { BakerIssue } from './errors'; -import type { RuntimeOptions } from './interfaces'; - -// ───────────────────────────────────────────────────────────────────────────── -// EmitContext — Code generation context (§4.7) -// ───────────────────────────────────────────────────────────────────────────── - -export interface EmitContext { - /** Register a RegExp in the reference array, return its index */ - addRegex(re: RegExp): number; - /** Register in the reference array, return its index — functions, arrays, Sets, primitives, etc. */ - addRef(value: unknown): number; - /** Register a SealedExecutors object in the reference array — for nested @Type DTOs */ - addExecutor(executor: SealedExecutors): number; - /** Generate a failure code string from an error code — path is bound by the builder */ - fail(code: string): string; - /** Whether error collection mode is enabled (= !stopAtFirstError) */ - collectErrors: boolean; - /** Whether this emit runs inside a type gate (typeof/instanceof already verified) */ - insideTypeGate?: boolean; - /** @internal Path expression for inline nested — used by makeRuleEmitCtx */ - pathExpr?: string; -} - -// ───────────────────────────────────────────────────────────────────────────── -// EmittableRule — Validation function + .emit() (§4.7, §4.8) -// ───────────────────────────────────────────────────────────────────────────── - -export interface EmittableRule { - (value: unknown): boolean | Promise; - emit(varName: string, ctx: EmitContext): string; - readonly ruleName: string; - /** - * Meta for the builder to determine whether to insert a typeof guard. - * Only set for rules that assume a specific type (e.g., isEmail → 'string'). - * `@IsString` itself is undefined (it includes its own typeof check). - */ - readonly requiresType?: RequiredType; - /** Expose rule parameters for external reading */ - readonly constraints?: Record; - /** true when the rule is explicitly async and must be awaited */ - readonly isAsync?: boolean; -} - -/** @internal internal rule shape used by builders for optimization metadata */ -export interface InternalRule extends EmittableRule { - readonly plan?: RulePlan; -} - -export type RulePlanExpr = - | { kind: RulePlanExprKind.Value } - | { kind: RulePlanExprKind.Member; object: RulePlanExpr; property: 'length' } - | { kind: RulePlanExprKind.Call0; object: RulePlanExpr; method: 'getTime' } - | { kind: RulePlanExprKind.Literal; value: number }; - -export type RulePlanCheck = - | { kind: RulePlanCheckKind.Compare; left: RulePlanExpr; op: RuleOp; right: RulePlanExpr } - | { kind: RulePlanCheckKind.And | RulePlanCheckKind.Or; checks: RulePlanCheck[] }; - -export interface RulePlan { - cacheKey?: CacheKey; - failure: RulePlanCheck; -} - -// ───────────────────────────────────────────────────────────────────────────── -// RuleDef / TransformDef / ExposeDef / ExcludeDef / TypeDef (§2.1) -// ───────────────────────────────────────────────────────────────────────────── - -/** Arguments for user-defined message callback */ -export interface MessageArgs { - property: string; - value: unknown; - constraints: Record; -} - -export interface RuleDef { - rule: InternalRule; - each?: boolean; - groups?: string[]; - /** Value to include in BakerIssue.message on validation failure */ - message?: string | ((args: MessageArgs) => string); - /** Arbitrary value to include in BakerIssue.context on validation failure */ - context?: unknown; -} - -export interface TransformParams { - value: unknown; - key: string; - obj: Record; -} - -export interface Transformer { - deserialize(params: TransformParams): unknown | Promise; - serialize(params: TransformParams): unknown | Promise; -} - -/** Internal — direction-specific transform function stored after @Field processing */ -export type TransformFunction = (params: TransformParams) => unknown | Promise; - -export interface TransformDef { - fn: TransformFunction; - isAsync?: boolean; - options?: { - groups?: string[]; - deserializeOnly?: boolean; - serializeOnly?: boolean; - }; -} - -export interface ExposeDef { - name?: string; - groups?: string[]; - deserializeOnly?: boolean; - serializeOnly?: boolean; -} - -export interface ExcludeDef { - deserializeOnly?: boolean; - serializeOnly?: boolean; -} - -/** Generic class constructor — contravariant `never[]` args accept any user constructor */ -export type ClassCtor = new (...args: never[]) => T; - -export interface TypeDef { - fn: () => ClassCtor | ClassCtor[] | MapConstructor | SetConstructor; - discriminator?: { - property: string; - subTypes: { value: Function; name: string }[]; - }; - keepDiscriminatorProperty?: boolean; - /** seal-time normalization result — true if fn() returns an array */ - isArray?: boolean; - /** seal-time normalization result — cached class after resolving fn() (DTOs only, excluding primitives) */ - resolvedClass?: ClassCtor; - /** seal-time normalization result — Map or Set collection type */ - collection?: CollectionType; - /** Nested DTO class thunk for Map value / Set element */ - collectionValue?: () => ClassCtor; - /** seal-time normalization result — cached class after resolving collectionValue */ - resolvedCollectionValue?: ClassCtor; -} - -// ───────────────────────────────────────────────────────────────────────────── -// PropertyFlags — @IsOptional, @IsDefined, @ValidateIf, @ValidateNested (§2.1) -// ───────────────────────────────────────────────────────────────────────────── - -export interface PropertyFlags { - /** `@IsOptional`() — skip all validation when undefined/null */ - isOptional?: boolean; - /** `@IsDefined`() — disallow undefined (overrides @IsOptional). Current code rejects only undefined; null is delegated to subsequent validation */ - isDefined?: boolean; - /** `@IsNullable`() — allow and assign null, reject undefined */ - isNullable?: boolean; - /** `@ValidateIf`(cond) — skip all field validation when false */ - validateIf?: (obj: Record) => boolean; - /** `@ValidateNested`() — trigger recursive validation for nested DTOs. Used with @Type */ - validateNested?: boolean; - /** `@ValidateNested`({ each: true }) — validate nested DTOs per array element */ - validateNestedEach?: boolean; -} - -// ───────────────────────────────────────────────────────────────────────────── -// RawPropertyMeta — Collection data stored in Class[Symbol.metadata][RAW][propertyKey] (§2.1) -// ───────────────────────────────────────────────────────────────────────────── - -export interface RawPropertyMeta { - validation: RuleDef[]; - transform: TransformDef[]; - expose: ExposeDef[]; - exclude: ExcludeDef | null; - type: TypeDef | null; - flags: PropertyFlags; - /** Field-level message applied to ALL failures of this field (gate/structural/required/conversion/rule) */ - message?: string | ((args: MessageArgs) => string); - /** Field-level context attached to ALL failures of this field */ - context?: unknown; -} - -export interface RawClassMeta { - [propertyKey: string]: RawPropertyMeta; -} - -// ───────────────────────────────────────────────────────────────────────────── -// SealedExecutors — Dual executor stored in the Baker's per-instance executor map (§2.1) -// ───────────────────────────────────────────────────────────────────────────── - -export interface SealedExecutors { - /** Internal executor — Result pattern. deserialize() wraps and converts to throw */ - deserialize(input: unknown, options?: RuntimeOptions): Result | ResultAsync; - /** Internal executor — always succeeds. serialize assumes no validation */ - serialize(instance: T, options?: RuntimeOptions): Record | Promise>; - /** Internal executor — validate-only (no object creation). Returns null on success, BakerIssue[] on failure */ - validate(input: unknown, options?: RuntimeOptions): BakerIssue[] | null | Promise; - /** true if the deserialize direction has async rules/transforms/nested */ - isAsync: boolean; - /** true if the serialize direction has async transforms/nested */ - isSerializeAsync: boolean; - /** Merged metadata cache — used internally by unseal helper */ - merged?: RawClassMeta; -} diff --git a/test/e2e/async-transform.test.ts b/test/e2e/async-transform.test.ts index d5c8a47..6c30948 100644 --- a/test/e2e/async-transform.test.ts +++ b/test/e2e/async-transform.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, afterEach, beforeEach } from 'bun:test'; import { Baker, Field } from '../../index'; +import { isAsyncFunction } from '../../src/common/utils'; import { isString, isNumber } from '../../src/rules/index'; -import { isAsyncFunction } from '../../src/utils'; import { sealClass } from '../integration/helpers/seal'; import { unseal } from '../integration/helpers/unseal'; @@ -89,9 +89,9 @@ describe('async @Transform — deserialize', () => { } const promiseDeserializeBaker = sealClass(PromiseDeserializeDto); - expect(() => promiseDeserializeBaker.deserialize(PromiseDeserializeDto, { name: ' Alice ' })).toThrow( - 'deserialize transform returned Promise', - ); + expect(() => + promiseDeserializeBaker.deserialize(PromiseDeserializeDto, { name: ' Alice ' }), + ).toThrow('deserialize transform returned Promise'); }); }); diff --git a/test/e2e/baker-scoped-isolation.test.ts b/test/e2e/baker-scoped-isolation.test.ts index b310898..b314c44 100644 --- a/test/e2e/baker-scoped-isolation.test.ts +++ b/test/e2e/baker-scoped-isolation.test.ts @@ -120,7 +120,13 @@ describe('Baker-scoped runtime — per-app config isolation', () => { class Owner { @Field({ type: () => Dog, - discriminator: { property: 'kind', subTypes: [{ value: Dog, name: 'dog' }, { value: Cat, name: 'cat' }] }, + discriminator: { + property: 'kind', + subTypes: [ + { value: Dog, name: 'dog' }, + { value: Cat, name: 'cat' }, + ], + }, }) pet!: Dog | Cat; } diff --git a/test/e2e/boundary-values.test.ts b/test/e2e/boundary-values.test.ts index 4e924d4..6004741 100644 --- a/test/e2e/boundary-values.test.ts +++ b/test/e2e/boundary-values.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, afterEach, beforeEach } from 'bun:test'; import { Baker, Field, isBakerIssueSet } from '../../index'; -import { assertBakerIssueSet } from '../integration/helpers/assert'; import { isString, isNumber, @@ -21,6 +20,7 @@ import { arrayMinSize, arrayMaxSize, } from '../../src/rules/index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; import { sealClass } from '../integration/helpers/seal'; import { unseal } from '../integration/helpers/unseal'; diff --git a/test/e2e/circular-check.test.ts b/test/e2e/circular-check.test.ts index 43c1c3c..324543d 100644 --- a/test/e2e/circular-check.test.ts +++ b/test/e2e/circular-check.test.ts @@ -19,6 +19,24 @@ class TreeNode { child?: TreeNode; } +// ─── Cycle introduced only through an inherited @Type field ────────────────── +// InheritedBase declares `next: () => InheritedDerived`; InheritedDerived extends it and inherits +// that field → InheritedDerived -> InheritedDerived is a cycle visible only in the merged metadata. +@baker.Recipe +class InheritedBase { + @Field(isString) + value!: string; + + @Field({ optional: true, type: () => InheritedDerived }) + next?: InheritedDerived; +} + +@baker.Recipe +class InheritedDerived extends InheritedBase { + @Field({ optional: true }) + extra?: string; +} + // ───────────────────────────────────────────────────────────────────────────── describe('circular reference detection', () => { @@ -42,6 +60,16 @@ describe('circular reference detection', () => { expect(err).toBeDefined(); }); + it('cycle through an inherited @Type field → circular error (not stack overflow)', async () => { + const circular: { value: string; extra: string; next?: unknown } = { value: 'a', extra: 'x' }; + circular.next = circular; // self-reference via the field inherited from InheritedBase + + const result = await baker.deserialize(InheritedDerived, circular); + assertBakerIssueSet(result); + const err = result.errors.find(e => e.code === 'circular'); + expect(err).toBeDefined(); + }); + it('auto mode (default) → auto-detects circular structure DTO', async () => { const result = (await baker.deserialize(TreeNode, { value: 'root', diff --git a/test/e2e/date-constraints.test.ts b/test/e2e/date-constraints.test.ts index 926c090..d4280d3 100644 --- a/test/e2e/date-constraints.test.ts +++ b/test/e2e/date-constraints.test.ts @@ -42,11 +42,15 @@ describe('@MinDate/@MaxDate', () => { }); it('before range → rejected', async () => { - expect(isBakerIssueSet(await baker.deserialize(DateRangeDto, { eventDate: new Date('2019-12-31T23:59:59.999Z') }))).toBe(true); + expect(isBakerIssueSet(await baker.deserialize(DateRangeDto, { eventDate: new Date('2019-12-31T23:59:59.999Z') }))).toBe( + true, + ); }); it('after range → rejected', async () => { - expect(isBakerIssueSet(await baker.deserialize(DateRangeDto, { eventDate: new Date('2026-01-01T00:00:00.000Z') }))).toBe(true); + expect(isBakerIssueSet(await baker.deserialize(DateRangeDto, { eventDate: new Date('2026-01-01T00:00:00.000Z') }))).toBe( + true, + ); }); it('non-Date value → isDate error', async () => { diff --git a/test/e2e/discriminator-advanced.test.ts b/test/e2e/discriminator-advanced.test.ts index 4f1486f..98b58a8 100644 --- a/test/e2e/discriminator-advanced.test.ts +++ b/test/e2e/discriminator-advanced.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import { Baker, Field, isBakerIssueSet } from '../../index'; +import { Baker, BakerError, Field, isBakerIssueSet } from '../../index'; import { isString, isBoolean } from '../../src/rules/index'; import { assertBakerIssueSet } from '../integration/helpers/assert'; @@ -113,23 +113,101 @@ class OwnerArrayDto { pets!: (DogDto | CatDto)[]; } +describe('discriminator — deserialize array (each)', () => { + it('deserializes an array of discriminated elements, dispatching per element', async () => { + const result = (await baker.deserialize(OwnerArrayDto, { + name: 'Bob', + pets: [ + { type: 'dog', breed: 'Shiba' }, + { type: 'cat', indoor: true }, + ], + })) as OwnerArrayDto; + expect(result.name).toBe('Bob'); + expect(result.pets).toHaveLength(2); + expect(result.pets[0]).toEqual({ breed: 'Shiba' } as DogDto); + expect(result.pets[1]).toEqual({ indoor: true } as unknown as CatDto); + }); + + it('reports invalidDiscriminator at the element path for a bad element', async () => { + const result = await baker.deserialize(OwnerArrayDto, { + name: 'Bob', + pets: [ + { type: 'dog', breed: 'Shiba' }, + { type: 'fish', glub: true }, + ], + }); + assertBakerIssueSet(result); + const bad = result.errors.find(e => e.path === 'pets[1]'); + expect(bad).toBeDefined(); + expect(bad!.code).toBe('invalidDiscriminator'); + }); + + it('validates element fields with element-level paths', async () => { + const result = await baker.deserialize(OwnerArrayDto, { + name: 'Bob', + pets: [{ type: 'dog', breed: 123 }], + }); + assertBakerIssueSet(result); + expect(result.errors.some(e => e.path === 'pets[0].breed')).toBe(true); + }); + + it('validate() accepts a valid discriminated array', async () => { + const result = await baker.validate(OwnerArrayDto, { + name: 'Bob', + pets: [ + { type: 'dog', breed: 'Shiba' }, + { type: 'cat', indoor: true }, + ], + }); + expect(result).toBe(true); + }); + + it('validate() reports invalidDiscriminator at the element path', async () => { + const result = await baker.validate(OwnerArrayDto, { + name: 'Bob', + pets: [{ type: 'fish', glub: true }], + }); + assertBakerIssueSet(result); + const bad = result.errors.find(e => e.path === 'pets[0]'); + expect(bad).toBeDefined(); + expect(bad!.code).toBe('invalidDiscriminator'); + }); +}); + describe('discriminator — serialize', () => { - it('should serialize single discriminator field with instanceof dispatch', async () => { + it('should serialize single discriminator field with instanceof dispatch (default drops discriminator key)', async () => { const dog = Object.assign(new DogDto(), { breed: 'Shiba' }); const owner = Object.assign(new OwnerDto(), { name: 'Alice', pet: dog }); const result = await baker.serialize(owner); - expect(result.pet).toEqual({ breed: 'Shiba', type: 'dog' }); + expect(result.pet).toEqual({ breed: 'Shiba' }); }); - it('should serialize array discriminator field with instanceof dispatch', async () => { + it('should serialize array discriminator field with instanceof dispatch (default drops discriminator key)', async () => { const dog = Object.assign(new DogDto(), { breed: 'Poodle' }); const cat = Object.assign(new CatDto(), { indoor: true }); const owner = Object.assign(new OwnerArrayDto(), { name: 'Bob', pets: [dog, cat] }); const result = await baker.serialize(owner); - expect(result.pets).toEqual([ - { breed: 'Poodle', type: 'dog' }, - { indoor: true, type: 'cat' }, - ]); + expect(result.pets).toEqual([{ breed: 'Poodle' }, { indoor: true }]); + }); + + it('keepDiscriminatorProperty:true → serialize retains the discriminator key', async () => { + const dog = Object.assign(new DogDto(), { breed: 'Shiba' }); + const owner = Object.assign(new OwnerKeepDiscDto(), { pet: dog }); + const result = await baker.serialize(owner); + expect(result.pet).toEqual({ breed: 'Shiba', kind: 'dog' }); + }); + + it('default (unset) → discriminator key dropped symmetrically across deserialize + serialize (round-trip)', async () => { + const de = (await baker.deserialize(OwnerDto, { name: 'Z', pet: { type: 'cat', indoor: true } })) as OwnerDto; + expect((de.pet as { type?: unknown }).type).toBeUndefined(); + const ser = await baker.serialize(de); + expect((ser.pet as { type?: unknown }).type).toBeUndefined(); + }); + + it('instance matching NO subtype → throws BakerError instead of leaking the raw object', () => { + // `pet` is a plain object, not an instance of any subtype (DogDto/CatDto). + const owner = Object.assign(new OwnerDto(), { name: 'Ghost', pet: { breed: 'ghost', venom: 'yes' } }); + expect(() => baker.serializeSync(owner)).toThrow(BakerError); }); }); @@ -331,4 +409,132 @@ describe('async serialize: discriminator + array (each)', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// async deserialize/validate: discriminator + array (exercises the `await` codegen branch) +// ───────────────────────────────────────────────────────────────────────────── + +describe('discriminator + array — async deserialize/validate', () => { + const ab = new Baker(); + @ab.Recipe + class CatA { + @Field(isString) kind!: string; + @Field(isString, { transform: { deserialize: async ({ value }) => value, serialize: ({ value }) => value } }) + meow!: string; + } + @ab.Recipe + class DogA { + @Field(isString) kind!: string; + @Field(isString) bark!: string; + } + @ab.Recipe + class OwnerA { + @Field({ + type: () => [CatA], + discriminator: { + property: 'kind', + subTypes: [ + { value: CatA, name: 'cat' }, + { value: DogA, name: 'dog' }, + ], + }, + }) + pets!: (CatA | DogA)[]; + } + ab.seal(); + + it('deserialize resolves a discriminated array through an async element transform', async () => { + const r = await ab.deserialize(OwnerA, { + pets: [ + { kind: 'cat', meow: 'nya' }, + { kind: 'dog', bark: 'woof' }, + ], + }); + expect(isBakerIssueSet(r)).toBe(false); + expect((r as OwnerA).pets).toHaveLength(2); + }); + + it('deserialize reports invalidDiscriminator at the element path (async)', async () => { + const r = await ab.deserialize(OwnerA, { pets: [{ kind: 'fish' }] }); + assertBakerIssueSet(r); + const bad = r.errors.find(e => e.path === 'pets[0]'); + expect(bad).toBeDefined(); + expect(bad!.code).toBe('invalidDiscriminator'); + }); + + it('validate accepts a valid discriminated array (async)', async () => { + const r = await ab.validate(OwnerA, { + pets: [ + { kind: 'cat', meow: 'nya' }, + { kind: 'dog', bark: 'woof' }, + ], + }); + expect(r).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// discriminator + array under stopAtFirstError (exercises the early-return branches) +// ───────────────────────────────────────────────────────────────────────────── + +describe('discriminator + array — stopAtFirstError (early return)', () => { + const sb = new Baker({ stopAtFirstError: true }); + @sb.Recipe + class Cat2 { + @Field(isString) kind!: string; + @Field(isString) meow!: string; + } + @sb.Recipe + class Dog2 { + @Field(isString) kind!: string; + @Field(isString) bark!: string; + } + @sb.Recipe + class Owner2 { + @Field(isString) name!: string; + @Field({ + type: () => [Cat2], + discriminator: { + property: 'kind', + subTypes: [ + { value: Cat2, name: 'cat' }, + { value: Dog2, name: 'dog' }, + ], + }, + }) + pets!: (Cat2 | Dog2)[]; + } + sb.seal(); + + it('deserialize returns invalidDiscriminator on the first bad element', async () => { + const r = await sb.deserialize(Owner2, { name: 'A', pets: [{ kind: 'fish' }] }); + assertBakerIssueSet(r); + expect(r.errors[0]!.path).toBe('pets[0]'); + expect(r.errors[0]!.code).toBe('invalidDiscriminator'); + }); + + it('deserialize returns the first nested element error with element path', async () => { + const r = await sb.deserialize(Owner2, { name: 'A', pets: [{ kind: 'cat', meow: 123 }] }); + assertBakerIssueSet(r); + expect(r.errors[0]!.path).toBe('pets[0].meow'); + }); + + it('validate returns invalidDiscriminator on the first bad element', async () => { + const r = await sb.validate(Owner2, { name: 'A', pets: [{ kind: 'fish' }] }); + assertBakerIssueSet(r); + expect(r.errors[0]!.path).toBe('pets[0]'); + expect(r.errors[0]!.code).toBe('invalidDiscriminator'); + }); + + it('deserialize succeeds on a valid array', async () => { + const r = (await sb.deserialize(Owner2, { + name: 'A', + pets: [ + { kind: 'cat', meow: 'nya' }, + { kind: 'dog', bark: 'woof' }, + ], + })) as Owner2; + expect(r.pets).toHaveLength(2); + }); +}); + baker.seal(); diff --git a/test/e2e/field-message.test.ts b/test/e2e/field-message.test.ts index 533a3ed..65ce9d5 100644 --- a/test/e2e/field-message.test.ts +++ b/test/e2e/field-message.test.ts @@ -47,6 +47,14 @@ class ArrayOfMessageDto { tags!: string[]; } +@baker.Recipe +class ArrayOfFnMessageDto { + @Field(arrayOf(minLength(5)), { + message: ({ value }) => `el:${JSON.stringify(value)}`, + }) + tags!: string[]; +} + @baker.Recipe class NoMessageDto { @Field(isString) @@ -196,6 +204,14 @@ describe('@Field message — used with arrayOf', () => { expect(error.message).toBe('Each tag must be a non-empty string'); } }); + + it('function message receives the failing ELEMENT as value, not the whole array', async () => { + const result = await baker.deserialize(ArrayOfFnMessageDto, { tags: ['ab'] }); + assertBakerIssueSet(result); + expect(result.errors[0]!.path).toBe('tags[0]'); + // value must be the failing element 'ab' — not the whole array ['ab']. + expect(result.errors[0]!.message).toBe('el:"ab"'); + }); }); describe('@Field message not set', () => { diff --git a/test/e2e/fuzz-parity.test.ts b/test/e2e/fuzz-parity.test.ts index f674a08..31dd7cc 100644 --- a/test/e2e/fuzz-parity.test.ts +++ b/test/e2e/fuzz-parity.test.ts @@ -47,7 +47,7 @@ function randomValue(rng: () => number): unknown { } } -async function dtoPasses(rule: import('../../src/types').EmittableRule, value: unknown): Promise { +async function dtoPasses(rule: import('../../src/rules/interfaces').EmittableRule, value: unknown): Promise { class Dto { @Field(rule) value!: unknown; diff --git a/test/e2e/implicit-conversion.test.ts b/test/e2e/implicit-conversion.test.ts index b7c4554..dde5171 100644 --- a/test/e2e/implicit-conversion.test.ts +++ b/test/e2e/implicit-conversion.test.ts @@ -54,7 +54,9 @@ describe('enableImplicitConversion (autoConvert: true)', () => { }); it('unconvertible value → conversionFailed', async () => { - expect(isBakerIssueSet(await baker.deserialize(ConvDto, { age: 'notanumber', active: true, createdAt: new Date() }))).toBe(true); + expect(isBakerIssueSet(await baker.deserialize(ConvDto, { age: 'notanumber', active: true, createdAt: new Date() }))).toBe( + true, + ); }); it('explicit @Field transform present → conversion skipped', async () => { diff --git a/test/e2e/inheritance-message.test.ts b/test/e2e/inheritance-message.test.ts new file mode 100644 index 0000000..2dd0e66 --- /dev/null +++ b/test/e2e/inheritance-message.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'bun:test'; + +import { Baker, Field } from '../../index'; +import { isString, minLength } from '../../src/rules/index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; + +const baker = new Baker(); + +@baker.Recipe +class MsgParent { + @Field(isString, { message: 'parent message', context: { from: 'parent' } }) + name!: string; +} + +// Child re-declares `name` (adding a rule) WITHOUT its own message/context. Field-level +// message/context must inherit from the parent, mirroring how type/expose/exclude/transform inherit. +@baker.Recipe +class MsgChild extends MsgParent { + @Field(isString, minLength(3)) + override name = ''; +} + +// Child that re-declares `name` WITH its own message → child wins (no inheritance). +@baker.Recipe +class MsgOverrideChild extends MsgParent { + @Field(isString, minLength(3), { message: 'child message' }) + override name = ''; +} + +baker.seal(); + +describe('inheritance — field-level message/context', () => { + it('child overriding a field without a message inherits the parent field message', async () => { + const result = await baker.deserialize(MsgChild, { name: 'ab' }); + assertBakerIssueSet(result); + const err = result.errors.find(e => e.code === 'minLength'); + expect(err).toBeDefined(); + expect(err!.message).toBe('parent message'); + }); + + it('child overriding a field without a context inherits the parent field context', async () => { + const result = await baker.deserialize(MsgChild, { name: 'ab' }); + assertBakerIssueSet(result); + const err = result.errors.find(e => e.code === 'minLength'); + expect(err!.context).toEqual({ from: 'parent' }); + }); + + it('child supplying its own message overrides the parent message', async () => { + const result = await baker.deserialize(MsgOverrideChild, { name: 'ab' }); + assertBakerIssueSet(result); + const err = result.errors.find(e => e.code === 'minLength'); + expect(err!.message).toBe('child message'); + }); +}); diff --git a/test/e2e/multi-app-isolation.test.ts b/test/e2e/multi-app-isolation.test.ts index bea9ead..3f79036 100644 --- a/test/e2e/multi-app-isolation.test.ts +++ b/test/e2e/multi-app-isolation.test.ts @@ -24,7 +24,7 @@ describe('Baker — multi-app isolation', () => { expect((result as UserDto).name).toBe('Alice'); }); - it('does not seal another instance\'s class — each app seals only its own roots', () => { + it("does not seal another instance's class — each app seals only its own roots", () => { const appA = new Baker(); const appB = new Baker(); diff --git a/test/e2e/prefix-collision-validate.test.ts b/test/e2e/prefix-collision-validate.test.ts new file mode 100644 index 0000000..ff09689 --- /dev/null +++ b/test/e2e/prefix-collision-validate.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'bun:test'; + +import { Baker, Field } from '../../index'; +import { arrayOf } from '../../src/decorators/field'; +import { minLength } from '../../src/rules/index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; + +const baker = new Baker(); + +// Two validate-only Set fields whose sanitized keys are in a prefix relationship ('tag' ⊂ 'tags'). +// A substring-based dedup of the generated path-prefix var would skip declaring `__bk$ep_tag`, +// producing a ReferenceError in the validate executor when a 'tag' element fails. +@baker.Recipe +class PrefixSetDto { + @Field(arrayOf(minLength(2)), { type: () => Set }) + tags!: Set; + + @Field(arrayOf(minLength(2)), { type: () => Set }) + tag!: Set; +} + +baker.seal(); + +describe('validate — prefix-colliding Set field keys', () => { + it('reports element errors without a ReferenceError from a skipped path-prefix var', async () => { + const result = await baker.validate(PrefixSetDto, { tags: ['ok'], tag: ['x'] }); + assertBakerIssueSet(result); + const tagErr = result.errors.find(e => e.path.startsWith('tag[')); + expect(tagErr).toBeDefined(); + expect(tagErr!.code).toBe('minLength'); + }); +}); diff --git a/test/e2e/real-world-dto.test.ts b/test/e2e/real-world-dto.test.ts index c52cc72..d6dc2b8 100644 --- a/test/e2e/real-world-dto.test.ts +++ b/test/e2e/real-world-dto.test.ts @@ -137,7 +137,9 @@ describe('CreateUserDto — validation failure', () => { }); it('nested DTO validation failure', async () => { - expect(isBakerIssueSet(await baker.deserialize(CreateUserDto, { ...validInput, address: { city: '', street: 'ok' } }))).toBe(true); + expect(isBakerIssueSet(await baker.deserialize(CreateUserDto, { ...validInput, address: { city: '', street: 'ok' } }))).toBe( + true, + ); }); }); diff --git a/test/e2e/rule-semantics-parity.test.ts b/test/e2e/rule-semantics-parity.test.ts index a17250a..62aa3a7 100644 --- a/test/e2e/rule-semantics-parity.test.ts +++ b/test/e2e/rule-semantics-parity.test.ts @@ -37,7 +37,7 @@ afterEach(() => unseal()); type RuleCase = { name: string; - rule: import('../../src/types').EmittableRule; + rule: import('../../src/rules/interfaces').EmittableRule; samples: unknown[]; }; diff --git a/test/e2e/seal-error.test.ts b/test/e2e/seal-error.test.ts index aaf21fb..b994ba6 100644 --- a/test/e2e/seal-error.test.ts +++ b/test/e2e/seal-error.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import type { EmittableRule } from '../../src/types'; +import type { EmittableRule } from '../../src/rules/interfaces'; import { Baker, Field, BakerError } from '../../index'; import { isNumber } from '../../src/rules/index'; diff --git a/test/e2e/serialize-parity-meta.test.ts b/test/e2e/serialize-parity-meta.test.ts index 77996a8..e5124e1 100644 --- a/test/e2e/serialize-parity-meta.test.ts +++ b/test/e2e/serialize-parity-meta.test.ts @@ -94,7 +94,8 @@ describe('serialize parity meta', () => { expect(publicResult.pet).toBeUndefined(); const adminResult = await baker.serialize(dto, { groups: ['admin'] }); - expect(adminResult.pet).toEqual({ color: 'black', kind: 'dog' }); + // keepDiscriminatorProperty unset → discriminator key dropped (symmetric with deserialize) + expect(adminResult.pet).toEqual({ color: 'black' }); }); it('roundtrips directional names and serialize output contract together', async () => { diff --git a/test/e2e/serialize-pipeline.test.ts b/test/e2e/serialize-pipeline.test.ts index 8d244e6..5e79616 100644 --- a/test/e2e/serialize-pipeline.test.ts +++ b/test/e2e/serialize-pipeline.test.ts @@ -116,7 +116,9 @@ describe('serialize pipeline — direction @Expose', () => { }); it('deserialize → deserializeOnly @Expose name used', async () => { - const result = (await baker.deserialize(DirectionExposeDto, { user_name: 'Carol' })) as DirectionExposeDto; + const result = (await baker.deserialize(DirectionExposeDto, { + user_name: 'Carol', + })) as DirectionExposeDto; expect(result.name).toBe('Carol'); }); }); diff --git a/test/e2e/set-each-groups.test.ts b/test/e2e/set-each-groups.test.ts new file mode 100644 index 0000000..37c84f2 --- /dev/null +++ b/test/e2e/set-each-groups.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'bun:test'; + +import { Baker, Field } from '../../index'; +import { arrayOf } from '../../src/decorators/field'; +import { minLength } from '../../src/rules/index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; + +const baker = new Baker(); + +@baker.Recipe +class SetEachGroupDto { + // Set field whose per-element rule inherits the field group. The whole field — including its + // element rules — is gated by the field-level group check, so a non-matching runtime group skips it. + @Field(arrayOf(minLength(3)), { type: () => Set, groups: ['admin'] }) + tags!: Set; +} + +baker.seal(); + +describe('Set field + grouped each-rule + runtime groups', () => { + it('skips the field and its element rules when the runtime group does not match', async () => { + const result = (await baker.deserialize(SetEachGroupDto, { tags: ['ok', 'no'] }, { groups: ['viewer'] })) as { + tags?: unknown; + }; + expect((result as { errors?: unknown }).errors).toBeUndefined(); + expect(result.tags).toBeUndefined(); + }); + + it('runs the element rule when the runtime group matches', async () => { + const result = await baker.deserialize(SetEachGroupDto, { tags: ['ok', 'no'] }, { groups: ['admin'] }); + assertBakerIssueSet(result); + expect(result.errors.some(e => e.code === 'minLength')).toBe(true); + }); +}); diff --git a/test/e2e/string-semantics-parity-meta.test.ts b/test/e2e/string-semantics-parity-meta.test.ts index 5b90118..f521c34 100644 --- a/test/e2e/string-semantics-parity-meta.test.ts +++ b/test/e2e/string-semantics-parity-meta.test.ts @@ -36,11 +36,11 @@ afterEach(() => unseal()); type StringRuleCase = { name: string; - rule: import('../../src/types').EmittableRule; + rule: import('../../src/rules/interfaces').EmittableRule; samples: unknown[]; }; -async function dtoPasses(rule: import('../../src/types').EmittableRule, value: unknown): Promise { +async function dtoPasses(rule: import('../../src/rules/interfaces').EmittableRule, value: unknown): Promise { class Dto { @Field(rule) value!: unknown; diff --git a/test/e2e/string-validators-full.test.ts b/test/e2e/string-validators-full.test.ts index dd62378..1a64d34 100644 --- a/test/e2e/string-validators-full.test.ts +++ b/test/e2e/string-validators-full.test.ts @@ -172,10 +172,10 @@ describe('isNumberString', () => { }); }); -describe('isNumberString({ no_symbols: true })', () => { +describe('isNumberString({ noSymbols: true })', () => { @baker.Recipe class NumStrictDto { - @Field(isNumberString({ no_symbols: true })) v!: string; + @Field(isNumberString({ noSymbols: true })) v!: string; } it('pure digits passes', async () => { expect(((await baker.deserialize(NumStrictDto, { v: '12345' })) as NumStrictDto).v).toBe('12345'); @@ -439,10 +439,10 @@ describe('isFQDN', () => { }); }); -describe('isFQDN({ require_tld: false })', () => { +describe('isFQDN({ requireTld: false })', () => { @baker.Recipe class HostDto { - @Field(isFQDN({ require_tld: false })) host!: string; + @Field(isFQDN({ requireTld: false })) host!: string; } it('single-label hostname passes', async () => { const r = (await baker.deserialize(HostDto, { host: 'localhost' })) as HostDto; @@ -602,7 +602,9 @@ describe('isHash', () => { @Field(isHash('md5')) v!: string; } it('passes', async () => { - expect(((await baker.deserialize(D, { v: 'd41d8cd98f00b204e9800998ecf8427e' })) as D).v).toBe('d41d8cd98f00b204e9800998ecf8427e'); + expect(((await baker.deserialize(D, { v: 'd41d8cd98f00b204e9800998ecf8427e' })) as D).v).toBe( + 'd41d8cd98f00b204e9800998ecf8427e', + ); }); it('rejected', async () => { expect(isBakerIssueSet(await baker.deserialize(D, { v: 'nothash' }))).toBe(true); @@ -782,6 +784,16 @@ describe('isDateString', () => { it('rejected', async () => { expect(isBakerIssueSet(await baker.deserialize(D, { v: 'notdate' }))).toBe(true); }); + // Generated-code path must use the proleptic Gregorian leap rule for years 0–99 too (year 0 is leap). + it('accepts 0000-02-29 (year 0 is a leap year)', async () => { + expect(((await baker.deserialize(D, { v: '0000-02-29' })) as D).v).toBe('0000-02-29'); + }); + it('rejects 0001-02-29 (year 1 is not a leap year)', async () => { + expect(isBakerIssueSet(await baker.deserialize(D, { v: '0001-02-29' }))).toBe(true); + }); + it('rejects 1900-02-29 (divisible by 100, not 400)', async () => { + expect(isBakerIssueSet(await baker.deserialize(D, { v: '1900-02-29' }))).toBe(true); + }); }); describe('isCurrency', () => { diff --git a/test/e2e/string-validators.test.ts b/test/e2e/string-validators.test.ts index 3ede0e5..69fb2f4 100644 --- a/test/e2e/string-validators.test.ts +++ b/test/e2e/string-validators.test.ts @@ -14,6 +14,7 @@ import { contains, length, } from '../../src/rules/index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; const baker = new Baker(); @@ -163,6 +164,11 @@ describe('isISO8601 strict — codegen executor', () => { it('rejects out-of-range day', async () => { await reject('2021-02-30'); }); + it('emits exactly one isISO8601 issue when both date and time are out of range (collect-errors mode)', async () => { + const result = await baker.deserialize(ISO8601StrictDto, { ts: '2021-13-01T25:61:61' }); + assertBakerIssueSet(result); + expect(result.errors.filter(e => e.code === 'isISO8601')).toHaveLength(1); + }); }); describe('minLength / maxLength', () => { diff --git a/test/e2e/transformers.test.ts b/test/e2e/transformers.test.ts index a2f50f0..297dcc2 100644 --- a/test/e2e/transformers.test.ts +++ b/test/e2e/transformers.test.ts @@ -131,6 +131,11 @@ describe('unixSecondsTransformer', () => { const plain = await baker.serialize(result); expect(plain.value).toBe(epoch); }); + + it('passes a non-finite number through untouched (no manufactured Invalid Date)', () => { + expect(unixSecondsTransformer.deserialize({ value: NaN, key: 'value', obj: {} })).toBeNaN(); + expect(unixSecondsTransformer.deserialize({ value: Infinity, key: 'value', obj: {} })).toBe(Infinity); + }); }); // ─── 6. unixMillisTransformer ─────────────────────────────────────────────── diff --git a/test/e2e/validate-inline-parity.test.ts b/test/e2e/validate-inline-parity.test.ts index 02aa659..da88f49 100644 --- a/test/e2e/validate-inline-parity.test.ts +++ b/test/e2e/validate-inline-parity.test.ts @@ -4,7 +4,6 @@ import { Field, Baker, isBakerIssueSet } from '../../index'; import { isString, isNumber, isBoolean, min, max, minLength, arrayMinSize } from '../../src/rules/index'; import { assertBakerIssueSet } from '../integration/helpers/assert'; - /** * Parity test: validate() must return the same errors as deserialize() * for every nesting scenario. This proves the inline code generation @@ -777,4 +776,3 @@ describe('validate inline parity — discriminator array', () => { expectSameErrors(baker.deserialize(Shelter, input), baker.validate(Shelter, input), 'disc array invalid'); }); }); - diff --git a/test/e2e/validators-missing-e2e.test.ts b/test/e2e/validators-missing-e2e.test.ts index 260187e..694488e 100644 --- a/test/e2e/validators-missing-e2e.test.ts +++ b/test/e2e/validators-missing-e2e.test.ts @@ -152,7 +152,9 @@ describe('isCurrency', () => { describe('isMagnetURI', () => { it('valid → passes', async () => { - const result = await baker.deserialize(MagnetURIDto, { value: 'magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }); + const result = await baker.deserialize(MagnetURIDto, { + value: 'magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a', + }); expect(isBakerIssueSet(result)).toBe(false); }); it('invalid → error code isMagnetURI', async () => { diff --git a/test/integration/__snapshots__/codegen-snapshot.test.ts.snap b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap new file mode 100644 index 0000000..60c8bb6 --- /dev/null +++ b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap @@ -0,0 +1,1969 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`codegen byte-identity snapshot Simple @ default 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +if (typeof __bk$f_name !== 'string') __bk$errors.push({path:"name",code:"isString"}); +else { + var __bk$mark_name = __bk$errors.length; + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); + if (__bk$errors.length === __bk$mark_name) __bk$out["name"] = __bk$f_name; +} +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) __bk$errors.push({path:"age",code:"isNumber"}); +else { + var __bk$mark_age = __bk$errors.length; + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); + if (__bk$errors.length === __bk$mark_age) __bk$out["age"] = __bk$f_age; +} +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +if (typeof __bk$f_email !== 'string') __bk$errors.push({path:"email",code:"isString"}); +else { + var __bk$mark_email = __bk$errors.length; + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); + if (__bk$errors.length === __bk$mark_email) __bk$out["email"] = __bk$f_email; +} +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +var __bk$mark_active = __bk$errors.length; +if (typeof __bk$f_active !== 'boolean') __bk$errors.push({path:"active",code:"isBoolean"}); +if (__bk$errors.length === __bk$mark_active) __bk$out["active"] = __bk$f_active; +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://SimpleDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_name = instance["name"]; +__bk$out["name"] = __bk$fv_name; +var __bk$fv_age = instance["age"]; +__bk$out["age"] = __bk$fv_age; +var __bk$fv_email = instance["email"]; +__bk$out["email"] = __bk$fv_email; +var __bk$fv_active = instance["active"]; +__bk$out["active"] = __bk$fv_active; +return __bk$out; +//# sourceURL=baker://SimpleDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +if (typeof __bk$f_name !== 'string') __bk$errors.push({path:"name",code:"isString"}); +else { + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); +} +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) __bk$errors.push({path:"age",code:"isNumber"}); +else { + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); +} +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +if (typeof __bk$f_email !== 'string') __bk$errors.push({path:"email",code:"isString"}); +else { + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); +} +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +if (typeof __bk$f_active !== 'boolean') __bk$errors.push({path:"active",code:"isBoolean"}); +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://SimpleDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Nested @ default 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +var __bk$mark_id = __bk$errors.length; +if (typeof __bk$f_id !== 'string') __bk$errors.push({path:"id",code:"isString"}); +if (__bk$errors.length === __bk$mark_id) __bk$out["id"] = __bk$f_id; +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { + var __bk$r_inner = execs[0].deserialize(__bk$f_inner, opts); + if (isErr(__bk$r_inner)) { + var __bk$re_inner = __bk$r_inner.data; + var __bk$ppinner = "inner."; + for (var __bk$j_inner=0; __bk$j_inner<__bk$re_inner.length; __bk$j_inner++) { + var __neinner_e=__bk$re_inner[__bk$j_inner]; + if(__neinner_e.message===undefined&&__neinner_e.context===undefined){__bk$errors.push({path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code});} + else{var __neinner={path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code}; + if(__neinner_e.message!==undefined)__neinner.message=__neinner_e.message; + if(__neinner_e.context!==undefined)__neinner.context=__neinner_e.context; + __bk$errors.push(__neinner);} + } + } else { __bk$out["inner"] = __bk$r_inner; } +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +__bk$out["tags"] = __bk$f_tags; +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://NestedDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_id = instance["id"]; +__bk$out["id"] = __bk$fv_id; +var __bk$fv_inner = instance["inner"]; +if (__bk$fv_inner != null) { + __bk$out["inner"] = execs[0].serialize(__bk$fv_inner, opts); +} else { + __bk$out["inner"] = __bk$fv_inner; +} +var __bk$fv_tags = instance["tags"]; +__bk$out["tags"] = __bk$fv_tags; +return __bk$out; +//# sourceURL=baker://NestedDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +if (typeof __bk$f_id !== 'string') __bk$errors.push({path:"id",code:"isString"}); +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { +var __bk$f_inner_0_k = __bk$f_inner["k"]; +if (__bk$f_inner_0_k === undefined || __bk$f_inner_0_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +if (typeof __bk$f_inner_0_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (isNaN(__bk$f_inner_0_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (__bk$f_inner_0_k === Infinity || __bk$f_inner_0_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +} +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://NestedDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Collection @ default 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + var __bk$arr_set = new Set(); + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __bk$r_set = execs[0].deserialize(__bk$f_set[__bk$i_set], opts); + if (isErr(__bk$r_set)) { + var __bk$re_set = __bk$r_set.data; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + for (var __bk$j_set=0; __bk$j_set<__bk$re_set.length; __bk$j_set++) { + var __neset_e=__bk$re_set[__bk$j_set]; + if(__neset_e.message===undefined&&__neset_e.context===undefined){__bk$errors.push({path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code});} + else{var __neset={path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code}; + if(__neset_e.message!==undefined)__neset.message=__neset_e.message; + if(__neset_e.context!==undefined)__neset.context=__neset_e.context; + __bk$errors.push(__neset);} + } + } else { __bk$arr_set.add(__bk$r_set); } + } + __bk$out["set"] = __bk$arr_set; +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$arr_map = new Map(); + var __bk$mkmap = Object.keys(__bk$f_map); + for (var __bk$mimap=0; __bk$mimap<__bk$mkmap.length; __bk$mimap++) { + var __bk$kmap = __bk$mkmap[__bk$mimap]; + var __bk$r_map = execs[1].deserialize(__bk$f_map[__bk$kmap], opts); + if (isErr(__bk$r_map)) { + var __bk$re_map = __bk$r_map.data; + var __bk$ppmap = "map"+'['+__bk$kmap+'].'; + for (var __bk$j_map=0; __bk$j_map<__bk$re_map.length; __bk$j_map++) { + var __nemap_e=__bk$re_map[__bk$j_map]; + if(__nemap_e.message===undefined&&__nemap_e.context===undefined){__bk$errors.push({path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code});} + else{var __nemap={path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code}; + if(__nemap_e.message!==undefined)__nemap.message=__nemap_e.message; + if(__nemap_e.context!==undefined)__nemap.context=__nemap_e.context; + __bk$errors.push(__nemap);} + } + } else { __bk$arr_map.set(__bk$kmap, __bk$r_map); } + } + __bk$out["map"] = __bk$arr_map; +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://CollectionDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_set = instance["set"]; +if (__bk$fv_set != null) { + var __bk$saset = []; + for (var __bk$siset of __bk$fv_set) { + __bk$saset.push(__bk$siset == null ? __bk$siset : execs[0].serialize(__bk$siset, opts)); + } + __bk$out["set"] = __bk$saset; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$mmap = Object.create(null); + for (var __bk$memap of __bk$fv_map) { + if (typeof __bk$memap[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$memap[0] + '). Map serialization requires string keys.'); } + __bk$mmap[__bk$memap[0]] = __bk$memap[1] == null ? __bk$memap[1] : execs[1].serialize(__bk$memap[1], opts); + } + __bk$out["map"] = __bk$mmap; +} else { + __bk$out["map"] = __bk$fv_map; +} +return __bk$out; +//# sourceURL=baker://CollectionDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __il$setci = __bk$f_set[__bk$i_set]; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].',code:'invalidInput'}); + else { +var __bk$f_setc_0_k = __il$setci["k"]; +if (__bk$f_setc_0_k === undefined || __bk$f_setc_0_k === null) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_setc_0_k !== 'number') __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +} + } + } +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$vkmap = Object.keys(__bk$f_map); + for (var __bk$vimap=0; __bk$vimap<__bk$vkmap.length; __bk$vimap++) { + var __bk$kmap = __bk$vkmap[__bk$vimap]; + var __il$mapmi = __bk$f_map[__bk$kmap]; + if (__il$mapmi == null || typeof __il$mapmi !== 'object' || Array.isArray(__il$mapmi)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].',code:'invalidInput'}); + else { +var __bk$f_mapm_1_k = __il$mapmi["k"]; +if (__bk$f_mapm_1_k === undefined || __bk$f_mapm_1_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_mapm_1_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_mapm_1_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (__bk$f_mapm_1_k === Infinity || __bk$f_mapm_1_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +} + } + } +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://CollectionDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Simple @ autoConvert 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +var __bk$skip_name = false; +if (typeof __bk$f_name !== 'string') { + __bk$f_name = String(__bk$f_name); +} +if (!__bk$skip_name) { + var __bk$mark_name = __bk$errors.length; + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); + if (__bk$errors.length === __bk$mark_name) __bk$out["name"] = __bk$f_name; +} +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +var __bk$skip_age = false; +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) { + __bk$f_age = Number(__bk$f_age); + if (isNaN(__bk$f_age)) { __bk$errors.push({path:"age",code:"conversionFailed"}); __bk$skip_age = true; } +} +if (!__bk$skip_age) { + var __bk$mark_age = __bk$errors.length; + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); + if (__bk$errors.length === __bk$mark_age) __bk$out["age"] = __bk$f_age; +} +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +var __bk$skip_email = false; +if (typeof __bk$f_email !== 'string') { + __bk$f_email = String(__bk$f_email); +} +if (!__bk$skip_email) { + var __bk$mark_email = __bk$errors.length; + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); + if (__bk$errors.length === __bk$mark_email) __bk$out["email"] = __bk$f_email; +} +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +var __bk$skip_active = false; +if (typeof __bk$f_active !== 'boolean') { + if (__bk$f_active === 'true' || __bk$f_active === '1' || __bk$f_active === 1) __bk$f_active = true; + else if (__bk$f_active === 'false' || __bk$f_active === '0' || __bk$f_active === 0) __bk$f_active = false; + else { __bk$errors.push({path:"active",code:"conversionFailed"}); __bk$skip_active = true; } +} +if (!__bk$skip_active) { + var __bk$mark_active = __bk$errors.length; + if (__bk$errors.length === __bk$mark_active) __bk$out["active"] = __bk$f_active; +} +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://SimpleDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_name = instance["name"]; +__bk$out["name"] = __bk$fv_name; +var __bk$fv_age = instance["age"]; +__bk$out["age"] = __bk$fv_age; +var __bk$fv_email = instance["email"]; +__bk$out["email"] = __bk$fv_email; +var __bk$fv_active = instance["active"]; +__bk$out["active"] = __bk$fv_active; +return __bk$out; +//# sourceURL=baker://SimpleDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +var __bk$skip_name = false; +if (typeof __bk$f_name !== 'string') { + __bk$f_name = String(__bk$f_name); +} +if (!__bk$skip_name) { + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); +} +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +var __bk$skip_age = false; +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) { + __bk$f_age = Number(__bk$f_age); + if (isNaN(__bk$f_age)) { __bk$errors.push({path:"age",code:"conversionFailed"}); __bk$skip_age = true; } +} +if (!__bk$skip_age) { + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); +} +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +var __bk$skip_email = false; +if (typeof __bk$f_email !== 'string') { + __bk$f_email = String(__bk$f_email); +} +if (!__bk$skip_email) { + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); +} +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +var __bk$skip_active = false; +if (typeof __bk$f_active !== 'boolean') { + if (__bk$f_active === 'true' || __bk$f_active === '1' || __bk$f_active === 1) __bk$f_active = true; + else if (__bk$f_active === 'false' || __bk$f_active === '0' || __bk$f_active === 0) __bk$f_active = false; + else { __bk$errors.push({path:"active",code:"conversionFailed"}); __bk$skip_active = true; } +} +if (!__bk$skip_active) { +} +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://SimpleDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Nested @ autoConvert 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +var __bk$skip_id = false; +if (typeof __bk$f_id !== 'string') { + __bk$f_id = String(__bk$f_id); +} +if (!__bk$skip_id) { + var __bk$mark_id = __bk$errors.length; + if (__bk$errors.length === __bk$mark_id) __bk$out["id"] = __bk$f_id; +} +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { + var __bk$r_inner = execs[0].deserialize(__bk$f_inner, opts); + if (isErr(__bk$r_inner)) { + var __bk$re_inner = __bk$r_inner.data; + var __bk$ppinner = "inner."; + for (var __bk$j_inner=0; __bk$j_inner<__bk$re_inner.length; __bk$j_inner++) { + var __neinner_e=__bk$re_inner[__bk$j_inner]; + if(__neinner_e.message===undefined&&__neinner_e.context===undefined){__bk$errors.push({path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code});} + else{var __neinner={path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code}; + if(__neinner_e.message!==undefined)__neinner.message=__neinner_e.message; + if(__neinner_e.context!==undefined)__neinner.context=__neinner_e.context; + __bk$errors.push(__neinner);} + } + } else { __bk$out["inner"] = __bk$r_inner; } +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +__bk$out["tags"] = __bk$f_tags; +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://NestedDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_id = instance["id"]; +__bk$out["id"] = __bk$fv_id; +var __bk$fv_inner = instance["inner"]; +if (__bk$fv_inner != null) { + __bk$out["inner"] = execs[0].serialize(__bk$fv_inner, opts); +} else { + __bk$out["inner"] = __bk$fv_inner; +} +var __bk$fv_tags = instance["tags"]; +__bk$out["tags"] = __bk$fv_tags; +return __bk$out; +//# sourceURL=baker://NestedDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +var __bk$skip_id = false; +if (typeof __bk$f_id !== 'string') { + __bk$f_id = String(__bk$f_id); +} +if (!__bk$skip_id) { +} +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { +var __bk$f_inner_0_k = __bk$f_inner["k"]; +if (__bk$f_inner_0_k === undefined || __bk$f_inner_0_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +var __bk$skip_inner_0_k = false; +if (typeof __bk$f_inner_0_k !== 'number' || isNaN(__bk$f_inner_0_k)) { + __bk$f_inner_0_k = Number(__bk$f_inner_0_k); + if (isNaN(__bk$f_inner_0_k)) { __bk$errors.push({path:"inner."+"k",code:"conversionFailed"}); __bk$skip_inner_0_k = true; } +} +if (!__bk$skip_inner_0_k) { + if (__bk$f_inner_0_k === Infinity || __bk$f_inner_0_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +} +} +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://NestedDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Collection @ autoConvert 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + var __bk$arr_set = new Set(); + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __bk$r_set = execs[0].deserialize(__bk$f_set[__bk$i_set], opts); + if (isErr(__bk$r_set)) { + var __bk$re_set = __bk$r_set.data; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + for (var __bk$j_set=0; __bk$j_set<__bk$re_set.length; __bk$j_set++) { + var __neset_e=__bk$re_set[__bk$j_set]; + if(__neset_e.message===undefined&&__neset_e.context===undefined){__bk$errors.push({path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code});} + else{var __neset={path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code}; + if(__neset_e.message!==undefined)__neset.message=__neset_e.message; + if(__neset_e.context!==undefined)__neset.context=__neset_e.context; + __bk$errors.push(__neset);} + } + } else { __bk$arr_set.add(__bk$r_set); } + } + __bk$out["set"] = __bk$arr_set; +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$arr_map = new Map(); + var __bk$mkmap = Object.keys(__bk$f_map); + for (var __bk$mimap=0; __bk$mimap<__bk$mkmap.length; __bk$mimap++) { + var __bk$kmap = __bk$mkmap[__bk$mimap]; + var __bk$r_map = execs[1].deserialize(__bk$f_map[__bk$kmap], opts); + if (isErr(__bk$r_map)) { + var __bk$re_map = __bk$r_map.data; + var __bk$ppmap = "map"+'['+__bk$kmap+'].'; + for (var __bk$j_map=0; __bk$j_map<__bk$re_map.length; __bk$j_map++) { + var __nemap_e=__bk$re_map[__bk$j_map]; + if(__nemap_e.message===undefined&&__nemap_e.context===undefined){__bk$errors.push({path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code});} + else{var __nemap={path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code}; + if(__nemap_e.message!==undefined)__nemap.message=__nemap_e.message; + if(__nemap_e.context!==undefined)__nemap.context=__nemap_e.context; + __bk$errors.push(__nemap);} + } + } else { __bk$arr_map.set(__bk$kmap, __bk$r_map); } + } + __bk$out["map"] = __bk$arr_map; +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://CollectionDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_set = instance["set"]; +if (__bk$fv_set != null) { + var __bk$saset = []; + for (var __bk$siset of __bk$fv_set) { + __bk$saset.push(__bk$siset == null ? __bk$siset : execs[0].serialize(__bk$siset, opts)); + } + __bk$out["set"] = __bk$saset; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$mmap = Object.create(null); + for (var __bk$memap of __bk$fv_map) { + if (typeof __bk$memap[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$memap[0] + '). Map serialization requires string keys.'); } + __bk$mmap[__bk$memap[0]] = __bk$memap[1] == null ? __bk$memap[1] : execs[1].serialize(__bk$memap[1], opts); + } + __bk$out["map"] = __bk$mmap; +} else { + __bk$out["map"] = __bk$fv_map; +} +return __bk$out; +//# sourceURL=baker://CollectionDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __il$setci = __bk$f_set[__bk$i_set]; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].',code:'invalidInput'}); + else { +var __bk$f_setc_0_k = __il$setci["k"]; +if (__bk$f_setc_0_k === undefined || __bk$f_setc_0_k === null) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isDefined"}); +else { +var __bk$skip_setc_0_k = false; +if (typeof __bk$f_setc_0_k !== 'number' || isNaN(__bk$f_setc_0_k)) { + __bk$f_setc_0_k = Number(__bk$f_setc_0_k); + if (isNaN(__bk$f_setc_0_k)) { __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"conversionFailed"}); __bk$skip_setc_0_k = true; } +} +if (!__bk$skip_setc_0_k) { + if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +} +} + } + } +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$vkmap = Object.keys(__bk$f_map); + for (var __bk$vimap=0; __bk$vimap<__bk$vkmap.length; __bk$vimap++) { + var __bk$kmap = __bk$vkmap[__bk$vimap]; + var __il$mapmi = __bk$f_map[__bk$kmap]; + if (__il$mapmi == null || typeof __il$mapmi !== 'object' || Array.isArray(__il$mapmi)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].',code:'invalidInput'}); + else { +var __bk$f_mapm_1_k = __il$mapmi["k"]; +if (__bk$f_mapm_1_k === undefined || __bk$f_mapm_1_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +var __bk$skip_mapm_1_k = false; +if (typeof __bk$f_mapm_1_k !== 'number' || isNaN(__bk$f_mapm_1_k)) { + __bk$f_mapm_1_k = Number(__bk$f_mapm_1_k); + if (isNaN(__bk$f_mapm_1_k)) { __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"conversionFailed"}); __bk$skip_mapm_1_k = true; } +} +if (!__bk$skip_mapm_1_k) { + if (__bk$f_mapm_1_k === Infinity || __bk$f_mapm_1_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +} +} + } + } +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://CollectionDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Simple @ stopAtFirstError 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) return err([{path:"name",code:"isDefined"}]); +else { +if (typeof __bk$f_name !== 'string') return err([{path:"name",code:"isString"}]); +if (__bk$f_name.length < 2) return err([{path:"name",code:"minLength"}]); +__bk$out["name"] = __bk$f_name; +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) return err([{path:"age",code:"isDefined"}]); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) return err([{path:"age",code:"isNumber"}]); +if (__bk$f_age === Infinity || __bk$f_age === -Infinity) return err([{path:"age",code:"isNumber"}]); +if (__bk$f_age < 0) return err([{path:"age",code:"min"}]); +__bk$out["age"] = __bk$f_age; +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) return err([{path:"email",code:"isDefined"}]); +else { +if (typeof __bk$f_email !== 'string') return err([{path:"email",code:"isString"}]); +if (!re[0].test(__bk$f_email)) return err([{path:"email",code:"isEmail"}]); +__bk$out["email"] = __bk$f_email; +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) return err([{path:"active",code:"isDefined"}]); +else { +if (typeof __bk$f_active !== 'boolean') return err([{path:"active",code:"isBoolean"}]); +__bk$out["active"] = __bk$f_active; +} +return __bk$out; +//# sourceURL=baker://SimpleDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_name = instance["name"]; +__bk$out["name"] = __bk$fv_name; +var __bk$fv_age = instance["age"]; +__bk$out["age"] = __bk$fv_age; +var __bk$fv_email = instance["email"]; +__bk$out["email"] = __bk$fv_email; +var __bk$fv_active = instance["active"]; +__bk$out["active"] = __bk$fv_active; +return __bk$out; +//# sourceURL=baker://SimpleDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) return [{path:"name",code:"isDefined"}]; +else { +if (typeof __bk$f_name !== 'string') return [{path:"name",code:"isString"}]; +if (__bk$f_name.length < 2) return [{path:"name",code:"minLength"}]; +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) return [{path:"age",code:"isDefined"}]; +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) return [{path:"age",code:"isNumber"}]; +if (__bk$f_age === Infinity || __bk$f_age === -Infinity) return [{path:"age",code:"isNumber"}]; +if (__bk$f_age < 0) return [{path:"age",code:"min"}]; +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) return [{path:"email",code:"isDefined"}]; +else { +if (typeof __bk$f_email !== 'string') return [{path:"email",code:"isString"}]; +if (!re[0].test(__bk$f_email)) return [{path:"email",code:"isEmail"}]; +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) return [{path:"active",code:"isDefined"}]; +else { +if (typeof __bk$f_active !== 'boolean') return [{path:"active",code:"isBoolean"}]; +} +return null; +//# sourceURL=baker://SimpleDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Nested @ stopAtFirstError 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) return err([{path:"id",code:"isDefined"}]); +else { +if (typeof __bk$f_id !== 'string') return err([{path:"id",code:"isString"}]); +__bk$out["id"] = __bk$f_id; +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) return err([{path:"inner",code:"isDefined"}]); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { + var __bk$r_inner = execs[0].deserialize(__bk$f_inner, opts); + if (isErr(__bk$r_inner)) { + var __bk$re_inner = __bk$r_inner.data; + var __bk$ppinner = "inner."; + var __neinner_e=__bk$re_inner[0]; + if(__neinner_e.message===undefined&&__neinner_e.context===undefined)return err([{path:__bk$ppinner+__bk$re_inner[0].path,code:__neinner_e.code}]); + var __neinner={path:__bk$ppinner+__bk$re_inner[0].path,code:__neinner_e.code}; + if(__neinner_e.message!==undefined)__neinner.message=__neinner_e.message; + if(__neinner_e.context!==undefined)__neinner.context=__neinner_e.context; + return err([__neinner]); + } else { __bk$out["inner"] = __bk$r_inner; } +} else { return err([{path:"inner",code:"isObject"}]); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) return err([{path:"tags",code:"isDefined"}]); +else { +__bk$out["tags"] = __bk$f_tags; +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) return err([{path:"tags",code:"isArray"}]); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') return err([{path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}]); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') return err([{path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}]); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') return err([{path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}]); + __bk$mi_tags++; + } +} +} +return __bk$out; +//# sourceURL=baker://NestedDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_id = instance["id"]; +__bk$out["id"] = __bk$fv_id; +var __bk$fv_inner = instance["inner"]; +if (__bk$fv_inner != null) { + __bk$out["inner"] = execs[0].serialize(__bk$fv_inner, opts); +} else { + __bk$out["inner"] = __bk$fv_inner; +} +var __bk$fv_tags = instance["tags"]; +__bk$out["tags"] = __bk$fv_tags; +return __bk$out; +//# sourceURL=baker://NestedDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) return [{path:"id",code:"isDefined"}]; +else { +if (typeof __bk$f_id !== 'string') return [{path:"id",code:"isString"}]; +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) return [{path:"inner",code:"isDefined"}]; +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { +var __bk$f_inner_0_k = __bk$f_inner["k"]; +if (__bk$f_inner_0_k === undefined || __bk$f_inner_0_k === null) return [{path:"inner."+"k",code:"isDefined"}]; +else { +if (typeof __bk$f_inner_0_k !== 'number') return [{path:"inner."+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_inner_0_k)) return [{path:"inner."+"k",code:"isNumber"}]; +else if (__bk$f_inner_0_k === Infinity || __bk$f_inner_0_k === -Infinity) return [{path:"inner."+"k",code:"isNumber"}]; +} +} else { return [{path:"inner",code:"isObject"}]; } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) return [{path:"tags",code:"isDefined"}]; +else { +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) return [{path:"tags",code:"isArray"}]; +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') return [{path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}]; + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') return [{path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}]; + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') return [{path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}]; + __bk$mi_tags++; + } +} +} +return null; +//# sourceURL=baker://NestedDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Collection @ stopAtFirstError 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) return err([{path:"set",code:"isDefined"}]); +else { +if (Array.isArray(__bk$f_set)) { + var __bk$arr_set = new Set(); + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __bk$r_set = execs[0].deserialize(__bk$f_set[__bk$i_set], opts); + if (isErr(__bk$r_set)) { + var __bk$re_set = __bk$r_set.data; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + var __neset_e=__bk$re_set[0]; + if(__neset_e.message===undefined&&__neset_e.context===undefined)return err([{path:__bk$ppset+__bk$re_set[0].path,code:__neset_e.code}]); + var __neset={path:__bk$ppset+__bk$re_set[0].path,code:__neset_e.code}; + if(__neset_e.message!==undefined)__neset.message=__neset_e.message; + if(__neset_e.context!==undefined)__neset.context=__neset_e.context; + return err([__neset]); + } else { __bk$arr_set.add(__bk$r_set); } + } + __bk$out["set"] = __bk$arr_set; +} else { return err([{path:"set",code:"isArray"}]); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) return err([{path:"map",code:"isDefined"}]); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$arr_map = new Map(); + var __bk$mkmap = Object.keys(__bk$f_map); + for (var __bk$mimap=0; __bk$mimap<__bk$mkmap.length; __bk$mimap++) { + var __bk$kmap = __bk$mkmap[__bk$mimap]; + var __bk$r_map = execs[1].deserialize(__bk$f_map[__bk$kmap], opts); + if (isErr(__bk$r_map)) { + var __bk$re_map = __bk$r_map.data; + var __bk$ppmap = "map"+'['+__bk$kmap+'].'; + var __nemap_e=__bk$re_map[0]; + if(__nemap_e.message===undefined&&__nemap_e.context===undefined)return err([{path:__bk$ppmap+__bk$re_map[0].path,code:__nemap_e.code}]); + var __nemap={path:__bk$ppmap+__bk$re_map[0].path,code:__nemap_e.code}; + if(__nemap_e.message!==undefined)__nemap.message=__nemap_e.message; + if(__nemap_e.context!==undefined)__nemap.context=__nemap_e.context; + return err([__nemap]); + } else { __bk$arr_map.set(__bk$kmap, __bk$r_map); } + } + __bk$out["map"] = __bk$arr_map; +} else { return err([{path:"map",code:"isObject"}]); } +} +return __bk$out; +//# sourceURL=baker://CollectionDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_set = instance["set"]; +if (__bk$fv_set != null) { + var __bk$saset = []; + for (var __bk$siset of __bk$fv_set) { + __bk$saset.push(__bk$siset == null ? __bk$siset : execs[0].serialize(__bk$siset, opts)); + } + __bk$out["set"] = __bk$saset; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$mmap = Object.create(null); + for (var __bk$memap of __bk$fv_map) { + if (typeof __bk$memap[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$memap[0] + '). Map serialization requires string keys.'); } + __bk$mmap[__bk$memap[0]] = __bk$memap[1] == null ? __bk$memap[1] : execs[1].serialize(__bk$memap[1], opts); + } + __bk$out["map"] = __bk$mmap; +} else { + __bk$out["map"] = __bk$fv_map; +} +return __bk$out; +//# sourceURL=baker://CollectionDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) return [{path:"set",code:"isDefined"}]; +else { +if (Array.isArray(__bk$f_set)) { + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __il$setci = __bk$f_set[__bk$i_set]; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) return [{path:"set"+'['+__bk$i_set+'].',code:'invalidInput'}]; + else { +var __bk$f_setc_0_k = __il$setci["k"]; +if (__bk$f_setc_0_k === undefined || __bk$f_setc_0_k === null) return [{path:"set"+'['+__bk$i_set+'].'+"k",code:"isDefined"}]; +else { +if (typeof __bk$f_setc_0_k !== 'number') return [{path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_setc_0_k)) return [{path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}]; +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) return [{path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}]; +} + } + } +} else { return [{path:"set",code:"isArray"}]; } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) return [{path:"map",code:"isDefined"}]; +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$vkmap = Object.keys(__bk$f_map); + for (var __bk$vimap=0; __bk$vimap<__bk$vkmap.length; __bk$vimap++) { + var __bk$kmap = __bk$vkmap[__bk$vimap]; + var __il$mapmi = __bk$f_map[__bk$kmap]; + if (__il$mapmi == null || typeof __il$mapmi !== 'object' || Array.isArray(__il$mapmi)) return [{path:"map"+'['+__bk$kmap+'].',code:'invalidInput'}]; + else { +var __bk$f_mapm_1_k = __il$mapmi["k"]; +if (__bk$f_mapm_1_k === undefined || __bk$f_mapm_1_k === null) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}]; +else { +if (typeof __bk$f_mapm_1_k !== 'number') return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_mapm_1_k)) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; +else if (__bk$f_mapm_1_k === Infinity || __bk$f_mapm_1_k === -Infinity) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; +} + } + } +} else { return [{path:"map",code:"isObject"}]; } +} +return null; +//# sourceURL=baker://CollectionDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Simple @ forbidUnknown 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var __bk$k=__wlk[__wli];if(!refs[0].has(__bk$k))__bk$errors.push({path:__bk$k,code:'whitelistViolation'});}} +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +if (typeof __bk$f_name !== 'string') __bk$errors.push({path:"name",code:"isString"}); +else { + var __bk$mark_name = __bk$errors.length; + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); + if (__bk$errors.length === __bk$mark_name) __bk$out["name"] = __bk$f_name; +} +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) __bk$errors.push({path:"age",code:"isNumber"}); +else { + var __bk$mark_age = __bk$errors.length; + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); + if (__bk$errors.length === __bk$mark_age) __bk$out["age"] = __bk$f_age; +} +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +if (typeof __bk$f_email !== 'string') __bk$errors.push({path:"email",code:"isString"}); +else { + var __bk$mark_email = __bk$errors.length; + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); + if (__bk$errors.length === __bk$mark_email) __bk$out["email"] = __bk$f_email; +} +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +var __bk$mark_active = __bk$errors.length; +if (typeof __bk$f_active !== 'boolean') __bk$errors.push({path:"active",code:"isBoolean"}); +if (__bk$errors.length === __bk$mark_active) __bk$out["active"] = __bk$f_active; +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://SimpleDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_name = instance["name"]; +__bk$out["name"] = __bk$fv_name; +var __bk$fv_age = instance["age"]; +__bk$out["age"] = __bk$fv_age; +var __bk$fv_email = instance["email"]; +__bk$out["email"] = __bk$fv_email; +var __bk$fv_active = instance["active"]; +__bk$out["active"] = __bk$fv_active; +return __bk$out; +//# sourceURL=baker://SimpleDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var __bk$k=__wlk[__wli];if(!refs[0].has(__bk$k))__bk$errors.push({path:__bk$k,code:'whitelistViolation'});}} +var __bk$f_name = input["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +if (typeof __bk$f_name !== 'string') __bk$errors.push({path:"name",code:"isString"}); +else { + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); +} +} +var __bk$f_age = input["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) __bk$errors.push({path:"age",code:"isNumber"}); +else { + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); +} +} +var __bk$f_email = input["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +if (typeof __bk$f_email !== 'string') __bk$errors.push({path:"email",code:"isString"}); +else { + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); +} +} +var __bk$f_active = input["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +if (typeof __bk$f_active !== 'boolean') __bk$errors.push({path:"active",code:"isBoolean"}); +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://SimpleDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Nested @ forbidUnknown 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var __bk$k=__wlk[__wli];if(!refs[0].has(__bk$k))__bk$errors.push({path:__bk$k,code:'whitelistViolation'});}} +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +var __bk$mark_id = __bk$errors.length; +if (typeof __bk$f_id !== 'string') __bk$errors.push({path:"id",code:"isString"}); +if (__bk$errors.length === __bk$mark_id) __bk$out["id"] = __bk$f_id; +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { + var __bk$r_inner = execs[0].deserialize(__bk$f_inner, opts); + if (isErr(__bk$r_inner)) { + var __bk$re_inner = __bk$r_inner.data; + var __bk$ppinner = "inner."; + for (var __bk$j_inner=0; __bk$j_inner<__bk$re_inner.length; __bk$j_inner++) { + var __neinner_e=__bk$re_inner[__bk$j_inner]; + if(__neinner_e.message===undefined&&__neinner_e.context===undefined){__bk$errors.push({path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code});} + else{var __neinner={path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code}; + if(__neinner_e.message!==undefined)__neinner.message=__neinner_e.message; + if(__neinner_e.context!==undefined)__neinner.context=__neinner_e.context; + __bk$errors.push(__neinner);} + } + } else { __bk$out["inner"] = __bk$r_inner; } +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +__bk$out["tags"] = __bk$f_tags; +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://NestedDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_id = instance["id"]; +__bk$out["id"] = __bk$fv_id; +var __bk$fv_inner = instance["inner"]; +if (__bk$fv_inner != null) { + __bk$out["inner"] = execs[0].serialize(__bk$fv_inner, opts); +} else { + __bk$out["inner"] = __bk$fv_inner; +} +var __bk$fv_tags = instance["tags"]; +__bk$out["tags"] = __bk$fv_tags; +return __bk$out; +//# sourceURL=baker://NestedDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var __bk$k=__wlk[__wli];if(!refs[0].has(__bk$k))__bk$errors.push({path:__bk$k,code:'whitelistViolation'});}} +var __bk$f_id = input["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +if (typeof __bk$f_id !== 'string') __bk$errors.push({path:"id",code:"isString"}); +} +var __bk$f_inner = input["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { +var __bk$f_inner_0_k = __bk$f_inner["k"]; +if (__bk$f_inner_0_k === undefined || __bk$f_inner_0_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +if (typeof __bk$f_inner_0_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (isNaN(__bk$f_inner_0_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (__bk$f_inner_0_k === Infinity || __bk$f_inner_0_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +} +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = input["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://NestedDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Collection @ forbidUnknown 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = Object.create(_Cls.prototype); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var __bk$k=__wlk[__wli];if(!refs[0].has(__bk$k))__bk$errors.push({path:__bk$k,code:'whitelistViolation'});}} +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + var __bk$arr_set = new Set(); + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __bk$r_set = execs[0].deserialize(__bk$f_set[__bk$i_set], opts); + if (isErr(__bk$r_set)) { + var __bk$re_set = __bk$r_set.data; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + for (var __bk$j_set=0; __bk$j_set<__bk$re_set.length; __bk$j_set++) { + var __neset_e=__bk$re_set[__bk$j_set]; + if(__neset_e.message===undefined&&__neset_e.context===undefined){__bk$errors.push({path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code});} + else{var __neset={path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code}; + if(__neset_e.message!==undefined)__neset.message=__neset_e.message; + if(__neset_e.context!==undefined)__neset.context=__neset_e.context; + __bk$errors.push(__neset);} + } + } else { __bk$arr_set.add(__bk$r_set); } + } + __bk$out["set"] = __bk$arr_set; +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$arr_map = new Map(); + var __bk$mkmap = Object.keys(__bk$f_map); + for (var __bk$mimap=0; __bk$mimap<__bk$mkmap.length; __bk$mimap++) { + var __bk$kmap = __bk$mkmap[__bk$mimap]; + var __bk$r_map = execs[1].deserialize(__bk$f_map[__bk$kmap], opts); + if (isErr(__bk$r_map)) { + var __bk$re_map = __bk$r_map.data; + var __bk$ppmap = "map"+'['+__bk$kmap+'].'; + for (var __bk$j_map=0; __bk$j_map<__bk$re_map.length; __bk$j_map++) { + var __nemap_e=__bk$re_map[__bk$j_map]; + if(__nemap_e.message===undefined&&__nemap_e.context===undefined){__bk$errors.push({path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code});} + else{var __nemap={path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code}; + if(__nemap_e.message!==undefined)__nemap.message=__nemap_e.message; + if(__nemap_e.context!==undefined)__nemap.context=__nemap_e.context; + __bk$errors.push(__nemap);} + } + } else { __bk$arr_map.set(__bk$kmap, __bk$r_map); } + } + __bk$out["map"] = __bk$arr_map; +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://CollectionDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_set = instance["set"]; +if (__bk$fv_set != null) { + var __bk$saset = []; + for (var __bk$siset of __bk$fv_set) { + __bk$saset.push(__bk$siset == null ? __bk$siset : execs[0].serialize(__bk$siset, opts)); + } + __bk$out["set"] = __bk$saset; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$mmap = Object.create(null); + for (var __bk$memap of __bk$fv_map) { + if (typeof __bk$memap[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$memap[0] + '). Map serialization requires string keys.'); } + __bk$mmap[__bk$memap[0]] = __bk$memap[1] == null ? __bk$memap[1] : execs[1].serialize(__bk$memap[1], opts); + } + __bk$out["map"] = __bk$mmap; +} else { + __bk$out["map"] = __bk$fv_map; +} +return __bk$out; +//# sourceURL=baker://CollectionDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +{var __wlk=Object.keys(input);for(var __wli=0;__wli<__wlk.length;__wli++){var __bk$k=__wlk[__wli];if(!refs[0].has(__bk$k))__bk$errors.push({path:__bk$k,code:'whitelistViolation'});}} +var __bk$f_set = input["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __il$setci = __bk$f_set[__bk$i_set]; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].',code:'invalidInput'}); + else { +var __bk$f_setc_0_k = __il$setci["k"]; +if (__bk$f_setc_0_k === undefined || __bk$f_setc_0_k === null) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_setc_0_k !== 'number') __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +} + } + } +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = input["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$vkmap = Object.keys(__bk$f_map); + for (var __bk$vimap=0; __bk$vimap<__bk$vkmap.length; __bk$vimap++) { + var __bk$kmap = __bk$vkmap[__bk$vimap]; + var __il$mapmi = __bk$f_map[__bk$kmap]; + if (__il$mapmi == null || typeof __il$mapmi !== 'object' || Array.isArray(__il$mapmi)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].',code:'invalidInput'}); + else { +var __bk$f_mapm_1_k = __il$mapmi["k"]; +if (__bk$f_mapm_1_k === undefined || __bk$f_mapm_1_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_mapm_1_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_mapm_1_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (__bk$f_mapm_1_k === Infinity || __bk$f_mapm_1_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +} + } + } +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://CollectionDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Simple @ allowClassDefaults 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = new _Cls(); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_name = Object.hasOwn(input, "name") ? input["name"] : __bk$out["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +if (typeof __bk$f_name !== 'string') __bk$errors.push({path:"name",code:"isString"}); +else { + var __bk$mark_name = __bk$errors.length; + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); + if (__bk$errors.length === __bk$mark_name) __bk$out["name"] = __bk$f_name; +} +} +var __bk$f_age = Object.hasOwn(input, "age") ? input["age"] : __bk$out["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) __bk$errors.push({path:"age",code:"isNumber"}); +else { + var __bk$mark_age = __bk$errors.length; + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); + if (__bk$errors.length === __bk$mark_age) __bk$out["age"] = __bk$f_age; +} +} +var __bk$f_email = Object.hasOwn(input, "email") ? input["email"] : __bk$out["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +if (typeof __bk$f_email !== 'string') __bk$errors.push({path:"email",code:"isString"}); +else { + var __bk$mark_email = __bk$errors.length; + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); + if (__bk$errors.length === __bk$mark_email) __bk$out["email"] = __bk$f_email; +} +} +var __bk$f_active = Object.hasOwn(input, "active") ? input["active"] : __bk$out["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +var __bk$mark_active = __bk$errors.length; +if (typeof __bk$f_active !== 'boolean') __bk$errors.push({path:"active",code:"isBoolean"}); +if (__bk$errors.length === __bk$mark_active) __bk$out["active"] = __bk$f_active; +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://SimpleDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_name = instance["name"]; +__bk$out["name"] = __bk$fv_name; +var __bk$fv_age = instance["age"]; +__bk$out["age"] = __bk$fv_age; +var __bk$fv_email = instance["email"]; +__bk$out["email"] = __bk$fv_email; +var __bk$fv_active = instance["active"]; +__bk$out["active"] = __bk$fv_active; +return __bk$out; +//# sourceURL=baker://SimpleDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$defs = new _Cls(); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_name = Object.hasOwn(input, "name") ? input["name"] : __bk$defs["name"]; +if (__bk$f_name === undefined || __bk$f_name === null) __bk$errors.push({path:"name",code:"isDefined"}); +else { +if (typeof __bk$f_name !== 'string') __bk$errors.push({path:"name",code:"isString"}); +else { + if (__bk$f_name.length < 2) __bk$errors.push({path:"name",code:"minLength"}); +} +} +var __bk$f_age = Object.hasOwn(input, "age") ? input["age"] : __bk$defs["age"]; +if (__bk$f_age === undefined || __bk$f_age === null) __bk$errors.push({path:"age",code:"isDefined"}); +else { +if (typeof __bk$f_age !== 'number' || isNaN(__bk$f_age)) __bk$errors.push({path:"age",code:"isNumber"}); +else { + if (__bk$f_age === Infinity || __bk$f_age === -Infinity) __bk$errors.push({path:"age",code:"isNumber"}); + if (__bk$f_age < 0) __bk$errors.push({path:"age",code:"min"}); +} +} +var __bk$f_email = Object.hasOwn(input, "email") ? input["email"] : __bk$defs["email"]; +if (__bk$f_email === undefined || __bk$f_email === null) __bk$errors.push({path:"email",code:"isDefined"}); +else { +if (typeof __bk$f_email !== 'string') __bk$errors.push({path:"email",code:"isString"}); +else { + if (!re[0].test(__bk$f_email)) __bk$errors.push({path:"email",code:"isEmail"}); +} +} +var __bk$f_active = Object.hasOwn(input, "active") ? input["active"] : __bk$defs["active"]; +if (__bk$f_active === undefined || __bk$f_active === null) __bk$errors.push({path:"active",code:"isDefined"}); +else { +if (typeof __bk$f_active !== 'boolean') __bk$errors.push({path:"active",code:"isBoolean"}); +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://SimpleDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Nested @ allowClassDefaults 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = new _Cls(); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_id = Object.hasOwn(input, "id") ? input["id"] : __bk$out["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +var __bk$mark_id = __bk$errors.length; +if (typeof __bk$f_id !== 'string') __bk$errors.push({path:"id",code:"isString"}); +if (__bk$errors.length === __bk$mark_id) __bk$out["id"] = __bk$f_id; +} +var __bk$f_inner = Object.hasOwn(input, "inner") ? input["inner"] : __bk$out["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { + var __bk$r_inner = execs[0].deserialize(__bk$f_inner, opts); + if (isErr(__bk$r_inner)) { + var __bk$re_inner = __bk$r_inner.data; + var __bk$ppinner = "inner."; + for (var __bk$j_inner=0; __bk$j_inner<__bk$re_inner.length; __bk$j_inner++) { + var __neinner_e=__bk$re_inner[__bk$j_inner]; + if(__neinner_e.message===undefined&&__neinner_e.context===undefined){__bk$errors.push({path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code});} + else{var __neinner={path:__bk$ppinner+__bk$re_inner[__bk$j_inner].path,code:__neinner_e.code}; + if(__neinner_e.message!==undefined)__neinner.message=__neinner_e.message; + if(__neinner_e.context!==undefined)__neinner.context=__neinner_e.context; + __bk$errors.push(__neinner);} + } + } else { __bk$out["inner"] = __bk$r_inner; } +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = Object.hasOwn(input, "tags") ? input["tags"] : __bk$out["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +__bk$out["tags"] = __bk$f_tags; +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://NestedDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_id = instance["id"]; +__bk$out["id"] = __bk$fv_id; +var __bk$fv_inner = instance["inner"]; +if (__bk$fv_inner != null) { + __bk$out["inner"] = execs[0].serialize(__bk$fv_inner, opts); +} else { + __bk$out["inner"] = __bk$fv_inner; +} +var __bk$fv_tags = instance["tags"]; +__bk$out["tags"] = __bk$fv_tags; +return __bk$out; +//# sourceURL=baker://NestedDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$defs = new _Cls(); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_id = Object.hasOwn(input, "id") ? input["id"] : __bk$defs["id"]; +if (__bk$f_id === undefined || __bk$f_id === null) __bk$errors.push({path:"id",code:"isDefined"}); +else { +if (typeof __bk$f_id !== 'string') __bk$errors.push({path:"id",code:"isString"}); +} +var __bk$f_inner = Object.hasOwn(input, "inner") ? input["inner"] : __bk$defs["inner"]; +if (__bk$f_inner === undefined || __bk$f_inner === null) __bk$errors.push({path:"inner",code:"isDefined"}); +else { +if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(__bk$f_inner)) { +var __bk$f_inner_0_k = __bk$f_inner["k"]; +if (__bk$f_inner_0_k === undefined || __bk$f_inner_0_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +if (typeof __bk$f_inner_0_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (isNaN(__bk$f_inner_0_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (__bk$f_inner_0_k === Infinity || __bk$f_inner_0_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +} +} else { __bk$errors.push({path:"inner",code:"isObject"}); } +} +var __bk$f_tags = Object.hasOwn(input, "tags") ? input["tags"] : __bk$defs["tags"]; +if (__bk$f_tags === undefined || __bk$f_tags === null) __bk$errors.push({path:"tags",code:"isDefined"}); +else { +var __bk$cktags = Array.isArray(__bk$f_tags)?1:(__bk$f_tags instanceof Set?2:(__bk$f_tags instanceof Map?3:0)); +var __bk$ep_tags = "tags"+'['; +if (__bk$cktags === 0) __bk$errors.push({path:"tags",code:"isArray"}); +if (__bk$cktags === 1) { + for (var __bk$i_tags=0; __bk$i_tags<__bk$f_tags.length; __bk$i_tags++) { + var __bk$eltags = __bk$f_tags[__bk$i_tags]; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + } +} else if (__bk$cktags === 2) { + var __bk$si_tags = 0; + for (var __bk$sv_tags of __bk$f_tags) { + var __bk$eltags = __bk$sv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + __bk$si_tags++; + } +} else if (__bk$cktags === 3) { + var __bk$mi_tags = 0; + for (var __bk$mv_tags of __bk$f_tags.values()) { + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + __bk$mi_tags++; + } +} +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://NestedDto/validate + }" +, +} +`; + +exports[`codegen byte-identity snapshot Collection @ allowClassDefaults 1`] = ` +{ + "deserialize": +"function(input, opts) { 'use strict'; +var __bk$out = new _Cls(); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return err([{path:'',code:'invalidInput'}]); +var __bk$f_set = Object.hasOwn(input, "set") ? input["set"] : __bk$out["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + var __bk$arr_set = new Set(); + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __bk$r_set = execs[0].deserialize(__bk$f_set[__bk$i_set], opts); + if (isErr(__bk$r_set)) { + var __bk$re_set = __bk$r_set.data; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + for (var __bk$j_set=0; __bk$j_set<__bk$re_set.length; __bk$j_set++) { + var __neset_e=__bk$re_set[__bk$j_set]; + if(__neset_e.message===undefined&&__neset_e.context===undefined){__bk$errors.push({path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code});} + else{var __neset={path:__bk$ppset+__bk$re_set[__bk$j_set].path,code:__neset_e.code}; + if(__neset_e.message!==undefined)__neset.message=__neset_e.message; + if(__neset_e.context!==undefined)__neset.context=__neset_e.context; + __bk$errors.push(__neset);} + } + } else { __bk$arr_set.add(__bk$r_set); } + } + __bk$out["set"] = __bk$arr_set; +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = Object.hasOwn(input, "map") ? input["map"] : __bk$out["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$arr_map = new Map(); + var __bk$mkmap = Object.keys(__bk$f_map); + for (var __bk$mimap=0; __bk$mimap<__bk$mkmap.length; __bk$mimap++) { + var __bk$kmap = __bk$mkmap[__bk$mimap]; + var __bk$r_map = execs[1].deserialize(__bk$f_map[__bk$kmap], opts); + if (isErr(__bk$r_map)) { + var __bk$re_map = __bk$r_map.data; + var __bk$ppmap = "map"+'['+__bk$kmap+'].'; + for (var __bk$j_map=0; __bk$j_map<__bk$re_map.length; __bk$j_map++) { + var __nemap_e=__bk$re_map[__bk$j_map]; + if(__nemap_e.message===undefined&&__nemap_e.context===undefined){__bk$errors.push({path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code});} + else{var __nemap={path:__bk$ppmap+__bk$re_map[__bk$j_map].path,code:__nemap_e.code}; + if(__nemap_e.message!==undefined)__nemap.message=__nemap_e.message; + if(__nemap_e.context!==undefined)__nemap.context=__nemap_e.context; + __bk$errors.push(__nemap);} + } + } else { __bk$arr_map.set(__bk$kmap, __bk$r_map); } + } + __bk$out["map"] = __bk$arr_map; +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return err(__bk$errors); +return __bk$out; +//# sourceURL=baker://CollectionDto/deserialize + }" +, + "serialize": +"function(instance, opts) { 'use strict'; +var __bk$out = {}; +var __bk$fv_set = instance["set"]; +if (__bk$fv_set != null) { + var __bk$saset = []; + for (var __bk$siset of __bk$fv_set) { + __bk$saset.push(__bk$siset == null ? __bk$siset : execs[0].serialize(__bk$siset, opts)); + } + __bk$out["set"] = __bk$saset; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$mmap = Object.create(null); + for (var __bk$memap of __bk$fv_map) { + if (typeof __bk$memap[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$memap[0] + '). Map serialization requires string keys.'); } + __bk$mmap[__bk$memap[0]] = __bk$memap[1] == null ? __bk$memap[1] : execs[1].serialize(__bk$memap[1], opts); + } + __bk$out["map"] = __bk$mmap; +} else { + __bk$out["map"] = __bk$fv_map; +} +return __bk$out; +//# sourceURL=baker://CollectionDto/serialize + }" +, + "validate": +"function(input, opts) { 'use strict'; +var __bk$defs = new _Cls(); +var __bk$errors = []; +if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{path:'',code:'invalidInput'}]; +var __bk$f_set = Object.hasOwn(input, "set") ? input["set"] : __bk$defs["set"]; +if (__bk$f_set === undefined || __bk$f_set === null) __bk$errors.push({path:"set",code:"isDefined"}); +else { +if (Array.isArray(__bk$f_set)) { + for (var __bk$i_set=0; __bk$i_set<__bk$f_set.length; __bk$i_set++) { + var __il$setci = __bk$f_set[__bk$i_set]; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].',code:'invalidInput'}); + else { +var __bk$f_setc_0_k = __il$setci["k"]; +if (__bk$f_setc_0_k === undefined || __bk$f_setc_0_k === null) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_setc_0_k !== 'number') __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); +} + } + } +} else { __bk$errors.push({path:"set",code:"isArray"}); } +} +var __bk$f_map = Object.hasOwn(input, "map") ? input["map"] : __bk$defs["map"]; +if (__bk$f_map === undefined || __bk$f_map === null) __bk$errors.push({path:"map",code:"isDefined"}); +else { +if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$f_map)) { + var __bk$vkmap = Object.keys(__bk$f_map); + for (var __bk$vimap=0; __bk$vimap<__bk$vkmap.length; __bk$vimap++) { + var __bk$kmap = __bk$vkmap[__bk$vimap]; + var __il$mapmi = __bk$f_map[__bk$kmap]; + if (__il$mapmi == null || typeof __il$mapmi !== 'object' || Array.isArray(__il$mapmi)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].',code:'invalidInput'}); + else { +var __bk$f_mapm_1_k = __il$mapmi["k"]; +if (__bk$f_mapm_1_k === undefined || __bk$f_mapm_1_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_mapm_1_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_mapm_1_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (__bk$f_mapm_1_k === Infinity || __bk$f_mapm_1_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +} + } + } +} else { __bk$errors.push({path:"map",code:"isObject"}); } +} +if (__bk$errors.length) return __bk$errors; +return null; +//# sourceURL=baker://CollectionDto/validate + }" +, +} +`; diff --git a/test/integration/check-call-options.test.ts b/test/integration/check-call-options.test.ts index 1125c4f..60ac3ef 100644 --- a/test/integration/check-call-options.test.ts +++ b/test/integration/check-call-options.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'bun:test'; -import type { RuntimeOptions } from '../../src/interfaces'; +import type { RuntimeOptions } from '../../src/common/interfaces'; import { Baker, Field, BakerError } from '../../index'; import { isString } from '../../src/rules/index'; @@ -30,6 +30,20 @@ describe('checkCallOptions — only `groups` is a valid per-call option', () => expect(() => baker.deserialize(CallOptDto, { name: 'x' }, { groups: ['a'] })).not.toThrow(); }); + it('groups as a non-array throws BakerError', () => { + // Untyped call boundary: a string would otherwise flow into `new Set(opts.groups)` and split into + // characters in generated code, silently misbehaving instead of failing cleanly. + expect(() => deserializeBad(CallOptDto, { name: 'x' }, { groups: 'admin' })).toThrow(/groups.*string\[\]/); + }); + + it('groups with a non-string element throws BakerError', () => { + expect(() => deserializeBad(CallOptDto, { name: 'x' }, { groups: ['a', 1] })).toThrow(/groups.*string\[\]/); + }); + + it('groups as an empty array is fine (no group filtering)', () => { + expect(() => baker.deserialize(CallOptDto, { name: 'x' }, { groups: [] })).not.toThrow(); + }); + it('deserialize with unsupported per-call option throws BakerError', () => { expect(() => deserializeBad(CallOptDto, { name: 'x' }, { stopAtFirstError: true })).toThrow(BakerError); }); diff --git a/test/integration/codegen-snapshot.test.ts b/test/integration/codegen-snapshot.test.ts new file mode 100644 index 0000000..633b83a --- /dev/null +++ b/test/integration/codegen-snapshot.test.ts @@ -0,0 +1,79 @@ +// Codegen byte-identity guard. Captures the SOURCE of each generated executor (deserialize / validate +// / serialize) for a representative DTO × config matrix into a committed snapshot. The structural +// refactors (Phases C/D) move codegen code verbatim, so these snapshots MUST NOT change — any diff is +// a codegen-drift regression (and, with the (class,config) cache sharing one sealed form across +// same-config bakers, drift is silently cross-baker-visible). Body text only — injected closure data +// (refs/regexes/execs) is not part of Function.prototype.toString(). +import { describe, expect, it } from 'bun:test'; + +import type { BakerConfig } from '../../src/config'; + +import { Baker, Field, arrayOf } from '../../index'; +import { normalizeConfig } from '../../src/config'; +import { isBoolean, isEmail, isNumber, isString, min, minLength } from '../../src/rules/index'; +import { compileCache } from '../../src/seal/compile-cache'; + +const fpOf = (cfg?: BakerConfig): string => compileCache.fingerprint(cfg ? normalizeConfig(cfg) : {}); + +/** Seal `Dto` under `cfg`, then return the generated source of all three executors. */ +function codegen(Dto: Function, cfg?: BakerConfig): { deserialize: string; validate: string; serialize: string } { + const baker = new Baker(cfg); + (baker.Recipe as (v: Function) => void)(Dto); + baker.seal(); + const sealed = compileCache.get(Dto, fpOf(cfg)); + if (!sealed) { + throw new Error('executor not cached'); + } + return { + deserialize: sealed.deserialize.toString(), + validate: sealed.validate.toString(), + serialize: sealed.serialize.toString(), + }; +} + +// ── representative DTOs (distinct classes so cache keys never collide) ───────── + +class SimpleDto { + @Field(isString, minLength(2)) name!: string; + @Field(isNumber(), min(0)) age!: number; + @Field(isString, isEmail()) email!: string; + @Field(isBoolean) active!: boolean; +} + +class InnerDto { + @Field(isNumber()) k!: number; +} +class NestedDto { + @Field(isString) id!: string; + @Field({ type: () => InnerDto }) inner!: InnerDto; + @Field(arrayOf(isString)) tags!: string[]; +} + +class CollectionDto { + @Field({ type: () => Set, setValue: () => InnerDto }) set!: Set; + @Field({ type: () => Map, mapValue: () => InnerDto }) map!: Map; +} + +const MATRIX: ReadonlyArray = [ + ['Simple', SimpleDto], + ['Nested', NestedDto], + ['Collection', CollectionDto], +]; + +const CONFIGS: ReadonlyArray = [ + ['default', undefined], + ['autoConvert', { autoConvert: true }], + ['stopAtFirstError', { stopAtFirstError: true }], + ['forbidUnknown', { forbidUnknown: true }], + ['allowClassDefaults', { allowClassDefaults: true }], +]; + +describe('codegen byte-identity snapshot', () => { + for (const [cfgName, cfg] of CONFIGS) { + for (const [dtoName, Dto] of MATRIX) { + it(`${dtoName} @ ${cfgName}`, () => { + expect(codegen(Dto, cfg)).toMatchSnapshot(); + }); + } + } +}); diff --git a/test/integration/codegen.test.ts b/test/integration/codegen.test.ts index d6e1b28..17ea5cb 100644 --- a/test/integration/codegen.test.ts +++ b/test/integration/codegen.test.ts @@ -84,7 +84,9 @@ describe('codegen — integration', () => { }); it('transform should be applied in generated deserialize code', async () => { - const result = (await baker.deserialize(CodegenTransformDto, { text: ' trimmed ' })) as CodegenTransformDto; + const result = (await baker.deserialize(CodegenTransformDto, { + text: ' trimmed ', + })) as CodegenTransformDto; expect(result.text).toBe('trimmed'); }); diff --git a/test/integration/deserialize.test.ts b/test/integration/deserialize.test.ts index 4df44a7..ab8f632 100644 --- a/test/integration/deserialize.test.ts +++ b/test/integration/deserialize.test.ts @@ -330,8 +330,8 @@ describe('M4 — validation groups runtime filtering', () => { }); it('fields without groups are always executed', async () => { - expect(isBakerIssueSet(await baker.deserialize(AdminOnlyDto, { secret: 'ok', id: 'not-a-number' }, { groups: ['viewer'] }))).toBe( - true, - ); + expect( + isBakerIssueSet(await baker.deserialize(AdminOnlyDto, { secret: 'ok', id: 'not-a-number' }, { groups: ['viewer'] })), + ).toBe(true); }); }); diff --git a/test/integration/error-system.test.ts b/test/integration/error-system.test.ts index eb48d4e..53e63e1 100644 --- a/test/integration/error-system.test.ts +++ b/test/integration/error-system.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import type { Transformer } from '../../src/types'; +import type { Transformer } from '../../src/transformers/interfaces'; import { Baker, Field, BakerError, isBakerIssueSet } from '../../index'; import { isString } from '../../src/rules/index'; diff --git a/test/integration/helpers/assert.spec.ts b/test/integration/helpers/assert.spec.ts index 8deb496..1500155 100644 --- a/test/integration/helpers/assert.spec.ts +++ b/test/integration/helpers/assert.spec.ts @@ -1,9 +1,9 @@ import { err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; -import type { BakerIssue } from '../../../src/errors'; +import type { BakerIssue } from '../../../src/common/errors'; -import { toBakerIssueSet } from '../../../src/errors'; +import { toBakerIssueSet } from '../../../src/common/errors'; import { assertBakerIssueSet, assertDefined, assertIsErr, assertNotBakerIssueSet } from './assert'; describe('test assert helpers', () => { diff --git a/test/integration/helpers/assert.ts b/test/integration/helpers/assert.ts index 9b0524e..0245b82 100644 --- a/test/integration/helpers/assert.ts +++ b/test/integration/helpers/assert.ts @@ -2,9 +2,9 @@ import type { Err } from '@zipbul/result'; import { isErr } from '@zipbul/result'; -import type { BakerIssueSet } from '../../../src/errors'; +import type { BakerIssueSet } from '../../../src/common/errors'; -import { isBakerIssueSet } from '../../../src/errors'; +import { isBakerIssueSet } from '../../../src/common/errors'; /** * Test-only assertion helper — narrows `result` to `BakerIssueSet`. diff --git a/test/integration/helpers/unseal.ts b/test/integration/helpers/unseal.ts index b256b21..3f4b482 100644 --- a/test/integration/helpers/unseal.ts +++ b/test/integration/helpers/unseal.ts @@ -3,10 +3,10 @@ // (not per-class), because a cached root and its nested DTOs are compiled together and must be // invalidated together; clearing only some would leave a root referencing a dropped nested. Dropping // the whole cache means a later re-seal of any class recompiles its full graph consistently. -import { clearAllCached } from '../../../src/seal/seal'; +import { compileCache } from '../../../src/seal/compile-cache'; import { trackedSealed } from './seal'; export function unseal(): void { - clearAllCached(); + compileCache.clearAll(); trackedSealed.clear(); } diff --git a/test/integration/inheritance.test.ts b/test/integration/inheritance.test.ts index f8ce8d9..fd3819b 100644 --- a/test/integration/inheritance.test.ts +++ b/test/integration/inheritance.test.ts @@ -44,7 +44,11 @@ describe('inheritance — integration', () => { }); it('should deserialize grandchild DTO with all ancestor fields', async () => { - const result = (await baker.deserialize(GrandChildDto, { name: 'Bob', age: 30, active: true })) as GrandChildDto; + const result = (await baker.deserialize(GrandChildDto, { + name: 'Bob', + age: 30, + active: true, + })) as GrandChildDto; expect(result.name).toBe('Bob'); expect(result.age).toBe(30); expect(result.active).toBe(true); diff --git a/test/integration/seal.test.ts b/test/integration/seal.test.ts index 423e8b5..96e2207 100644 --- a/test/integration/seal.test.ts +++ b/test/integration/seal.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { Field, Baker, createRule, isBakerIssueSet, BakerError } from '../../index'; -import { setRaw } from '../../src/meta-access'; +import { metaStore } from '../../src/metadata'; import { isString, isNumber, isEmail, min } from '../../src/rules/index'; import { assertBakerIssueSet } from './helpers/assert'; @@ -167,7 +167,7 @@ describe('baker.seal() — late-registered class', () => { it('seals a class registered via baker.Recipe with manually-set metadata', async () => { const b = new Baker(); class LateDto {} - setRaw(LateDto, { + metaStore.set(LateDto, { value: { validation: [{ rule: isString }], transform: [],