From 9f05334c7693f1e1599ff2f28d88d8194b16ed0b Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 21:29:47 +0900 Subject: [PATCH 01/31] =?UTF-8?q?docs:=20rewrite=20refactoring=20plan=20?= =?UTF-8?q?=E2=80=94=20domain-owned=20layered=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structure-first plan (triple-reviewed). Each domain owns its types+enums+impl; higher layers import downward. No 'core kernel'; bottom leaves = symbols/errors/utils only. RAW metadata IR is a layer ABOVE the author primitives (it aggregates their types). Single documented upward edge: type-only rules->seal (EmitContext.addExecutor: SealedExecutors). Phases: A compile-cache extraction, snapshot harness, B skeleton, C dissolve types/enums/interfaces, D builder decomposition, E defer string.ts, F close-out. Co-Authored-By: Claude Opus 4.8 (1M context) --- REFACTORING.md | 372 ++++++++++++++++++++++--------------------------- 1 file changed, 165 insertions(+), 207 deletions(-) diff --git a/REFACTORING.md b/REFACTORING.md index de38b45..c35c195 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -1,230 +1,188 @@ -# @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. +# @zipbul/baker — Refactoring Plan (structure-first, domain-owned) ---- - -## 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). ---- +Goal: a layered directory structure where **each domain owns its own types, enums, and implementation**, +and higher layers reference lower ones — never the reverse. Decide the skeleton top-down, then move +symbols into the domain that owns them, then split the oversized files. Behavior-preserving; `tsc` clean +and the full suite green at every step; generated `new Function` bodies **byte-identical** (the 5.1 +`(class,config)` cache shares one sealed form across same-config bakers, so codegen drift would be +silently cross-baker-visible). -## 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. +Conventions: per-directory `enums.ts`/`types.ts`/`constants.ts`/`interfaces.ts`; directory barrels +(`index.ts`); strict named exports (no `export *`); `import type`/`export type` for type-only +(`verbatimModuleSyntax`); move code verbatim (never "tidy" documented micro-opts during extraction). --- -## 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). +## Classification — layered pipeline, domain-owned (triple-reviewed) -| 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 | +baker is a compiler: **author metadata → seal/compile → run**. Its axis of change is the pipeline +stage (add a rule → `rules/`; change codegen → `seal/`; change runtime → `runtime/`). A vertical/feature +slice is wrong — it would shatter the single generic `deserialize-builder` compiler and the single +generated runtime executor. So the cut is by pipeline layer. -`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. +**Placement rule (the one that was gotten wrong before):** *each symbol lives in the domain that owns +it — the LOWEST layer that consumes it — and higher layers import it downward.* A domain owns its +TYPES and its IMPLEMENTATION together (e.g. `transformers/` owns the `Transformer` type AND the +`trimTransformer`/`jsonTransformer` impls; `rules/` owns `InternalRule`/`RequiredType` AND `isString`). +There is **no bottom "core kernel"** holding other domains' types — that inverts the arrows. The only +bottom leaves are the truly-global primitives (`symbols`, `errors`, `utils`). -### 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). +**The RAW metadata IR is a layer, not a leaf.** `Raw*Meta` + the `*Def` family *aggregate* the author +domains' types (`RuleDef.rule: InternalRule`, `TransformDef.fn: TransformFunction`), so the IR sits +ABOVE `rules/`/`transformers/` and imports them downward — it is NOT a leaf below them. --- -## 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`. +## Target skeleton (bottom → top; every import points downward) + +``` +src/ +├── symbols.ts # LEAF · ROOT — Symbol.metadata polyfill (load-order) + published ./symbols. DO NOT MOVE. +├── errors/ # LEAF — BakerError, BakerIssue(Set), guards, toBakerIssueSet, BAKER_ERROR +├── utils.ts # LEAF — isAsyncFunction / isPromiseLike +│ +├── rules/ # AUTHOR primitive → ./rules · OWNS its types+enums+impl +│ # types: InternalRule, EmittableRule, EmitContext, RulePlan* +│ # enums: RequiredType, RuleOp, RulePlanExprKind, RulePlanCheckKind, CacheKey +│ # impl: string…(split Phase E), number…, typechecker, combinators, +│ # create-rule, rule-plan, rule-metadata +├── transformers/ # AUTHOR primitive → ./transformers · OWNS Transformer/TransformParams/TransformFunction + impls +│ +├── metadata/ # IR layer (ABOVE author primitives) — the schema decorators write & seal reads +│ # types: RawClassMeta, RawPropertyMeta, RuleDef, TransformDef, ExposeDef, +│ # ExcludeDef, TypeDef, PropertyFlags, ClassCtor, MessageArgs +│ # enums: CollectionType (TypeDef references it) +│ # impl: collect, meta-access (read/write RAW on the class via symbols) +│ # imports ↓ rules (InternalRule), transformers (Transformer), symbols, errors +├── decorators/ # AUTHOR → ./decorators — @Field etc. PRODUCE metadata +│ # enums: ExcludeMode ; Direction (lowest consumer = decorators+seal → here) +│ # imports ↓ metadata, rules, transformers, errors +├── seal/ # COMPILE — owns its output + options +│ # types: SealedExecutors ; interfaces: SealOptions, RuntimeOptions +│ # enums: GuardKey (Direction is imported downward from decorators/, not owned here) +│ # impl: seal, deserialize-builder, serialize-builder, compile-cache, +│ # async-analysis, merge-inheritance, circular-analyzer, +│ # expose-validator, validate-meta, codegen-utils +│ # imports ↓ metadata, rules, decorators(schema), errors +│ # (config is ABOVE seal — config imports SealOptions from seal, not vice versa) +├── config/ # normalizeConfig (BakerConfig → SealOptions) ; imports ↓ errors, seal(SealOptions type) +├── runtime/ # RUN (rename of functions/) — deserialize/serialize/validate, check-call-options +│ # imports ↓ seal (SealedExecutors, RuntimeOptions), errors +└── baker.ts # ROOT — composition root ; imports ↓ config, seal, runtime +``` + +### The one irreducible seam (document, don't fight) +`rules/` `EmittableRule.emit(ctx)` / `EmitContext.addExecutor(exec: SealedExecutors)` references +`SealedExecutors`, which `seal/` owns. That is the visitor pattern: rules define `emit`, seal supplies +the context and calls it. It is a single **type-only (erased) forward edge `rules → seal`**, kept as +`import type` so there is no runtime cycle (dpdm sees none). This is the ONLY upward edge; everything +else is strictly downward. (It exists today inside the monolithic `types.ts`; the split makes it an +explicit, commented `import type`.) + +### Placement decisions that bit the earlier draft (corrected) +- `Transformer*` → **transformers/** (its owner); `TransformDef`(metadata) imports it downward. +- `RequiredType`/`RuleOp`/`RulePlan*`/`CacheKey` → **rules/**; metadata/seal import downward. +- `CollectionType` → **metadata/** (lowest consumer: `TypeDef`); seal imports downward. +- `RuntimeOptions`/`SealOptions`/`SealedExecutors` → **seal/** (lowest consumer of each is seal, via + `SealedExecutors`'s signature); runtime/config/baker import downward. +- `Direction` → **decorators/** (lowest of {decorators, seal}); seal imports downward. +- `ExcludeMode` → **decorators/** (sole consumer). --- -## 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. +## Phase 0 / 1 (DONE) +P0 enum conversion. P1 (5.0/5.1): `Baker` class, per-baker runtime `app.deserialize/validate/serialize`, +global runtime + `Class[SEALED]` + `SEALED` symbol removed, executors in each Baker's `#executors` map, +`(class,config)` compile cache + cache-hit nested seeding, `Baker.#require` prototype-chain walk. + +## Phase A — compile-cache extraction (FIRST: self-contained, spec-backed, zero codegen risk) +Extract `seal/compile-cache.ts` (the WeakMap + `configFingerprint`/`getCached`/`setCached`/`clearCached`/ +`clearAllCached`). It already has a committed spec (`src/seal/compile-cache.spec.ts`, repoint its import) +and a consumer (`test/integration/helpers/unseal.ts` imports `clearAllCached`). Touches no `new Function` +body. Of seal.ts's 7 test-only exports it relocates the cache ones; the rest (`mergeInheritance`, +`circularPlaceholder`) move in Phase C. +Gate: suite green; codegen unchanged. + +## Phase B — establish the skeleton (moves only, no logic change) +1. `functions/` → **`runtime/`** (repoint imports; `functions` is not in `package.json` exports or + `index.ts`, so no published path changes). +2. Create **`errors/`**, **`metadata/`**, **`config/`**; move `errors.ts`→`errors/`, + `collect.ts`+`meta-access.ts`→`metadata/`, `configure.ts`→`config/` (owns `normalizeConfig` + + `BakerConfig` type + `BAKER_CONFIG_KEYS`). Leave `symbols.ts`, `utils.ts`(or a `utils/`), `baker.ts` + at root. +3. `index.ts` re-export paths repointed. NOTE: `index.ts` publicly re-exports `RequiredType`/`ExcludeMode` + (from enums) and `EmittableRule`/`Transformer`/`TransformParams` (from types) — Phase C moves those + symbols (`EmittableRule`→rules/, `Transformer*`→transformers/, `RequiredType`→rules/, `ExcludeMode`→ + decorators/), so these PUBLIC re-exports must be repointed then (public-barrel edit, not just internal). +Gate: `tsc` + suite green; `deps:check` no new cycles; public **type surface (names+shapes) unchanged** +(note: emitted `.d.ts` *internal re-export paths* necessarily change on a move — that is expected; the +invariant is the public names/shapes, not byte-identical `.d.ts`). + +## Phase C — dissolve `types.ts`/`enums.ts`/`interfaces.ts` into their owning domains +Apply the placement table above. Create `metadata/` IR types, `rules/` types+enums, `transformers/` +types, `decorators/` enums, `seal/` types+interfaces. Relocate `create-rule`/`rule-plan`/`rule-metadata` +into `rules/`. Mark the `rules → seal` `EmitContext`→`SealedExecutors` edge `import type` and comment it. +Also extract `seal/async-analysis.ts` (`analyzeAsync`+`nestedClassesOf`), `seal/merge-inheritance.ts`, +and `circularPlaceholder` out of `seal.ts` (each carries a test-only export) → `sealOne` becomes a clean +~160-line orchestrator. Do NOT fragment `sealOne`'s inline pipeline (typedef normalization stays inline). +Gate: `tsc` + suite green; `deps:check` clean — **verify zero edge from a lower layer up to a higher one +except the single documented `rules → seal` erased type edge**; codegen byte-identical. + +## Phase D — decompose the big builders +Split `deserialize-builder.ts` (1986) + `serialize-builder.ts` (446) into single-purpose codegen modules +(verbatim): error-codegen, conversion-codegen, expose-resolver, guard-strategies, rule-analysis, +issue-extras, emit-context, rule-emitter, nested-codegen, nested-codegen-validate, field-codegen, +transform-codegen, serialize-field-codegen, slim drivers. **Cycle break:** `field-codegen ↔ +nested-codegen-validate` via an `emitField` callback (dependency inversion) — this alters the codegen +call path, so do it as its own separately-gated commit (extract the leaf modules verbatim first). +Gate: byte-identical codegen — **mechanized** (see harness below), not eyeballed. + +## Phase E — `rules/string.ts` split (DEFERRED — lowest value, last or skip) +2525 lines, flat, low-coupling. Split by concern behind a pure re-export barrel so `rules/index.ts` +(the `./rules` subpath) stays byte-stable. Pure churn; schedule last or defer. + +## Phase F — barrels / exports / `.d.ts` close-out +Per-directory barrels; public barrels + root `/index.ts` + `./symbols` stable. `.d.ts` review, +`deps:check`, `knip`. Optional nit: de-dupe `runtime/` `run*` unwrap/guard helpers. --- -## 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. +## Prerequisite — codegen-snapshot harness (build BEFORE Phase C/D) +The "byte-identical codegen" invariant is currently only asserted. Add a `bun test` that, for a +representative DTO set, captures each generated executor's source (`sealed.deserialize/serialize/ +validate.toString()`, reachable via the test-only `getCached(Cls, configFingerprint(opts))`) into a +committed snapshot and diffs it. (Captures the generated **body text** only — injected closure data +like `refs`/`regexes`/`execs` is not part of `.toString()`; that is exactly the "codegen byte-identical" +invariant, which is about the body.) Land it as its own commit before any seal/ +builder code is moved (Phases C/D feed/own codegen), so drift is machine-checked every commit. Phases +A/B don't touch codegen but the harness should exist before C. + +## Execution order (each step = one commit; `tsc` + suite green; codegen byte-identical) +1. ~~P0 enums~~, ~~P1 Baker/runtime/cache~~ (DONE). +2. **A** — extract `compile-cache.ts` (safest, spec-backed first win). +3. **snapshot harness** (machine-check codegen byte-identity). +4. **B** — skeleton: `functions/`→`runtime/`, create `errors/` + `metadata/`, move substrate. +5. **C** — dissolve `types/enums/interfaces` into owning domains; extract seal analysis modules. +6. **D** — builder decomposition (leaf modules verbatim, then `emitField` cycle-break as its own commit). +7. **F** — barrels/exports close-out + de-dupe nit. +8. **E** — string.ts split (deferred/last/optional). + +Each phase independently revertible; regressions isolate to one layer. --- -## 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`). +## Invariants (every commit) +- `bunx tsc --noEmit` clean; `bun test` fully green (currently 2335 pass). +- Generated `new Function` bodies byte-identical (snapshot-checked from Phase C onward). +- Public surface unchanged: `/index.ts` names+shapes, subpath barrels (`./rules`, `./transformers`, + `./decorators`, `./symbols`), `package.json` exports. `./symbols` keeps pointing at root `symbols.ts`. +- **Strict downward layering**: leaves(symbols/errors/utils) ← rules·transformers ← metadata ← + decorators ← seal ← {config, runtime} ← baker. The ONLY upward edge permitted is the documented + type-only `rules → seal` (`EmitContext.addExecutor: SealedExecutors`). `deps:check` clean; `knip` clean. +- `verbatimModuleSyntax` respected. --- -## 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. +## Forward-looking — OpenAPI 3.0 +`app.toOpenAPI()` walks the type graph from the roots a baker collected — per-app isolation falls out of +the `Baker` boundary; class identity stays the isolation boundary; single-app projects have one `Baker`. From 3a02b5341b690fce8d8b42d15e414264813e4300 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 21:31:40 +0900 Subject: [PATCH 02/31] refactor(seal): extract compile-cache.ts from seal.ts (Phase A) Move the (class,config) executor cache (compileCache WeakMap + configFingerprint/ getCached/setCached/clearCached/clearAllCached) out of seal.ts into seal/compile-cache.ts. seal.ts imports configFingerprint/getCached/setCached from it; the spec and unseal helper repoint their imports. Self-contained, zero codegen change. tsc 0, 2335 pass/0 fail, coverage 0, lint 0, knip clean, no circular deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/compile-cache.spec.ts | 2 +- src/seal/compile-cache.ts | 60 +++++++++++++++++++++++++++++ src/seal/seal.ts | 61 +----------------------------- test/integration/helpers/unseal.ts | 2 +- 4 files changed, 64 insertions(+), 61 deletions(-) create mode 100644 src/seal/compile-cache.ts diff --git a/src/seal/compile-cache.spec.ts b/src/seal/compile-cache.spec.ts index 52276ab..77d1739 100644 --- a/src/seal/compile-cache.spec.ts +++ b/src/seal/compile-cache.spec.ts @@ -2,7 +2,7 @@ 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 { getCached, configFingerprint, clearCached } 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. diff --git a/src/seal/compile-cache.ts b/src/seal/compile-cache.ts new file mode 100644 index 0000000..f52da8e --- /dev/null +++ b/src/seal/compile-cache.ts @@ -0,0 +1,60 @@ +import type { SealOptions } from '../interfaces'; +import type { SealedExecutors } from '../types'; + +// ───────────────────────────────────────────────────────────────────────────── +// (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. */ +export 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') + ); +} + +export function getCached(cls: Function, fp: string): SealedExecutors | undefined { + return compileCache.get(cls)?.get(fp); +} + +export 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); +} + +/** Test-only: drop a single class's cached executors so a re-seal recompiles it. */ +export 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). + */ +export function clearAllCached(): void { + compileCache = new WeakMap(); +} diff --git a/src/seal/seal.ts b/src/seal/seal.ts index 259779c..53da366 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -6,6 +6,7 @@ import { BakerError } from '../errors'; import { getRaw, hasRawOwn } from '../meta-access'; import { isAsyncFunction } from '../utils'; import { analyzeCircular } from './circular-analyzer'; +import { configFingerprint, getCached, setCached } from './compile-cache'; import { buildDeserializeCode, buildValidateCode } from './deserialize-builder'; import { validateExposeStacks } from './expose-validator'; import { buildSerializeCode } from './serialize-builder'; @@ -134,64 +135,6 @@ function nestedClassesOf(meta: RawPropertyMeta): Function[] { * 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, @@ -492,4 +435,4 @@ function mergeInheritance(Class: Function): RawClassMeta { return merged; } -export { sealRegistry, mergeInheritance, circularPlaceholder, getCached, configFingerprint, clearCached, clearAllCached }; +export { sealRegistry, mergeInheritance, circularPlaceholder }; diff --git a/test/integration/helpers/unseal.ts b/test/integration/helpers/unseal.ts index b256b21..0776d1c 100644 --- a/test/integration/helpers/unseal.ts +++ b/test/integration/helpers/unseal.ts @@ -3,7 +3,7 @@ // (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 { clearAllCached } from '../../../src/seal/compile-cache'; import { trackedSealed } from './seal'; export function unseal(): void { From 2823b9de96143908e81439eca8c2cfd126450d09 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 21:48:54 +0900 Subject: [PATCH 03/31] =?UTF-8?q?docs:=20refine=20refactoring=20plan=20?= =?UTF-8?q?=E2=80=94=20precise=20DOMAIN=20vs=20COMMON=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triple-reviewed. Add a common/ bottom leaf for ownerless cross-cutting symbols, with an explicit membership test (semantic owner, not fewest-importers): - common/: errors, utils, Direction, CacheKey, ClassCtor, RuntimeOptions(seam); symbols root-pinned. - domains own their types: CollectionType/MessageArgs->metadata, rule types->rules, Transformer*->transformers, SealedExecutors/SealOptions->seal, ExcludeMode->decorators. - Fix RuntimeOptions (was wrongly under seal — seal only threads it; common avoids a seal->runtime cycle). Fix MessageArgs rationale (owned via RuleDef membership). Complete the public re-export repoint list (BakerConfig, RuntimeOptions). common/ verified a true acyclic leaf (all members import-free). Co-Authored-By: Claude Opus 4.8 (1M context) --- REFACTORING.md | 123 ++++++++++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 47 deletions(-) diff --git a/REFACTORING.md b/REFACTORING.md index c35c195..daed274 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -20,12 +20,28 @@ stage (add a rule → `rules/`; change codegen → `seal/`; change runtime → ` slice is wrong — it would shatter the single generic `deserialize-builder` compiler and the single generated runtime executor. So the cut is by pipeline layer. -**Placement rule (the one that was gotten wrong before):** *each symbol lives in the domain that owns -it — the LOWEST layer that consumes it — and higher layers import it downward.* A domain owns its -TYPES and its IMPLEMENTATION together (e.g. `transformers/` owns the `Transformer` type AND the -`trimTransformer`/`jsonTransformer` impls; `rules/` owns `InternalRule`/`RequiredType` AND `isString`). -There is **no bottom "core kernel"** holding other domains' types — that inverts the arrows. The only -bottom leaves are the truly-global primitives (`symbols`, `errors`, `utils`). +**Two kinds of home — DOMAIN vs COMMON (the distinction that was muddled before):** + +- **Pipeline DOMAIN** — a stage that owns a cohesive responsibility (`rules`, `transformers`, + `metadata`, `decorators`, `seal`, `config`, `runtime`). A domain owns its TYPES + ENUMS + + IMPLEMENTATION together (`transformers/` owns the `Transformer` type AND `trimTransformer`; `rules/` + owns `InternalRule`/`RequiredType` AND `isString`). +- **COMMON** — cross-cutting primitives with **no semantic owning stage**, used across the pipeline. + +**Membership test (objective): "Is there a single stage that *semantically owns* this symbol — the +place a developer would naturally look for it?"** If yes → that domain. If no (it's a pipeline-wide +primitive/concept) → `common/`. Note this is *semantic* ownership, not merely "fewest importers": +`CollectionType` (Map/Set of a field) is owned by `metadata` even though `seal` also reads it, because +`TypeDef` *defines* it; `Direction` (Deserialize/Serialize) is owned by **nobody** — it is the +pipeline's two directions — so it is common even though only decorators+seal use it. + +Applying the test to the genuinely-ownerless symbols (verified by usage): `errors` +(`BakerError` used by 6 areas), `utils` (`isAsyncFunction`/`isPromiseLike`), `Direction`, +`CacheKey` (codegen cache key, rules+seal, no single owner), `ClassCtor` (generic `new(...)=>T`) → all +**common**. `symbols` (the RAW metadata symbol) is common by nature but **pinned at root** (published +`./symbols` subpath + `Symbol.metadata` polyfill load-order). Everything with a real owner stays in its +domain — there is **no "core kernel" holding other domains' types** (that inverts the arrows; +`Transformer` is transformers', not common). **The RAW metadata IR is a layer, not a leaf.** `Raw*Meta` + the `*Def` family *aggregate* the author domains' types (`RuleDef.rule: InternalRule`, `TransformDef.fn: TransformFunction`), so the IR sits @@ -37,37 +53,41 @@ ABOVE `rules/`/`transformers/` and imports them downward — it is NOT a leaf be ``` src/ -├── symbols.ts # LEAF · ROOT — Symbol.metadata polyfill (load-order) + published ./symbols. DO NOT MOVE. -├── errors/ # LEAF — BakerError, BakerIssue(Set), guards, toBakerIssueSet, BAKER_ERROR -├── utils.ts # LEAF — isAsyncFunction / isPromiseLike +├── symbols.ts # COMMON-by-nature but ROOT-PINNED — Symbol.metadata polyfill (load-order) + published ./symbols. +├── common/ # NO owning stage — cross-cutting primitives (the bottom leaf; imports nothing from a stage) +│ # errors/ : BakerError, BakerIssue(Set), guards, toBakerIssueSet, BAKER_ERROR +│ # utils : isAsyncFunction, isPromiseLike +│ # enums : Direction, CacheKey (no semantic owner) +│ # types : ClassCtor (generic new(...)=>T) +│ # interfaces: RuntimeOptions (seam: seal threads it, runtime consumes — neither owns) │ -├── rules/ # AUTHOR primitive → ./rules · OWNS its types+enums+impl +├── rules/ # DOMAIN (author primitive) → ./rules · OWNS its types+enums+impl │ # types: InternalRule, EmittableRule, EmitContext, RulePlan* -│ # enums: RequiredType, RuleOp, RulePlanExprKind, RulePlanCheckKind, CacheKey -│ # impl: string…(split Phase E), number…, typechecker, combinators, -│ # create-rule, rule-plan, rule-metadata -├── transformers/ # AUTHOR primitive → ./transformers · OWNS Transformer/TransformParams/TransformFunction + impls +│ # enums: RequiredType, RuleOp, RulePlanExprKind, RulePlanCheckKind +│ # impl: string…(split Phase E), number…, typechecker, combinators, +│ # create-rule, rule-plan, rule-metadata +├── transformers/ # DOMAIN (author primitive) → ./transformers · OWNS Transformer/TransformParams/TransformFunction + impls │ -├── metadata/ # IR layer (ABOVE author primitives) — the schema decorators write & seal reads +├── metadata/ # DOMAIN — IR layer (ABOVE author primitives): the schema decorators write & seal reads │ # types: RawClassMeta, RawPropertyMeta, RuleDef, TransformDef, ExposeDef, -│ # ExcludeDef, TypeDef, PropertyFlags, ClassCtor, MessageArgs -│ # enums: CollectionType (TypeDef references it) +│ # ExcludeDef, TypeDef, PropertyFlags, MessageArgs +│ # enums: CollectionType (TypeDef defines it — metadata is its semantic owner) │ # impl: collect, meta-access (read/write RAW on the class via symbols) -│ # imports ↓ rules (InternalRule), transformers (Transformer), symbols, errors -├── decorators/ # AUTHOR → ./decorators — @Field etc. PRODUCE metadata -│ # enums: ExcludeMode ; Direction (lowest consumer = decorators+seal → here) -│ # imports ↓ metadata, rules, transformers, errors -├── seal/ # COMPILE — owns its output + options -│ # types: SealedExecutors ; interfaces: SealOptions, RuntimeOptions -│ # enums: GuardKey (Direction is imported downward from decorators/, not owned here) +│ # imports ↓ rules (InternalRule), transformers (Transformer), common, symbols +├── decorators/ # DOMAIN → ./decorators — @Field etc. PRODUCE metadata +│ # enums: ExcludeMode (sole consumer = decorators) +│ # imports ↓ metadata, rules, transformers, common +├── seal/ # DOMAIN (compile) — owns its output + options +│ # types: SealedExecutors ; interfaces: SealOptions ; enums: GuardKey +│ # (RuntimeOptions is in common/ — seal only threads it through SealedExecutors' signature) │ # impl: seal, deserialize-builder, serialize-builder, compile-cache, │ # async-analysis, merge-inheritance, circular-analyzer, │ # expose-validator, validate-meta, codegen-utils -│ # imports ↓ metadata, rules, decorators(schema), errors +│ # imports ↓ metadata, rules, decorators(schema), common │ # (config is ABOVE seal — config imports SealOptions from seal, not vice versa) -├── config/ # normalizeConfig (BakerConfig → SealOptions) ; imports ↓ errors, seal(SealOptions type) -├── runtime/ # RUN (rename of functions/) — deserialize/serialize/validate, check-call-options -│ # imports ↓ seal (SealedExecutors, RuntimeOptions), errors +├── config/ # DOMAIN — normalizeConfig (BakerConfig → SealOptions) ; imports ↓ common, seal(SealOptions type) +├── runtime/ # DOMAIN (run, rename of functions/) — deserialize/serialize/validate, check-call-options +│ # imports ↓ seal (SealedExecutors), common (RuntimeOptions, errors) └── baker.ts # ROOT — composition root ; imports ↓ config, seal, runtime ``` @@ -79,14 +99,22 @@ the context and calls it. It is a single **type-only (erased) forward edge `rule else is strictly downward. (It exists today inside the monolithic `types.ts`; the split makes it an explicit, commented `import type`.) -### Placement decisions that bit the earlier draft (corrected) -- `Transformer*` → **transformers/** (its owner); `TransformDef`(metadata) imports it downward. -- `RequiredType`/`RuleOp`/`RulePlan*`/`CacheKey` → **rules/**; metadata/seal import downward. -- `CollectionType` → **metadata/** (lowest consumer: `TypeDef`); seal imports downward. -- `RuntimeOptions`/`SealOptions`/`SealedExecutors` → **seal/** (lowest consumer of each is seal, via - `SealedExecutors`'s signature); runtime/config/baker import downward. -- `Direction` → **decorators/** (lowest of {decorators, seal}); seal imports downward. -- `ExcludeMode` → **decorators/** (sole consumer). +### Placement decisions (by the semantic-owner test) +- **DOMAIN (has an owner):** + - `Transformer*` → **transformers/** (its owner); `TransformDef`(metadata) imports it downward. + - `RequiredType`/`RuleOp`/`RulePlan*` → **rules/**; metadata/seal import downward. + - `CollectionType` → **metadata/** (`TypeDef` defines it); seal imports downward. + - `MessageArgs` → **metadata/** (structural member of `RuleDef`/`RawPropertyMeta` — owned by the IR, not by whoever consumes it). + - `SealOptions`/`SealedExecutors` → **seal/** (seal produces/owns them); runtime/config/baker import downward. + - `ExcludeMode` → **decorators/** (sole consumer). +- **COMMON (no owner — fails the test):** + - `Direction` (Deserialize/Serialize — pipeline-wide), `CacheKey` (codegen cache key, rules produce / seal consume), + `ClassCtor` (generic constructor), `errors`, `utils` → **common/**. + - `RuntimeOptions` → **common/** (seam): seal only *threads* it through `SealedExecutors`' signature and + runtime *consumes* it — neither owns it (mirrors `CacheKey`). Putting it in `runtime/` would create a + `seal → runtime` upward edge via `SealedExecutors`; `common/` keeps the seam below both. It is published + (`index.ts`), so its public re-export repoints to `common/`. + - `symbols` → common-by-nature but **root-pinned** (published subpath + polyfill load-order). --- @@ -106,14 +134,14 @@ Gate: suite green; codegen unchanged. ## Phase B — establish the skeleton (moves only, no logic change) 1. `functions/` → **`runtime/`** (repoint imports; `functions` is not in `package.json` exports or `index.ts`, so no published path changes). -2. Create **`errors/`**, **`metadata/`**, **`config/`**; move `errors.ts`→`errors/`, - `collect.ts`+`meta-access.ts`→`metadata/`, `configure.ts`→`config/` (owns `normalizeConfig` + - `BakerConfig` type + `BAKER_CONFIG_KEYS`). Leave `symbols.ts`, `utils.ts`(or a `utils/`), `baker.ts` - at root. -3. `index.ts` re-export paths repointed. NOTE: `index.ts` publicly re-exports `RequiredType`/`ExcludeMode` - (from enums) and `EmittableRule`/`Transformer`/`TransformParams` (from types) — Phase C moves those - symbols (`EmittableRule`→rules/, `Transformer*`→transformers/, `RequiredType`→rules/, `ExcludeMode`→ - decorators/), so these PUBLIC re-exports must be repointed then (public-barrel edit, not just internal). +2. Create **`common/`**, **`metadata/`**, **`config/`**; move `errors.ts`→`common/errors/`, + `utils.ts`→`common/`, `collect.ts`+`meta-access.ts`→`metadata/`, `configure.ts`→`config/` (owns + `normalizeConfig` + `BakerConfig` type + `BAKER_CONFIG_KEYS`). Leave `symbols.ts` and `baker.ts` at root. + (The cross-cutting enums `Direction`/`CacheKey` + `ClassCtor` land in `common/` during Phase C.) +3. `index.ts` re-export paths repointed. NOTE the PUBLIC re-exports that move (each is a public-barrel + edit, not just internal): `RequiredType`→rules/, `ExcludeMode`→decorators/, `EmittableRule`→rules/, + `Transformer`/`TransformParams`→transformers/ (Phase C); `BakerConfig`→config/ (Phase B); + `RuntimeOptions`→common/ (Phase C). Repoint each as its symbol moves. Gate: `tsc` + suite green; `deps:check` no new cycles; public **type surface (names+shapes) unchanged** (note: emitted `.d.ts` *internal re-export paths* necessarily change on a move — that is expected; the invariant is the public names/shapes, not byte-identical `.d.ts`). @@ -161,7 +189,7 @@ A/B don't touch codegen but the harness should exist before C. 1. ~~P0 enums~~, ~~P1 Baker/runtime/cache~~ (DONE). 2. **A** — extract `compile-cache.ts` (safest, spec-backed first win). 3. **snapshot harness** (machine-check codegen byte-identity). -4. **B** — skeleton: `functions/`→`runtime/`, create `errors/` + `metadata/`, move substrate. +4. **B** — skeleton: `functions/`→`runtime/`, create `common/` + `metadata/` + `config/`, move substrate. 5. **C** — dissolve `types/enums/interfaces` into owning domains; extract seal analysis modules. 6. **D** — builder decomposition (leaf modules verbatim, then `emitField` cycle-break as its own commit). 7. **F** — barrels/exports close-out + de-dupe nit. @@ -176,8 +204,9 @@ Each phase independently revertible; regressions isolate to one layer. - Generated `new Function` bodies byte-identical (snapshot-checked from Phase C onward). - Public surface unchanged: `/index.ts` names+shapes, subpath barrels (`./rules`, `./transformers`, `./decorators`, `./symbols`), `package.json` exports. `./symbols` keeps pointing at root `symbols.ts`. -- **Strict downward layering**: leaves(symbols/errors/utils) ← rules·transformers ← metadata ← - decorators ← seal ← {config, runtime} ← baker. The ONLY upward edge permitted is the documented +- **Strict downward layering**: `common/` (+ root `symbols`) ← rules·transformers ← metadata ← + decorators ← seal ← {config, runtime} ← baker. `common/` imports NOTHING from any stage (if it would + need to, the symbol has an owner and isn't common). The ONLY upward edge permitted is the documented type-only `rules → seal` (`EmitContext.addExecutor: SealedExecutors`). `deps:check` clean; `knip` clean. - `verbatimModuleSyntax` respected. From 91cbfafb0dcbda51b593aeafa82a821336a22df4 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 21:51:22 +0900 Subject: [PATCH 04/31] test: codegen byte-identity snapshot harness (pre-refactor guard) Snapshots the generated source (deserialize/validate/serialize .toString()) for a representative DTO x config matrix (15 snapshots), reachable via getCached(Cls, configFingerprint(opts)). Locks codegen byte-identity so the structural moves in the seal/builder phases are machine-checked, not eyeballed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../codegen-snapshot.test.ts.snap | 1941 +++++++++++++++++ test/integration/codegen-snapshot.test.ts | 86 + 2 files changed, 2027 insertions(+) create mode 100644 test/integration/__snapshots__/codegen-snapshot.test.ts.snap create mode 100644 test/integration/codegen-snapshot.test.ts 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..d818d9c --- /dev/null +++ b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap @@ -0,0 +1,1941 @@ +// 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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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_k = __bk$f_inner["k"]; +if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +if (typeof __bk$f_inner_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (isNaN(__bk$f_inner_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (__bk$f_inner_k === Infinity || __bk$f_inner_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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$sa = []; + for (var __bk$si of __bk$fv_set) { + __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + } + __bk$out["set"] = __bk$sa; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$m = Object.create(null); + for (var __bk$me of __bk$fv_map) { + if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } + __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + } + __bk$out["map"] = __bk$m; +} 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]; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + else { +var __bk$f_setc_k = __il$setci["k"]; +if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +else { +if (typeof __bk$f_setc_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"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_k = __il$mapmi["k"]; +if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_mapm_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_mapm_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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_k = __bk$f_inner["k"]; +if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +var __bk$skip_k = false; +if (typeof __bk$f_inner_k !== 'number' || isNaN(__bk$f_inner_k)) { + __bk$f_inner_k = Number(__bk$f_inner_k); + if (isNaN(__bk$f_inner_k)) { __bk$errors.push({path:"inner."+"k",code:"conversionFailed"}); __bk$skip_k = true; } +} +if (!__bk$skip_k) { + if (__bk$f_inner_k === Infinity || __bk$f_inner_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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$sa = []; + for (var __bk$si of __bk$fv_set) { + __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + } + __bk$out["set"] = __bk$sa; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$m = Object.create(null); + for (var __bk$me of __bk$fv_map) { + if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } + __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + } + __bk$out["map"] = __bk$m; +} 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]; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + else { +var __bk$f_setc_k = __il$setci["k"]; +if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +else { +var __bk$skip_k = false; +if (typeof __bk$f_setc_k !== 'number' || isNaN(__bk$f_setc_k)) { + __bk$f_setc_k = Number(__bk$f_setc_k); + if (isNaN(__bk$f_setc_k)) { __bk$errors.push({path:__bk$ppset+"k",code:"conversionFailed"}); __bk$skip_k = true; } +} +if (!__bk$skip_k) { + if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"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_k = __il$mapmi["k"]; +if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +var __bk$skip_k = false; +if (typeof __bk$f_mapm_k !== 'number' || isNaN(__bk$f_mapm_k)) { + __bk$f_mapm_k = Number(__bk$f_mapm_k); + if (isNaN(__bk$f_mapm_k)) { __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"conversionFailed"}); __bk$skip_k = true; } +} +if (!__bk$skip_k) { + if (__bk$f_mapm_k === Infinity || __bk$f_mapm_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."; + if(__bk$re_inner[0].message===undefined&&__bk$re_inner[0].context===undefined)return err([{path:__bk$ppinner+__bk$re_inner[0].path,code:__bk$re_inner[0].code}]); + var __neinner={path:__bk$ppinner+__bk$re_inner[0].path,code:__bk$re_inner[0].code}; + if(__bk$re_inner[0].message!==undefined)__neinner.message=__bk$re_inner[0].message; + if(__bk$re_inner[0].context!==undefined)__neinner.context=__bk$re_inner[0].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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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_k = __bk$f_inner["k"]; +if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) return [{path:"inner."+"k",code:"isDefined"}]; +else { +if (typeof __bk$f_inner_k !== 'number') return [{path:"inner."+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_inner_k)) return [{path:"inner."+"k",code:"isNumber"}]; +else if (__bk$f_inner_k === Infinity || __bk$f_inner_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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+'].'; + if(__bk$re_set[0].message===undefined&&__bk$re_set[0].context===undefined)return err([{path:__bk$ppset+__bk$re_set[0].path,code:__bk$re_set[0].code}]); + var __neset={path:__bk$ppset+__bk$re_set[0].path,code:__bk$re_set[0].code}; + if(__bk$re_set[0].message!==undefined)__neset.message=__bk$re_set[0].message; + if(__bk$re_set[0].context!==undefined)__neset.context=__bk$re_set[0].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+'].'; + if(__bk$re_map[0].message===undefined&&__bk$re_map[0].context===undefined)return err([{path:__bk$ppmap+__bk$re_map[0].path,code:__bk$re_map[0].code}]); + var __nemap={path:__bk$ppmap+__bk$re_map[0].path,code:__bk$re_map[0].code}; + if(__bk$re_map[0].message!==undefined)__nemap.message=__bk$re_map[0].message; + if(__bk$re_map[0].context!==undefined)__nemap.context=__bk$re_map[0].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$sa = []; + for (var __bk$si of __bk$fv_set) { + __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + } + __bk$out["set"] = __bk$sa; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$m = Object.create(null); + for (var __bk$me of __bk$fv_map) { + if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } + __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + } + __bk$out["map"] = __bk$m; +} 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]; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) return [{path:__bk$ppset,code:'invalidInput'}]; + else { +var __bk$f_setc_k = __il$setci["k"]; +if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) return [{path:__bk$ppset+"k",code:"isDefined"}]; +else { +if (typeof __bk$f_setc_k !== 'number') return [{path:__bk$ppset+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_setc_k)) return [{path:__bk$ppset+"k",code:"isNumber"}]; +else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) return [{path:__bk$ppset+"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_k = __il$mapmi["k"]; +if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}]; +else { +if (typeof __bk$f_mapm_k !== 'number') return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_mapm_k)) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; +else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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_k = __bk$f_inner["k"]; +if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +if (typeof __bk$f_inner_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (isNaN(__bk$f_inner_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (__bk$f_inner_k === Infinity || __bk$f_inner_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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$sa = []; + for (var __bk$si of __bk$fv_set) { + __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + } + __bk$out["set"] = __bk$sa; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$m = Object.create(null); + for (var __bk$me of __bk$fv_map) { + if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } + __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + } + __bk$out["map"] = __bk$m; +} 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]; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + else { +var __bk$f_setc_k = __il$setci["k"]; +if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +else { +if (typeof __bk$f_setc_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"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_k = __il$mapmi["k"]; +if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_mapm_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_mapm_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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_k = __bk$f_inner["k"]; +if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +else { +if (typeof __bk$f_inner_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (isNaN(__bk$f_inner_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +else if (__bk$f_inner_k === Infinity || __bk$f_inner_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++) { + if (typeof __bk$f_tags[__bk$i_tags] !== '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) { + if (typeof __bk$sv_tags !== '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()) { + if (typeof __bk$mv_tags !== '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$sa = []; + for (var __bk$si of __bk$fv_set) { + __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + } + __bk$out["set"] = __bk$sa; +} else { + __bk$out["set"] = __bk$fv_set; +} +var __bk$fv_map = instance["map"]; +if (__bk$fv_map != null) { + var __bk$m = Object.create(null); + for (var __bk$me of __bk$fv_map) { + if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } + __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + } + __bk$out["map"] = __bk$m; +} 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]; + var __bk$ppset = "set"+'['+__bk$i_set+'].'; + if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + else { +var __bk$f_setc_k = __il$setci["k"]; +if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +else { +if (typeof __bk$f_setc_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"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_k = __il$mapmi["k"]; +if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +else { +if (typeof __bk$f_mapm_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (isNaN(__bk$f_mapm_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_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/codegen-snapshot.test.ts b/test/integration/codegen-snapshot.test.ts new file mode 100644 index 0000000..784523c --- /dev/null +++ b/test/integration/codegen-snapshot.test.ts @@ -0,0 +1,86 @@ +// 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/configure'; + +import { Baker, Field, arrayOf } from '../../index'; +import { normalizeConfig } from '../../src/configure'; +import { configFingerprint, getCached } from '../../src/seal/compile-cache'; +import { + isBoolean, + isEmail, + isNumber, + isString, + min, + minLength, +} from '../../src/rules/index'; + +const fpOf = (cfg?: BakerConfig): string => configFingerprint(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 = getCached(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(); + }); + } + } +}); From 4f34a1dea5742886b0fc97576945bf01ae53889d Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 21:56:02 +0900 Subject: [PATCH 05/31] =?UTF-8?q?refactor:=20skeleton=20dirs=20=E2=80=94?= =?UTF-8?q?=20functions->runtime,=20create=20common/metadata/config=20(Pha?= =?UTF-8?q?se=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure moves (git rename) + import repoints, no logic/codegen change: - src/functions/ -> src/runtime/ - src/errors.ts, utils.ts -> src/common/ - src/collect.ts, meta-access.ts -> src/metadata/ - src/configure.ts -> src/config/ symbols.ts/baker.ts and the types/enums/interfaces/rule-machinery roots stay (move in Phase C). tsc 0, 2350 pass/0 fail, codegen snapshot unchanged (15), deps:check clean, knip clean, lint 0, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.ts | 6 +++--- scripts/fix-exports-last.ts | 2 +- src/baker.ts | 14 +++++++------- src/{ => common}/errors.spec.ts | 2 +- src/{ => common}/errors.ts | 0 src/{ => common}/utils.ts | 0 src/{ => config}/configure.ts | 4 ++-- src/create-rule.ts | 4 ++-- src/decorators/field-guards.spec.ts | 2 +- src/decorators/field.ts | 6 +++--- src/decorators/transform.spec.ts | 2 +- src/error-system.spec.ts | 2 +- src/{ => metadata}/collect.spec.ts | 2 +- src/{ => metadata}/collect.ts | 4 ++-- src/{ => metadata}/meta-access.spec.ts | 0 src/{ => metadata}/meta-access.ts | 4 ++-- src/rules/combinators.ts | 2 +- src/rules/locales.ts | 2 +- src/rules/number.ts | 2 +- src/{functions => runtime}/check-call-options.ts | 2 +- src/{functions => runtime}/deserialize.spec.ts | 2 +- src/{functions => runtime}/deserialize.ts | 2 +- src/{functions => runtime}/serialize.spec.ts | 2 +- src/{functions => runtime}/serialize.ts | 2 +- src/{functions => runtime}/validate.ts | 4 ++-- src/seal/circular-analyzer.spec.ts | 2 +- src/seal/circular-analyzer.ts | 4 ++-- src/seal/deserialize-builder.spec.ts | 2 +- src/seal/deserialize-builder.ts | 2 +- src/seal/expose-validator.spec.ts | 2 +- src/seal/expose-validator.ts | 2 +- src/seal/seal.spec.ts | 4 ++-- src/seal/seal.ts | 6 +++--- src/seal/serialize-builder.ts | 2 +- src/seal/validate-meta.ts | 4 ++-- src/transformers/luxon.transformer.ts | 2 +- src/transformers/moment.transformer.ts | 2 +- src/types.ts | 2 +- test/e2e/async-transform.test.ts | 2 +- test/integration/codegen-snapshot.test.ts | 4 ++-- test/integration/helpers/assert.spec.ts | 4 ++-- test/integration/helpers/assert.ts | 4 ++-- test/integration/seal.test.ts | 2 +- 43 files changed, 63 insertions(+), 63 deletions(-) rename src/{ => common}/errors.spec.ts (98%) rename src/{ => common}/errors.ts (100%) rename src/{ => common}/utils.ts (100%) rename src/{ => config}/configure.ts (95%) rename src/{ => metadata}/collect.spec.ts (98%) rename src/{ => metadata}/collect.ts (93%) rename src/{ => metadata}/meta-access.spec.ts (100%) rename src/{ => metadata}/meta-access.ts (96%) rename src/{functions => runtime}/check-call-options.ts (98%) rename src/{functions => runtime}/deserialize.spec.ts (99%) rename src/{functions => runtime}/deserialize.ts (98%) rename src/{functions => runtime}/serialize.spec.ts (99%) rename src/{functions => runtime}/serialize.ts (98%) rename src/{functions => runtime}/validate.ts (94%) diff --git a/index.ts b/index.ts index 515ec57..c838c3c 100644 --- a/index.ts +++ b/index.ts @@ -12,12 +12,12 @@ export { Baker } from './src/baker'; export { ExcludeMode, RequiredType } from './src/enums'; // Errors -export type { BakerIssue, BakerIssueSet } from './src/errors'; -export { isBakerIssueSet, BakerError } from './src/errors'; +export type { BakerIssue, BakerIssueSet } from './src/common/errors'; +export { isBakerIssueSet, BakerError } from './src/common/errors'; // Types export type { EmittableRule, Transformer, TransformParams } from './src/types'; -export type { BakerConfig } from './src/configure'; +export type { BakerConfig } from './src/config/configure'; // Interfaces / Options export type { RuntimeOptions } from './src/interfaces'; 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..9466d6c 100644 --- a/src/baker.ts +++ b/src/baker.ts @@ -1,13 +1,13 @@ -import type { BakerConfig } from './configure'; -import type { BakerIssueSet } from './errors'; +import type { BakerConfig } from './config/configure'; +import type { BakerIssueSet } from './common/errors'; import type { RuntimeOptions, SealOptions } from './interfaces'; import type { SealedExecutors } from './types'; -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 { normalizeConfig } from './config/configure'; +import { BakerError } from './common/errors'; +import { runDeserialize, runDeserializeSync, runDeserializeAsync } from './runtime/deserialize'; +import { resolveSerializeClass, runSerialize, runSerializeSync, runSerializeAsync } from './runtime/serialize'; +import { runValidate, runValidateSync, runValidateAsync } from './runtime/validate'; import { sealRegistry } from './seal/seal'; /** 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 100% rename from src/errors.ts rename to src/common/errors.ts 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/configure.ts b/src/config/configure.ts similarity index 95% rename from src/configure.ts rename to src/config/configure.ts index 0d6c5e6..66391dd 100644 --- a/src/configure.ts +++ b/src/config/configure.ts @@ -1,6 +1,6 @@ -import type { SealOptions } from './interfaces'; +import type { SealOptions } from '../interfaces'; -import { BakerError } from './errors'; +import { BakerError } from '../common/errors'; // ───────────────────────────────────────────────────────────────────────────── // BakerConfig — per-Baker configuration (passed to `new Baker(config)`) diff --git a/src/create-rule.ts b/src/create-rule.ts index d2d4629..bd7a379 100644 --- a/src/create-rule.ts +++ b/src/create-rule.ts @@ -1,9 +1,9 @@ import type { RequiredType } from './enums'; import type { EmittableRule, EmitContext, InternalRule } from './types'; -import { BakerError } from './errors'; +import { BakerError } from './common/errors'; import { defineRuleMetadata } from './rule-metadata'; -import { isAsyncFunction, isPromiseLike } from './utils'; +import { isAsyncFunction, isPromiseLike } from './common/utils'; // ───────────────────────────────────────────────────────────────────────────── // createRule — Custom validation rule creation Public API (§1.1) diff --git a/src/decorators/field-guards.spec.ts b/src/decorators/field-guards.spec.ts index f809ef3..abfa5c0 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'; diff --git a/src/decorators/field.ts b/src/decorators/field.ts index 5452d44..c593d64 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,9 +1,9 @@ import type { ClassCtor, EmittableRule, InternalRule, RawPropertyMeta, RuleDef, ExposeDef, TypeDef, Transformer } from '../types'; -import { ensureMeta } from '../collect'; +import { ensureMeta } from '../metadata/collect'; import { Direction, ExcludeMode } from '../enums'; -import { BakerError } from '../errors'; -import { isAsyncFunction, isPromiseLike } from '../utils'; +import { BakerError } from '../common/errors'; +import { isAsyncFunction, isPromiseLike } from '../common/utils'; // ───────────────────────────────────────────────────────────────────────────── // arrayOf — Array element validation marker (replaces each: true) diff --git a/src/decorators/transform.spec.ts b/src/decorators/transform.spec.ts index 47fb2f8..17e6c51 100644 --- a/src/decorators/transform.spec.ts +++ b/src/decorators/transform.spec.ts @@ -5,7 +5,7 @@ import type { EmittableRule, RawPropertyMeta, TransformDef, TransformParams, Typ 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 { deleteRaw, requireRaw } from '../metadata/meta-access'; import { Field } from './field'; const createdCtors: Function[] = []; diff --git a/src/error-system.spec.ts b/src/error-system.spec.ts index 7480d5b..a547c18 100644 --- a/src/error-system.spec.ts +++ b/src/error-system.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { createRule } from './create-rule'; -import { BakerError } from './errors'; +import { BakerError } from './common/errors'; import { isPassportNumber } from './rules/locales'; import { isDivisibleBy, max, min } from './rules/number'; diff --git a/src/collect.spec.ts b/src/metadata/collect.spec.ts similarity index 98% rename from src/collect.spec.ts rename to src/metadata/collect.spec.ts index c297c61..886b578 100644 --- a/src/collect.spec.ts +++ b/src/metadata/collect.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { ensureMeta } from './collect'; -import { RAW } from './symbols'; +import { RAW } from '../symbols'; type MetaObject = Record; diff --git a/src/collect.ts b/src/metadata/collect.ts similarity index 93% rename from src/collect.ts rename to src/metadata/collect.ts index 873fc2a..4da21c8 100644 --- a/src/collect.ts +++ b/src/metadata/collect.ts @@ -1,6 +1,6 @@ -import type { RawClassMeta, RawPropertyMeta } from './types'; +import type { RawClassMeta, RawPropertyMeta } from '../types'; -import { RAW } from './symbols'; +import { RAW } from '../symbols'; type MetaObject = Record & { [RAW]?: RawClassMeta }; diff --git a/src/meta-access.spec.ts b/src/metadata/meta-access.spec.ts similarity index 100% rename from src/meta-access.spec.ts rename to src/metadata/meta-access.spec.ts diff --git a/src/meta-access.ts b/src/metadata/meta-access.ts similarity index 96% rename from src/meta-access.ts rename to src/metadata/meta-access.ts index 04cb68e..3c8e78e 100644 --- a/src/meta-access.ts +++ b/src/metadata/meta-access.ts @@ -1,6 +1,6 @@ -import type { RawClassMeta } from './types'; +import type { RawClassMeta } from '../types'; -import { RAW } from './symbols'; +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. diff --git a/src/rules/combinators.ts b/src/rules/combinators.ts index b401a50..15cb1c9 100644 --- a/src/rules/combinators.ts +++ b/src/rules/combinators.ts @@ -1,6 +1,6 @@ import type { EmitContext, EmittableRule } from '../types'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { makeRule } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/locales.ts b/src/rules/locales.ts index 56f509a..aa6c789 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from '../types'; import { RequiredType } from '../enums'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { makeRule } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/number.ts b/src/rules/number.ts index e95b1e9..909f4f5 100644 --- a/src/rules/number.ts +++ b/src/rules/number.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from '../types'; import { RequiredType, RuleOp } from '../enums'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/functions/check-call-options.ts b/src/runtime/check-call-options.ts similarity index 98% rename from src/functions/check-call-options.ts rename to src/runtime/check-call-options.ts index de6c3ab..92b31bb 100644 --- a/src/functions/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -1,6 +1,6 @@ import type { RuntimeOptions } from '../interfaces'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; const CALL_OPTION_KEYS = new Set(['groups']); const SEAL_TIME_KEYS = new Set([ diff --git a/src/functions/deserialize.spec.ts b/src/runtime/deserialize.spec.ts similarity index 99% rename from src/functions/deserialize.spec.ts rename to src/runtime/deserialize.spec.ts index 8be70b1..dc4f87a 100644 --- a/src/functions/deserialize.spec.ts +++ b/src/runtime/deserialize.spec.ts @@ -7,7 +7,7 @@ import type { SealedExecutors } from '../types'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { Baker } from '../baker'; import { Field } from '../decorators/field'; -import { isBakerIssueSet, BakerError } from '../errors'; +import { isBakerIssueSet, BakerError } from '../common/errors'; import { isString } from '../rules/typechecker'; import { runDeserialize } from './deserialize'; diff --git a/src/functions/deserialize.ts b/src/runtime/deserialize.ts similarity index 98% rename from src/functions/deserialize.ts rename to src/runtime/deserialize.ts index a58a30b..c656254 100644 --- a/src/functions/deserialize.ts +++ b/src/runtime/deserialize.ts @@ -3,7 +3,7 @@ import { isErr } from '@zipbul/result'; import type { RuntimeOptions } from '../interfaces'; import type { SealedExecutors } from '../types'; -import { toBakerIssueSet, BakerError, type BakerIssue, type BakerIssueSet } from '../errors'; +import { toBakerIssueSet, BakerError, type BakerIssue, type BakerIssueSet } from '../common/errors'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/functions/serialize.spec.ts b/src/runtime/serialize.spec.ts similarity index 99% rename from src/functions/serialize.spec.ts rename to src/runtime/serialize.spec.ts index 6a082ca..f92e439 100644 --- a/src/functions/serialize.spec.ts +++ b/src/runtime/serialize.spec.ts @@ -5,7 +5,7 @@ import type { SealedExecutors } from '../types'; import { Baker } from '../baker'; import { Field } from '../decorators/field'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { isString } from '../rules/typechecker'; import { resolveSerializeClass, runSerialize } from './serialize'; diff --git a/src/functions/serialize.ts b/src/runtime/serialize.ts similarity index 98% rename from src/functions/serialize.ts rename to src/runtime/serialize.ts index 221f9bf..230cf6b 100644 --- a/src/functions/serialize.ts +++ b/src/runtime/serialize.ts @@ -1,7 +1,7 @@ import type { RuntimeOptions } from '../interfaces'; import type { SealedExecutors } from '../types'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/functions/validate.ts b/src/runtime/validate.ts similarity index 94% rename from src/functions/validate.ts rename to src/runtime/validate.ts index c605af6..c148cea 100644 --- a/src/functions/validate.ts +++ b/src/runtime/validate.ts @@ -1,8 +1,8 @@ -import type { BakerIssue, BakerIssueSet } from '../errors'; +import type { BakerIssue, BakerIssueSet } from '../common/errors'; import type { RuntimeOptions } from '../interfaces'; import type { SealedExecutors } from '../types'; -import { toBakerIssueSet, BakerError } from '../errors'; +import { toBakerIssueSet, BakerError } from '../common/errors'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/circular-analyzer.spec.ts b/src/seal/circular-analyzer.spec.ts index 110dc87..5801e0c 100644 --- a/src/seal/circular-analyzer.spec.ts +++ b/src/seal/circular-analyzer.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import type { ClassCtor, RawClassMeta } from '../types'; -import { setRaw } from '../meta-access'; +import { setRaw } from '../metadata/meta-access'; import { analyzeCircular } from './circular-analyzer'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/circular-analyzer.ts b/src/seal/circular-analyzer.ts index 1c18eca..a87f602 100644 --- a/src/seal/circular-analyzer.ts +++ b/src/seal/circular-analyzer.ts @@ -1,5 +1,5 @@ -import { BakerError } from '../errors'; -import { getRaw } from '../meta-access'; +import { BakerError } from '../common/errors'; +import { getRaw } from '../metadata/meta-access'; /** * Static analysis for circular references (§4.6) diff --git a/src/seal/deserialize-builder.spec.ts b/src/seal/deserialize-builder.spec.ts index 36af206..cf1edc2 100644 --- a/src/seal/deserialize-builder.spec.ts +++ b/src/seal/deserialize-builder.spec.ts @@ -1,7 +1,7 @@ import { isErr, err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; -import type { BakerIssue } from '../errors'; +import type { BakerIssue } from '../common/errors'; import type { SealOptions } from '../interfaces'; import type { RawClassMeta, SealedExecutors, EmittableRule } from '../types'; diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index c9c4861..5802678 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -6,7 +6,7 @@ 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 { BakerError, type BakerIssue } from '../common/errors'; import { emitRulePlan } from '../rule-plan'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; import { GuardKey } from './enums'; diff --git a/src/seal/expose-validator.spec.ts b/src/seal/expose-validator.spec.ts index 33e06d4..bcc7d8d 100644 --- a/src/seal/expose-validator.spec.ts +++ b/src/seal/expose-validator.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'bun:test'; import type { RawClassMeta } from '../types'; -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..3cbbdef 100644 --- a/src/seal/expose-validator.ts +++ b/src/seal/expose-validator.ts @@ -1,7 +1,7 @@ import type { RawClassMeta, ExposeDef } from '../types'; import { Direction } from '../enums'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; /** * Static validation of @Expose stacks (§4.1, §3.3) diff --git a/src/seal/seal.spec.ts b/src/seal/seal.spec.ts index 4565fd2..0cd498c 100644 --- a/src/seal/seal.spec.ts +++ b/src/seal/seal.spec.ts @@ -5,8 +5,8 @@ import type { RawClassMeta, RuleDef } from '../types'; 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 { setRaw } from '../metadata/meta-access'; import { min, max } from '../rules/number'; import { isString } from '../rules/typechecker'; import { circularPlaceholder, mergeInheritance } from './seal'; diff --git a/src/seal/seal.ts b/src/seal/seal.ts index 53da366..efb6420 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -2,9 +2,9 @@ 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 { BakerError } from '../common/errors'; +import { getRaw, hasRawOwn } from '../metadata/meta-access'; +import { isAsyncFunction } from '../common/utils'; import { analyzeCircular } from './circular-analyzer'; import { configFingerprint, getCached, setCached } from './compile-cache'; import { buildDeserializeCode, buildValidateCode } from './deserialize-builder'; diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index d7fcaab..0eb5309 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -2,7 +2,7 @@ import type { SealOptions, RuntimeOptions } from '../interfaces'; import type { RawClassMeta, RawPropertyMeta, SealedExecutors, TransformDef } from '../types'; import { CollectionType } from '../enums'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/validate-meta.ts b/src/seal/validate-meta.ts index 0eb0800..25c0297 100644 --- a/src/seal/validate-meta.ts +++ b/src/seal/validate-meta.ts @@ -1,8 +1,8 @@ import type { RawClassMeta } from '../types'; import { CollectionType } from '../enums'; -import { BakerError } from '../errors'; -import { hasRawOwn } from '../meta-access'; +import { BakerError } from '../common/errors'; +import { hasRawOwn } from '../metadata/meta-access'; /** * @internal — seal-time invariant checks invoked from sealOne after merge + type normalization, diff --git a/src/transformers/luxon.transformer.ts b/src/transformers/luxon.transformer.ts index 48bde1c..367f599 100644 --- a/src/transformers/luxon.transformer.ts +++ b/src/transformers/luxon.transformer.ts @@ -1,6 +1,6 @@ import type { Transformer } from '../types'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; interface LuxonTransformerOptions { format?: string; diff --git a/src/transformers/moment.transformer.ts b/src/transformers/moment.transformer.ts index ec70704..85c4da6 100644 --- a/src/transformers/moment.transformer.ts +++ b/src/transformers/moment.transformer.ts @@ -1,6 +1,6 @@ import type { Transformer } from '../types'; -import { BakerError } from '../errors'; +import { BakerError } from '../common/errors'; interface MomentTransformerOptions { format?: string; diff --git a/src/types.ts b/src/types.ts index 362c49b..3fda9a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { Result, ResultAsync } from '@zipbul/result'; import type { CacheKey, CollectionType, RequiredType, RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; -import type { BakerIssue } from './errors'; +import type { BakerIssue } from './common/errors'; import type { RuntimeOptions } from './interfaces'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/test/e2e/async-transform.test.ts b/test/e2e/async-transform.test.ts index d5c8a47..c1e0e21 100644 --- a/test/e2e/async-transform.test.ts +++ b/test/e2e/async-transform.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, beforeEach } from 'bun:test'; import { Baker, Field } from '../../index'; import { isString, isNumber } from '../../src/rules/index'; -import { isAsyncFunction } from '../../src/utils'; +import { isAsyncFunction } from '../../src/common/utils'; import { sealClass } from '../integration/helpers/seal'; import { unseal } from '../integration/helpers/unseal'; diff --git a/test/integration/codegen-snapshot.test.ts b/test/integration/codegen-snapshot.test.ts index 784523c..66ad44f 100644 --- a/test/integration/codegen-snapshot.test.ts +++ b/test/integration/codegen-snapshot.test.ts @@ -6,10 +6,10 @@ // (refs/regexes/execs) is not part of Function.prototype.toString(). import { describe, expect, it } from 'bun:test'; -import type { BakerConfig } from '../../src/configure'; +import type { BakerConfig } from '../../src/config/configure'; import { Baker, Field, arrayOf } from '../../index'; -import { normalizeConfig } from '../../src/configure'; +import { normalizeConfig } from '../../src/config/configure'; import { configFingerprint, getCached } from '../../src/seal/compile-cache'; import { isBoolean, 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/seal.test.ts b/test/integration/seal.test.ts index 423e8b5..96d7a43 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 { setRaw } from '../../src/metadata/meta-access'; import { isString, isNumber, isEmail, min } from '../../src/rules/index'; import { assertBakerIssueSet } from './helpers/assert'; From 82a88887fd162a06e6681179e11032cbb124732e Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 22:08:03 +0900 Subject: [PATCH 06/31] refactor: relocate type/enum/interface declarations to owning domains (Phase C1a) Declarations moved verbatim into domain files (common/, rules/, transformers/, metadata/, decorators/, seal/) per the semantic-owner placement; root types.ts/enums.ts/interfaces.ts become thin re-export shims so all importers stay green. The one upward edge is the type-only rules/types -> seal/types (EmitContext.addExecutor: SealedExecutors). C1b will repoint importers off the shims and delete them. tsc 0, 2350 pass/0 fail, codegen snapshot unchanged (15), deps:check clean, knip/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/common/enums.ts | 13 +++ src/common/interfaces.ts | 9 ++ src/common/types.ts | 2 + src/decorators/enums.ts | 5 + src/enums.ts | 71 +------------ src/interfaces.ts | 44 +------- src/metadata/enums.ts | 5 + src/metadata/types.ts | 106 +++++++++++++++++++ src/rules/enums.ts | 36 +++++++ src/rules/types.ts | 64 ++++++++++++ src/seal/interfaces.ts | 20 ++++ src/seal/types.ts | 24 +++++ src/transformers/types.ts | 13 +++ src/types.ts | 211 ++------------------------------------ 14 files changed, 312 insertions(+), 311 deletions(-) create mode 100644 src/common/enums.ts create mode 100644 src/common/interfaces.ts create mode 100644 src/common/types.ts create mode 100644 src/decorators/enums.ts create mode 100644 src/metadata/enums.ts create mode 100644 src/metadata/types.ts create mode 100644 src/rules/enums.ts create mode 100644 src/rules/types.ts create mode 100644 src/seal/interfaces.ts create mode 100644 src/seal/types.ts create mode 100644 src/transformers/types.ts 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/common/interfaces.ts b/src/common/interfaces.ts new file mode 100644 index 0000000..547b890 --- /dev/null +++ b/src/common/interfaces.ts @@ -0,0 +1,9 @@ +// ───────────────────────────────────────────────────────────────────────────── +// RuntimeOptions — per-call runtime options (§5.3). 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/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/enums.ts b/src/enums.ts index ad650f9..385eb30 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -1,66 +1,5 @@ -// ───────────────────────────────────────────────────────────────────────────── -// 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', -} +// Re-export shim — enums moved to their owning domains (Phase C1). Importers repointed in C1b. +export { Direction, CacheKey } from './common/enums'; +export { ExcludeMode } from './decorators/enums'; +export { RequiredType, RuleOp, RulePlanExprKind, RulePlanCheckKind } from './rules/enums'; +export { CollectionType } from './metadata/enums'; diff --git a/src/interfaces.ts b/src/interfaces.ts index 00c44ba..e2c9ae4 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,41 +1,3 @@ -// ───────────────────────────────────────────────────────────────────────────── -// 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[]; -} +// Re-export shim — interfaces moved to their owning domains (Phase C1). Importers repointed in C1b. +export type { SealOptions } from './seal/interfaces'; +export type { RuntimeOptions } from './common/interfaces'; 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/types.ts b/src/metadata/types.ts new file mode 100644 index 0000000..dcc896b --- /dev/null +++ b/src/metadata/types.ts @@ -0,0 +1,106 @@ +import type { ClassCtor } from '../common/types'; +import type { InternalRule } from '../rules/types'; +import type { TransformFunction } from '../transformers/types'; +import type { CollectionType } from './enums'; + +// ───────────────────────────────────────────────────────────────────────────── +// 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 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; +} + +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; +} 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/types.ts b/src/rules/types.ts new file mode 100644 index 0000000..732b1f1 --- /dev/null +++ b/src/rules/types.ts @@ -0,0 +1,64 @@ +import type { CacheKey } from '../common/enums'; +import type { SealedExecutors } from '../seal/types'; +import type { RuleOp, RulePlanCheckKind, RulePlanExprKind, RequiredType } from './enums'; + +// ───────────────────────────────────────────────────────────────────────────── +// 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; +} diff --git a/src/seal/interfaces.ts b/src/seal/interfaces.ts new file mode 100644 index 0000000..07ab5e8 --- /dev/null +++ b/src/seal/interfaces.ts @@ -0,0 +1,20 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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 — present but excluded from the result. + * @default false + */ + whitelist?: boolean; + /** true: include field exclusion reasons as comments in generated code. @default false */ + debug?: boolean; +} diff --git a/src/seal/types.ts b/src/seal/types.ts new file mode 100644 index 0000000..9ddd812 --- /dev/null +++ b/src/seal/types.ts @@ -0,0 +1,24 @@ +import type { Result, ResultAsync } from '@zipbul/result'; + +import type { BakerIssue } from '../common/errors'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { RawClassMeta } from '../metadata/types'; + +// ───────────────────────────────────────────────────────────────────────────── +// 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/src/transformers/types.ts b/src/transformers/types.ts new file mode 100644 index 0000000..cb34abc --- /dev/null +++ b/src/transformers/types.ts @@ -0,0 +1,13 @@ +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; diff --git a/src/types.ts b/src/types.ts index 3fda9a8..57f3b01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,204 +1,7 @@ -import type { Result, ResultAsync } from '@zipbul/result'; - -import type { CacheKey, CollectionType, RequiredType, RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; -import type { BakerIssue } from './common/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; -} +// Re-export shim — declarations moved to their owning domains (Phase C1). Importers will be +// repointed off this shim in C1b; this file is then deleted. +export type { ClassCtor } from './common/types'; +export type { EmitContext, EmittableRule, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './rules/types'; +export type { MessageArgs, RuleDef, TransformDef, ExposeDef, ExcludeDef, TypeDef, PropertyFlags, RawClassMeta, RawPropertyMeta } from './metadata/types'; +export type { Transformer, TransformParams, TransformFunction } from './transformers/types'; +export type { SealedExecutors } from './seal/types'; From e5ce6f9044c8cad4d4e91a748676d1a5e29556f0 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 22:16:03 +0900 Subject: [PATCH 07/31] refactor: repoint imports to domain type files, delete junk-drawer shims (Phase C1b) Repoint 49 files off the root types.ts/enums.ts/interfaces.ts re-export shims to the domain homes, then delete the three shims. types.ts/enums.ts/interfaces.ts are gone; each symbol is now imported directly from its owning domain. No declaration/name change. tsc 0, 2350 pass/0 fail, codegen snapshot unchanged (15), deps:check clean, knip/lint clean, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.ts | 8 +++++--- src/baker.ts | 5 +++-- src/config/configure.ts | 2 +- src/create-rule.spec.ts | 2 +- src/create-rule.ts | 4 ++-- src/decorators/field.ts | 8 ++++++-- src/decorators/transform.spec.ts | 6 ++++-- src/enums.ts | 5 ----- src/interfaces.ts | 3 --- src/metadata/collect.ts | 2 +- src/metadata/meta-access.ts | 2 +- src/rule-metadata.ts | 2 +- src/rule-plan.ts | 6 +++--- src/rules/array.spec.ts | 2 +- src/rules/array.ts | 5 +++-- src/rules/binary.spec.ts | 2 +- src/rules/binary.ts | 2 +- src/rules/combinators.spec.ts | 2 +- src/rules/combinators.ts | 2 +- src/rules/common.spec.ts | 2 +- src/rules/common.ts | 2 +- src/rules/date.spec.ts | 2 +- src/rules/date.ts | 5 +++-- src/rules/locales.spec.ts | 4 ++-- src/rules/locales.ts | 4 ++-- src/rules/number.spec.ts | 4 ++-- src/rules/number.ts | 4 ++-- src/rules/object.spec.ts | 2 +- src/rules/object.ts | 4 ++-- src/rules/string.spec.ts | 4 ++-- src/rules/string.ts | 5 +++-- src/rules/typechecker.spec.ts | 4 ++-- src/rules/typechecker.ts | 4 ++-- src/runtime/check-call-options.ts | 2 +- src/runtime/deserialize.spec.ts | 4 ++-- src/runtime/deserialize.ts | 4 ++-- src/runtime/serialize.spec.ts | 4 ++-- src/runtime/serialize.ts | 4 ++-- src/runtime/validate.ts | 4 ++-- src/seal/circular-analyzer.spec.ts | 3 ++- src/seal/compile-cache.ts | 4 ++-- src/seal/deserialize-builder.spec.ts | 10 ++++++---- src/seal/deserialize-builder.ts | 12 ++++++++---- src/seal/expose-validator.spec.ts | 2 +- src/seal/expose-validator.ts | 4 ++-- src/seal/seal.spec.ts | 2 +- src/seal/seal.ts | 9 ++++++--- src/seal/serialize-builder.spec.ts | 7 ++++--- src/seal/serialize-builder.ts | 8 +++++--- src/seal/validate-meta.ts | 4 ++-- src/transformers/collection.transformer.ts | 2 +- src/transformers/date.transformer.ts | 2 +- src/transformers/luxon.transformer.ts | 2 +- src/transformers/moment.transformer.ts | 2 +- src/transformers/number.transformer.ts | 2 +- src/transformers/string.transformer.ts | 2 +- src/types.ts | 7 ------- test/e2e/fuzz-parity.test.ts | 2 +- test/e2e/rule-semantics-parity.test.ts | 2 +- test/e2e/seal-error.test.ts | 2 +- test/e2e/string-semantics-parity-meta.test.ts | 4 ++-- test/integration/check-call-options.test.ts | 2 +- test/integration/error-system.test.ts | 2 +- 63 files changed, 125 insertions(+), 115 deletions(-) delete mode 100644 src/enums.ts delete mode 100644 src/interfaces.ts delete mode 100644 src/types.ts diff --git a/index.ts b/index.ts index c838c3c..be3c399 100644 --- a/index.ts +++ b/index.ts @@ -9,15 +9,17 @@ export type { FieldOptions, ArrayOfMarker } from './src/decorators/index'; export { Baker } from './src/baker'; // Enums -export { ExcludeMode, RequiredType } from './src/enums'; +export { ExcludeMode } from './src/decorators/enums'; +export { RequiredType } from './src/rules/enums'; // Errors export type { BakerIssue, BakerIssueSet } from './src/common/errors'; export { isBakerIssueSet, BakerError } from './src/common/errors'; // Types -export type { EmittableRule, Transformer, TransformParams } from './src/types'; +export type { EmittableRule } from './src/rules/types'; +export type { Transformer, TransformParams } from './src/transformers/types'; export type { BakerConfig } from './src/config/configure'; // Interfaces / Options -export type { RuntimeOptions } from './src/interfaces'; +export type { RuntimeOptions } from './src/common/interfaces'; diff --git a/src/baker.ts b/src/baker.ts index 9466d6c..cbb576f 100644 --- a/src/baker.ts +++ b/src/baker.ts @@ -1,7 +1,8 @@ import type { BakerConfig } from './config/configure'; import type { BakerIssueSet } from './common/errors'; -import type { RuntimeOptions, SealOptions } from './interfaces'; -import type { SealedExecutors } from './types'; +import type { RuntimeOptions } from './common/interfaces'; +import type { SealOptions } from './seal/interfaces'; +import type { SealedExecutors } from './seal/types'; import { normalizeConfig } from './config/configure'; import { BakerError } from './common/errors'; diff --git a/src/config/configure.ts b/src/config/configure.ts index 66391dd..fb80ac7 100644 --- a/src/config/configure.ts +++ b/src/config/configure.ts @@ -1,4 +1,4 @@ -import type { SealOptions } from '../interfaces'; +import type { SealOptions } from '../seal/interfaces'; import { BakerError } from '../common/errors'; diff --git a/src/create-rule.spec.ts b/src/create-rule.spec.ts index 45d8c6e..1b9ce71 100644 --- a/src/create-rule.spec.ts +++ b/src/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 './rules/types'; import { createRule } from './create-rule'; diff --git a/src/create-rule.ts b/src/create-rule.ts index bd7a379..8e88af1 100644 --- a/src/create-rule.ts +++ b/src/create-rule.ts @@ -1,5 +1,5 @@ -import type { RequiredType } from './enums'; -import type { EmittableRule, EmitContext, InternalRule } from './types'; +import type { RequiredType } from './rules/enums'; +import type { EmittableRule, EmitContext, InternalRule } from './rules/types'; import { BakerError } from './common/errors'; import { defineRuleMetadata } from './rule-metadata'; diff --git a/src/decorators/field.ts b/src/decorators/field.ts index c593d64..d7fa77c 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,7 +1,11 @@ -import type { ClassCtor, EmittableRule, InternalRule, RawPropertyMeta, RuleDef, ExposeDef, TypeDef, Transformer } from '../types'; +import type { ClassCtor } from '../common/types'; +import type { EmittableRule, InternalRule } from '../rules/types'; +import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata/types'; +import type { Transformer } from '../transformers/types'; import { ensureMeta } from '../metadata/collect'; -import { Direction, ExcludeMode } from '../enums'; +import { Direction } from '../common/enums'; +import { ExcludeMode } from './enums'; import { BakerError } from '../common/errors'; import { isAsyncFunction, isPromiseLike } from '../common/utils'; diff --git a/src/decorators/transform.spec.ts b/src/decorators/transform.spec.ts index 17e6c51..94c331c 100644 --- a/src/decorators/transform.spec.ts +++ b/src/decorators/transform.spec.ts @@ -1,10 +1,12 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import type { EmittableRule, RawPropertyMeta, TransformDef, TransformParams, TypeDef } from '../types'; +import type { EmittableRule } from '../rules/types'; +import type { RawPropertyMeta, TransformDef, TypeDef } from '../metadata/types'; +import type { TransformParams } from '../transformers/types'; import { assertDefined } from '../../test/integration/helpers/assert'; import { applyField } from '../../test/integration/helpers/modern-decorator'; -import { ExcludeMode } from '../enums'; +import { ExcludeMode } from './enums'; import { deleteRaw, requireRaw } from '../metadata/meta-access'; import { Field } from './field'; diff --git a/src/enums.ts b/src/enums.ts deleted file mode 100644 index 385eb30..0000000 --- a/src/enums.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Re-export shim — enums moved to their owning domains (Phase C1). Importers repointed in C1b. -export { Direction, CacheKey } from './common/enums'; -export { ExcludeMode } from './decorators/enums'; -export { RequiredType, RuleOp, RulePlanExprKind, RulePlanCheckKind } from './rules/enums'; -export { CollectionType } from './metadata/enums'; diff --git a/src/interfaces.ts b/src/interfaces.ts deleted file mode 100644 index e2c9ae4..0000000 --- a/src/interfaces.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Re-export shim — interfaces moved to their owning domains (Phase C1). Importers repointed in C1b. -export type { SealOptions } from './seal/interfaces'; -export type { RuntimeOptions } from './common/interfaces'; diff --git a/src/metadata/collect.ts b/src/metadata/collect.ts index 4da21c8..08a327e 100644 --- a/src/metadata/collect.ts +++ b/src/metadata/collect.ts @@ -1,4 +1,4 @@ -import type { RawClassMeta, RawPropertyMeta } from '../types'; +import type { RawClassMeta, RawPropertyMeta } from './types'; import { RAW } from '../symbols'; diff --git a/src/metadata/meta-access.ts b/src/metadata/meta-access.ts index 3c8e78e..c41d7b6 100644 --- a/src/metadata/meta-access.ts +++ b/src/metadata/meta-access.ts @@ -1,4 +1,4 @@ -import type { RawClassMeta } from '../types'; +import type { RawClassMeta } from './types'; import { RAW } from '../symbols'; diff --git a/src/rule-metadata.ts b/src/rule-metadata.ts index 0d1f7c7..822d105 100644 --- a/src/rule-metadata.ts +++ b/src/rule-metadata.ts @@ -1,4 +1,4 @@ -import type { EmittableRule, InternalRule, RulePlan } from './types'; +import type { EmittableRule, InternalRule, RulePlan } from './rules/types'; // Type boundary — the single place that brands a bare validator function with // the readonly metadata properties declared on InternalRule. All other modules diff --git a/src/rule-plan.ts b/src/rule-plan.ts index 2fd1698..357cd49 100644 --- a/src/rule-plan.ts +++ b/src/rule-plan.ts @@ -1,7 +1,7 @@ -import type { RequiredType } from './enums'; -import type { EmitContext, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './types'; +import type { RequiredType } from './rules/enums'; +import type { EmitContext, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './rules/types'; -import { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; +import { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './rules/enums'; import { defineRuleMetadata } from './rule-metadata'; type RulePlanCache = { diff --git a/src/rules/array.spec.ts b/src/rules/array.spec.ts index 086a8b3..be8bd54 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 './types'; import { arrayContains, arrayNotContains, arrayMinSize, arrayMaxSize, arrayUnique, arrayNotEmpty } from './array'; diff --git a/src/rules/array.ts b/src/rules/array.ts index 6587c0f..76dc0b7 100644 --- a/src/rules/array.ts +++ b/src/rules/array.ts @@ -1,6 +1,7 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; -import { CacheKey, RequiredType, RuleOp } from '../enums'; +import { CacheKey } from '../common/enums'; +import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, makeRule, planCompare, planLength } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/binary.spec.ts b/src/rules/binary.spec.ts index 32dff60..1bcfbdd 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 './types'; import { isUint8Array, isByteSize } from './binary'; diff --git a/src/rules/binary.ts b/src/rules/binary.ts index c5adabc..5d93edd 100644 --- a/src/rules/binary.ts +++ b/src/rules/binary.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; import { makeRule } from '../rule-plan'; diff --git a/src/rules/combinators.spec.ts b/src/rules/combinators.spec.ts index 81ae4dc..dc6cf6c 100644 --- a/src/rules/combinators.spec.ts +++ b/src/rules/combinators.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './types'; import { createRule } from '../create-rule'; import { oneOf, arrayEvery } from './combinators'; diff --git a/src/rules/combinators.ts b/src/rules/combinators.ts index 15cb1c9..505b3c4 100644 --- a/src/rules/combinators.ts +++ b/src/rules/combinators.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; import { BakerError } from '../common/errors'; import { makeRule } from '../rule-plan'; diff --git a/src/rules/common.spec.ts b/src/rules/common.spec.ts index f12eea0..1c5c506 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 './types'; import { equals, notEquals, isEmpty, isNotEmpty, isIn, isNotIn } from './common'; diff --git a/src/rules/common.ts b/src/rules/common.ts index 87e196b..ab80708 100644 --- a/src/rules/common.ts +++ b/src/rules/common.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; import { makeRule } from '../rule-plan'; diff --git a/src/rules/date.spec.ts b/src/rules/date.spec.ts index 7113610..54cad50 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 './types'; import { minDate, maxDate } from './date'; diff --git a/src/rules/date.ts b/src/rules/date.ts index e61ebd9..ca5d926 100644 --- a/src/rules/date.ts +++ b/src/rules/date.ts @@ -1,6 +1,7 @@ -import type { EmittableRule } from '../types'; +import type { EmittableRule } from './types'; -import { CacheKey, RequiredType, RuleOp } from '../enums'; +import { CacheKey } from '../common/enums'; +import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, planCompare, planLiteral, planOr, planTime } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/locales.spec.ts b/src/rules/locales.spec.ts index 3309280..6e4c7c0 100644 --- a/src/rules/locales.spec.ts +++ b/src/rules/locales.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './types'; import { isMobilePhone, isPostalCode, isIdentityCard, isPassportNumber } from './locales'; diff --git a/src/rules/locales.ts b/src/rules/locales.ts index aa6c789..ce923b1 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -1,6 +1,6 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; import { BakerError } from '../common/errors'; import { makeRule } from '../rule-plan'; diff --git a/src/rules/number.spec.ts b/src/rules/number.spec.ts index 666372d..f655ac9 100644 --- a/src/rules/number.spec.ts +++ b/src/rules/number.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './types'; import { min, max, isPositive, isNegative, isDivisibleBy } from './number'; diff --git a/src/rules/number.ts b/src/rules/number.ts index 909f4f5..c36ba14 100644 --- a/src/rules/number.ts +++ b/src/rules/number.ts @@ -1,6 +1,6 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; -import { RequiredType, RuleOp } from '../enums'; +import { RequiredType, RuleOp } from './enums'; import { BakerError } from '../common/errors'; import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from '../rule-plan'; diff --git a/src/rules/object.spec.ts b/src/rules/object.spec.ts index 67ce708..72759b3 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 './types'; import { isNotEmptyObject, isInstance } from './object'; diff --git a/src/rules/object.ts b/src/rules/object.ts index 923bb7e..c10fd52 100644 --- a/src/rules/object.ts +++ b/src/rules/object.ts @@ -1,6 +1,6 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; import { makeRule } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/string.spec.ts b/src/rules/string.spec.ts index 9d95634..5b938b4 100644 --- a/src/rules/string.spec.ts +++ b/src/rules/string.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './types'; import { // Group A — length/range diff --git a/src/rules/string.ts b/src/rules/string.ts index eaa9b59..54ba141 100644 --- a/src/rules/string.ts +++ b/src/rules/string.ts @@ -1,6 +1,7 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; -import { CacheKey, RequiredType, RuleOp } from '../enums'; +import { CacheKey } from '../common/enums'; +import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, makeRule, planCompare, planLength, planOr } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/typechecker.spec.ts b/src/rules/typechecker.spec.ts index 9b0c5b8..5a65214 100644 --- a/src/rules/typechecker.spec.ts +++ b/src/rules/typechecker.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; -import type { EmitContext } from '../types'; +import type { EmitContext } from './types'; import { isString, diff --git a/src/rules/typechecker.ts b/src/rules/typechecker.ts index f9a6c7a..345b744 100644 --- a/src/rules/typechecker.ts +++ b/src/rules/typechecker.ts @@ -1,6 +1,6 @@ -import type { EmitContext, EmittableRule } from '../types'; +import type { EmitContext, EmittableRule } from './types'; -import { RequiredType } from '../enums'; +import { RequiredType } from './enums'; import { makeRule } from '../rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/check-call-options.ts b/src/runtime/check-call-options.ts index 92b31bb..af6369f 100644 --- a/src/runtime/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -1,4 +1,4 @@ -import type { RuntimeOptions } from '../interfaces'; +import type { RuntimeOptions } from '../common/interfaces'; import { BakerError } from '../common/errors'; diff --git a/src/runtime/deserialize.spec.ts b/src/runtime/deserialize.spec.ts index dc4f87a..e5afdbe 100644 --- a/src/runtime/deserialize.spec.ts +++ b/src/runtime/deserialize.spec.ts @@ -1,8 +1,8 @@ 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/types'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { Baker } from '../baker'; diff --git a/src/runtime/deserialize.ts b/src/runtime/deserialize.ts index c656254..0df4b14 100644 --- a/src/runtime/deserialize.ts +++ b/src/runtime/deserialize.ts @@ -1,7 +1,7 @@ import { isErr } from '@zipbul/result'; -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { SealedExecutors } from '../seal/types'; import { toBakerIssueSet, BakerError, type BakerIssue, type BakerIssueSet } from '../common/errors'; import { checkCallOptions } from './check-call-options'; diff --git a/src/runtime/serialize.spec.ts b/src/runtime/serialize.spec.ts index f92e439..e24add9 100644 --- a/src/runtime/serialize.spec.ts +++ b/src/runtime/serialize.spec.ts @@ -1,7 +1,7 @@ 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/types'; import { Baker } from '../baker'; import { Field } from '../decorators/field'; diff --git a/src/runtime/serialize.ts b/src/runtime/serialize.ts index 230cf6b..e8900b3 100644 --- a/src/runtime/serialize.ts +++ b/src/runtime/serialize.ts @@ -1,5 +1,5 @@ -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { SealedExecutors } from '../seal/types'; import { BakerError } from '../common/errors'; import { checkCallOptions } from './check-call-options'; diff --git a/src/runtime/validate.ts b/src/runtime/validate.ts index c148cea..c3ba59a 100644 --- a/src/runtime/validate.ts +++ b/src/runtime/validate.ts @@ -1,6 +1,6 @@ import type { BakerIssue, BakerIssueSet } from '../common/errors'; -import type { RuntimeOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { SealedExecutors } from '../seal/types'; import { toBakerIssueSet, BakerError } from '../common/errors'; import { checkCallOptions } from './check-call-options'; diff --git a/src/seal/circular-analyzer.spec.ts b/src/seal/circular-analyzer.spec.ts index 5801e0c..e075aac 100644 --- a/src/seal/circular-analyzer.spec.ts +++ b/src/seal/circular-analyzer.spec.ts @@ -1,6 +1,7 @@ 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/types'; import { setRaw } from '../metadata/meta-access'; import { analyzeCircular } from './circular-analyzer'; diff --git a/src/seal/compile-cache.ts b/src/seal/compile-cache.ts index f52da8e..f2da0fc 100644 --- a/src/seal/compile-cache.ts +++ b/src/seal/compile-cache.ts @@ -1,5 +1,5 @@ -import type { SealOptions } from '../interfaces'; -import type { SealedExecutors } from '../types'; +import type { SealOptions } from './interfaces'; +import type { SealedExecutors } from './types'; // ───────────────────────────────────────────────────────────────────────────── // (class, config) executor cache — content-addressed sharing across bakers diff --git a/src/seal/deserialize-builder.spec.ts b/src/seal/deserialize-builder.spec.ts index cf1edc2..a5055f1 100644 --- a/src/seal/deserialize-builder.spec.ts +++ b/src/seal/deserialize-builder.spec.ts @@ -2,8 +2,10 @@ import { isErr, err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; import type { BakerIssue } from '../common/errors'; -import type { SealOptions } from '../interfaces'; -import type { RawClassMeta, SealedExecutors, EmittableRule } from '../types'; +import type { SealOptions } from './interfaces'; +import type { RawClassMeta } from '../metadata/types'; +import type { EmittableRule } from '../rules/types'; +import type { SealedExecutors } from './types'; import { assertIsErr } from '../../test/integration/helpers/assert'; import { isNotEmpty } from '../rules/common'; @@ -842,7 +844,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/types').EmitContext): string => '', ruleName: 'alwaysPass', }); @@ -959,7 +961,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/types').EmitContext): string { // exercise addExecutor to cover L657-658 ctx.addExecutor(dummySealedExec); // return a simple validation check (always pass for string) diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 5802678..6e3d623 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -2,10 +2,14 @@ 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 type { RuntimeOptions } from '../common/interfaces'; +import type { SealOptions } from './interfaces'; +import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata/types'; +import type { EmitContext } from '../rules/types'; +import type { SealedExecutors } from './types'; + +import { CacheKey } from '../common/enums'; +import { CollectionType } from '../metadata/enums'; import { BakerError, type BakerIssue } from '../common/errors'; import { emitRulePlan } from '../rule-plan'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; diff --git a/src/seal/expose-validator.spec.ts b/src/seal/expose-validator.spec.ts index bcc7d8d..de2ab5d 100644 --- a/src/seal/expose-validator.spec.ts +++ b/src/seal/expose-validator.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import type { RawClassMeta } from '../types'; +import type { RawClassMeta } from '../metadata/types'; 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 3cbbdef..f70f2d1 100644 --- a/src/seal/expose-validator.ts +++ b/src/seal/expose-validator.ts @@ -1,6 +1,6 @@ -import type { RawClassMeta, ExposeDef } from '../types'; +import type { RawClassMeta, ExposeDef } from '../metadata/types'; -import { Direction } from '../enums'; +import { Direction } from '../common/enums'; import { BakerError } from '../common/errors'; /** diff --git a/src/seal/seal.spec.ts b/src/seal/seal.spec.ts index 0cd498c..0eddd22 100644 --- a/src/seal/seal.spec.ts +++ b/src/seal/seal.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, spyOn } from 'bun:test'; -import type { RawClassMeta, RuleDef } from '../types'; +import type { RawClassMeta, RuleDef } from '../metadata/types'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { sealClass } from '../../test/integration/helpers/seal'; diff --git a/src/seal/seal.ts b/src/seal/seal.ts index efb6420..bf34b71 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -1,7 +1,10 @@ -import type { SealOptions } from '../interfaces'; -import type { ClassCtor, RawClassMeta, RawPropertyMeta, SealedExecutors } from '../types'; +import type { SealOptions } from './interfaces'; +import type { ClassCtor } from '../common/types'; +import type { RawClassMeta, RawPropertyMeta } from '../metadata/types'; +import type { SealedExecutors } from './types'; -import { CollectionType, Direction } from '../enums'; +import { CollectionType } from '../metadata/enums'; +import { Direction } from '../common/enums'; import { BakerError } from '../common/errors'; import { getRaw, hasRawOwn } from '../metadata/meta-access'; import { isAsyncFunction } from '../common/utils'; diff --git a/src/seal/serialize-builder.spec.ts b/src/seal/serialize-builder.spec.ts index 5341652..f0514f4 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/types'; +import type { SealedExecutors } from './types'; -import { CollectionType } from '../enums'; +import { CollectionType } from '../metadata/enums'; import { isString } from '../rules/typechecker'; import { buildSerializeCode } from './serialize-builder'; diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index 0eb5309..dd8b38c 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -1,7 +1,9 @@ -import type { SealOptions, RuntimeOptions } from '../interfaces'; -import type { RawClassMeta, RawPropertyMeta, SealedExecutors, TransformDef } from '../types'; +import type { RuntimeOptions } from '../common/interfaces'; +import type { SealOptions } from './interfaces'; +import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata/types'; +import type { SealedExecutors } from './types'; -import { CollectionType } from '../enums'; +import { CollectionType } from '../metadata/enums'; import { BakerError } from '../common/errors'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; diff --git a/src/seal/validate-meta.ts b/src/seal/validate-meta.ts index 25c0297..845b5fe 100644 --- a/src/seal/validate-meta.ts +++ b/src/seal/validate-meta.ts @@ -1,6 +1,6 @@ -import type { RawClassMeta } from '../types'; +import type { RawClassMeta } from '../metadata/types'; -import { CollectionType } from '../enums'; +import { CollectionType } from '../metadata/enums'; import { BakerError } from '../common/errors'; import { hasRawOwn } from '../metadata/meta-access'; diff --git a/src/transformers/collection.transformer.ts b/src/transformers/collection.transformer.ts index c492a24..af2c288 100644 --- a/src/transformers/collection.transformer.ts +++ b/src/transformers/collection.transformer.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './types'; export function csvTransformer(separator = ','): Transformer { return { diff --git a/src/transformers/date.transformer.ts b/src/transformers/date.transformer.ts index 7a637f8..02df92b 100644 --- a/src/transformers/date.transformer.ts +++ b/src/transformers/date.transformer.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './types'; export const unixSecondsTransformer: Transformer = { deserialize: ({ value }) => (typeof value === 'number' ? new Date(value * 1000) : value), diff --git a/src/transformers/luxon.transformer.ts b/src/transformers/luxon.transformer.ts index 367f599..be0499d 100644 --- a/src/transformers/luxon.transformer.ts +++ b/src/transformers/luxon.transformer.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './types'; import { BakerError } from '../common/errors'; diff --git a/src/transformers/moment.transformer.ts b/src/transformers/moment.transformer.ts index 85c4da6..cc3eca5 100644 --- a/src/transformers/moment.transformer.ts +++ b/src/transformers/moment.transformer.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './types'; import { BakerError } from '../common/errors'; diff --git a/src/transformers/number.transformer.ts b/src/transformers/number.transformer.ts index 0d5ef00..ee20167 100644 --- a/src/transformers/number.transformer.ts +++ b/src/transformers/number.transformer.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './types'; export function roundTransformer(precision = 0): Transformer { const factor = Math.pow(10, precision); diff --git a/src/transformers/string.transformer.ts b/src/transformers/string.transformer.ts index 6c47dc2..046847f 100644 --- a/src/transformers/string.transformer.ts +++ b/src/transformers/string.transformer.ts @@ -1,4 +1,4 @@ -import type { Transformer } from '../types'; +import type { Transformer } from './types'; export const trimTransformer: Transformer = { deserialize: ({ value }) => (typeof value === 'string' ? value.trim() : value), diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 57f3b01..0000000 --- a/src/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Re-export shim — declarations moved to their owning domains (Phase C1). Importers will be -// repointed off this shim in C1b; this file is then deleted. -export type { ClassCtor } from './common/types'; -export type { EmitContext, EmittableRule, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './rules/types'; -export type { MessageArgs, RuleDef, TransformDef, ExposeDef, ExcludeDef, TypeDef, PropertyFlags, RawClassMeta, RawPropertyMeta } from './metadata/types'; -export type { Transformer, TransformParams, TransformFunction } from './transformers/types'; -export type { SealedExecutors } from './seal/types'; diff --git a/test/e2e/fuzz-parity.test.ts b/test/e2e/fuzz-parity.test.ts index f674a08..7a542d3 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/types').EmittableRule, value: unknown): Promise { class Dto { @Field(rule) value!: unknown; diff --git a/test/e2e/rule-semantics-parity.test.ts b/test/e2e/rule-semantics-parity.test.ts index a17250a..9d0f67a 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/types').EmittableRule; samples: unknown[]; }; diff --git a/test/e2e/seal-error.test.ts b/test/e2e/seal-error.test.ts index aaf21fb..d4e3bf3 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/types'; import { Baker, Field, BakerError } from '../../index'; import { isNumber } from '../../src/rules/index'; diff --git a/test/e2e/string-semantics-parity-meta.test.ts b/test/e2e/string-semantics-parity-meta.test.ts index 5b90118..1feff7d 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/types').EmittableRule; samples: unknown[]; }; -async function dtoPasses(rule: import('../../src/types').EmittableRule, value: unknown): Promise { +async function dtoPasses(rule: import('../../src/rules/types').EmittableRule, value: unknown): Promise { class Dto { @Field(rule) value!: unknown; diff --git a/test/integration/check-call-options.test.ts b/test/integration/check-call-options.test.ts index 1125c4f..d03915f 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'; diff --git a/test/integration/error-system.test.ts b/test/integration/error-system.test.ts index eb48d4e..31931fb 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/types'; import { Baker, Field, BakerError, isBakerIssueSet } from '../../index'; import { isString } from '../../src/rules/index'; From 2d3f7920cb745da7ea530558d6beb8883eadbeb8 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 22:21:04 +0900 Subject: [PATCH 08/31] refactor(seal): extract async-analysis, merge-inheritance, circular-placeholder (Phase C2) Move analyzeAsync+nestedClassesOf -> seal/async-analysis.ts, mergeInheritance -> seal/merge-inheritance.ts, circularPlaceholder -> seal/circular-placeholder.ts, and the shared PRIMITIVE_CTORS -> seal/constants.ts. seal.ts is now a slim orchestrator; each module owns its own test surface (seal.spec repoints). Verbatim moves, no codegen change. tsc 0, 2350 pass/0 fail, codegen snapshot unchanged (15), deps:check clean, knip/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/async-analysis.ts | 99 ++++++++++++++ src/seal/circular-placeholder.ts | 21 +++ src/seal/constants.ts | 2 + src/seal/merge-inheritance.ts | 109 +++++++++++++++ src/seal/seal.spec.ts | 3 +- src/seal/seal.ts | 226 +------------------------------ 6 files changed, 238 insertions(+), 222 deletions(-) create mode 100644 src/seal/async-analysis.ts create mode 100644 src/seal/circular-placeholder.ts create mode 100644 src/seal/constants.ts create mode 100644 src/seal/merge-inheritance.ts diff --git a/src/seal/async-analysis.ts b/src/seal/async-analysis.ts new file mode 100644 index 0000000..7f27d46 --- /dev/null +++ b/src/seal/async-analysis.ts @@ -0,0 +1,99 @@ +import type { RawClassMeta, RawPropertyMeta } from '../metadata/types'; +import type { SealedExecutors } from './types'; + +import { Direction } from '../common/enums'; +import { isAsyncFunction } from '../common/utils'; +import { PRIMITIVE_CTORS } from './constants'; +import { mergeInheritance } from './merge-inheritance'; + +// ───────────────────────────────────────────────────────────────────────────── +// analyzeAsync — static analysis to determine if a sealed DTO requires an async executor (C1) +// ───────────────────────────────────────────────────────────────────────────── + +export 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; +} + +/** + * 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. + */ +export 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); + } + 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); + } + } else { + const resolved = Array.isArray(result) ? (result as unknown[])[0] : result; + if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { + out.push(resolved as Function); + } + } + } + return out; +} diff --git a/src/seal/circular-placeholder.ts b/src/seal/circular-placeholder.ts new file mode 100644 index 0000000..fd135d7 --- /dev/null +++ b/src/seal/circular-placeholder.ts @@ -0,0 +1,21 @@ +import type { SealedExecutors } from './types'; + +import { BakerError } from '../common/errors'; + +/** @internal Placeholder executor for circular dependency detection during seal */ +export 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, + }; +} diff --git a/src/seal/constants.ts b/src/seal/constants.ts new file mode 100644 index 0000000..f6e175e --- /dev/null +++ b/src/seal/constants.ts @@ -0,0 +1,2 @@ +/** Built-in constructors that are NOT treated as nested DTOs during seal. */ +export const PRIMITIVE_CTORS = new Set([Number, String, Boolean, Date]); diff --git a/src/seal/merge-inheritance.ts b/src/seal/merge-inheritance.ts new file mode 100644 index 0000000..c1c36d9 --- /dev/null +++ b/src/seal/merge-inheritance.ts @@ -0,0 +1,109 @@ +import type { RawClassMeta } from '../metadata/types'; + +import { getRaw, hasRawOwn } from '../metadata/meta-access'; + +// ───────────────────────────────────────────────────────────────────────────── +// 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 + */ +export 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; +} diff --git a/src/seal/seal.spec.ts b/src/seal/seal.spec.ts index 0eddd22..c3ef987 100644 --- a/src/seal/seal.spec.ts +++ b/src/seal/seal.spec.ts @@ -9,7 +9,8 @@ import { BakerError, isBakerIssueSet } from '../common/errors'; import { setRaw } from '../metadata/meta-access'; import { min, max } from '../rules/number'; import { isString } from '../rules/typechecker'; -import { circularPlaceholder, mergeInheritance } from './seal'; +import { circularPlaceholder } from './circular-placeholder'; +import { mergeInheritance } from './merge-inheritance'; // ───────────────────────────────────────────────────────────────────────────── // Helpers diff --git a/src/seal/seal.ts b/src/seal/seal.ts index bf34b71..b34c3b1 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -1,132 +1,22 @@ import type { SealOptions } from './interfaces'; import type { ClassCtor } from '../common/types'; -import type { RawClassMeta, RawPropertyMeta } from '../metadata/types'; import type { SealedExecutors } from './types'; import { CollectionType } from '../metadata/enums'; import { Direction } from '../common/enums'; import { BakerError } from '../common/errors'; -import { getRaw, hasRawOwn } from '../metadata/meta-access'; -import { isAsyncFunction } from '../common/utils'; +import { analyzeAsync, nestedClassesOf } from './async-analysis'; import { analyzeCircular } from './circular-analyzer'; +import { circularPlaceholder } from './circular-placeholder'; import { configFingerprint, getCached, setCached } from './compile-cache'; +import { PRIMITIVE_CTORS } from './constants'; import { buildDeserializeCode, buildValidateCode } from './deserialize-builder'; import { validateExposeStacks } from './expose-validator'; +import { mergeInheritance } from './merge-inheritance'; 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; -} - -/** - * 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. - */ -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); - } - 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); - } - } else { - const resolved = Array.isArray(result) ? (result as unknown[])[0] : result; - if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { - out.push(resolved as Function); - } - } - } - return out; -} /** * Seal every class in `registry` with `options`. The core used by `new Baker().seal()`. @@ -332,110 +222,4 @@ function sealOne( 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 - */ -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; -} - -export { sealRegistry, mergeInheritance, circularPlaceholder }; +export { sealRegistry }; From b36a6846dfb899f2a00bdc5e4042fc3b12fb1357 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 22:49:11 +0900 Subject: [PATCH 09/31] refactor(seal): convert deserialize/serialize builders to classes (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeserializeBuilder + SerializeBuilder classes hold codegen state (regexes/refs/execs/ options/resolve/...) as fields; the ~20 ctx-threaded functions become methods calling each other via this. Eliminates the FieldCodeContext threading, the fragment re-return, and the field<->nested-validate cycle-break callback. Inline-nested = a child builder sharing the parent's ref arrays (identical executor indices). buildDeserializeCode/buildValidateCode/ buildSerializeCode kept as thin exported wrappers; pure stateless helpers stay module-level. Structural only — generated new Function bodies BYTE-IDENTICAL (codegen snapshot unchanged). No new any/unknown. tsc 0, 2350 pass/0 fail, deps/knip/lint clean, build ok, memory pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/deserialize-builder.ts | 3015 ++++++++++++++++--------------- src/seal/serialize-builder.ts | 661 +++---- 2 files changed, 1858 insertions(+), 1818 deletions(-) diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 6e3d623..fb391fb 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -42,7 +42,7 @@ const GEN = { } as const; // ───────────────────────────────────────────────────────────────────────────── -// Helpers — code generation utilities +// Helpers — code generation utilities (pure, module-level) // ───────────────────────────────────────────────────────────────────────────── /** Generate nested error push code that propagates message/context fields */ @@ -113,207 +113,6 @@ function getDeserializeExposeGroups(exposeStack: RawPropertyMeta['expose']): str 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; - - // Reference arrays — injected into new Function closure - const regexes: RegExp[] = []; - const refs: unknown[] = []; - const execs: SealedExecutors[] = []; - - // ── Code generation ──────────────────────────────────────────────────────── - - // 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"; - - // 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`; - } - - // 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); - } - 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`; - } - } - - // 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; - } - 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 (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`; - } - - // 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; -} - -// ───────────────────────────────────────────────────────────────────────────── -// buildValidateCode — validate-only executor (no Object.create, no assignments) -// ───────────────────────────────────────────────────────────────────────────── - -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); -} - // ───────────────────────────────────────────────────────────────────────────── // nullable/optional guard — truth-table strategy pattern (D-3) // ───────────────────────────────────────────────────────────────────────────── @@ -381,345 +180,6 @@ const GUARD_STRATEGIES: Record string> = { }, }; -// ───────────────────────────────────────────────────────────────────────────── -// 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; -} - -function generateFieldCode(fieldKey: string, meta: RawPropertyMeta, ctx: FieldCodeContext): string { - const { exposeDefaultValues } = ctx; - - // ⓪ 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`; - } - 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, 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 = computeFieldExtras(meta, fieldKey, varName, ctx); - const emitCtx = makeEmitCtx(fieldKey, ctx, fieldExtras); - - let fieldCode = ''; - - // ① @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 = 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 (§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; - - // ② 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 = generateValidationCode(fieldKey, varName, meta, ctx, emitCtx, exposeGroups); - const assignNull = ctx.validateOnly ? '' : `${GEN.out}[${JSON.stringify(fieldKey)}] = null;\n`; - - 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; - } - - return fieldCode; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Validation code generation — type guard + transform + validate + assign -// ───────────────────────────────────────────────────────────────────────────── - -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); - 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`; - } - 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; -} - -/** 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, - ); -} - -/** 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); -} - -/** 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; - } - 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; - } - if (rd.rule.plan?.cacheKey === CacheKey.Length) { - lengthCount += 1; - } else if (rd.rule.plan?.cacheKey === CacheKey.Time) { - timeCount += 1; - } - } - 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`; - } - if (timeVar) { - code += `${indent}var ${timeVar} = ${varName}.getTime();\n`; - } - - 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); - } - 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'; - } - - return code; -} - // ───────────────────────────────────────────────────────────────────────────── // wrapGroupsGuard — per-rule validation groups check wrapper (§M4) // ───────────────────────────────────────────────────────────────────────────── @@ -806,11 +266,6 @@ const ASSERTER_TO_GATE: Record = { /** 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 (§4.3, §4.10) -// Decomposed into: categorizeRules → resolveTypeGate → emitTypedRules / emitGeneralRules / emitEachRules -// ───────────────────────────────────────────────────────────────────────────── - /** Result of categorizeRules — each/nonEach split and typed dependency classification */ interface CategorizedRules { each: RuleDef[]; @@ -819,7 +274,7 @@ interface CategorizedRules { typedDeps: { type: 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object'; deps: RuleDef[] } | undefined; } -/** categorizeRules — separate each/nonEach rules, detect mixed gate conflicts */ +/** categorizeRules — separate each/nonEach rules, detect mixed gate conflicts (pure) */ 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. @@ -888,66 +343,6 @@ interface ResolvedTypeGate { 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 }); - } - } - - 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; @@ -960,1031 +355,1655 @@ interface TypeGateConfig { 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); - }; - +/** Generate nested-result handling for deserialize mode (pure) */ +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 canConvert = - enableConversion && - (effectiveGateType === 'string' || - effectiveGateType === 'number' || - 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(' '); - } 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 { - code += `if (${gateCondition}) ${gateEmitCtx.fail(gateErrorCode)};\n`; - code += `else {\n`; - if (ctx.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 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; + 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` + ); } -/** 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 = ''; - +/** Generate validate-mode nested result handling (null check instead of isErr) (pure) */ +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) { - if (generalRules.length === 0) { - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + 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` + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DeserializeBuilder — new Function-based executor generation (§4.9) +// ───────────────────────────────────────────────────────────────────────────── + +type DeserializeExecutor = (input: unknown, opts?: RuntimeOptions) => Result | ResultAsync; +type ValidateExecutor = (input: unknown, opts?: RuntimeOptions) => BakerIssue[] | null | Promise; + +/** + * 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[]; + + /** 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, + ) { + 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; + this.exposeDefaultValues = options?.exposeDefaultValues ?? false; + + this.regexes = []; + this.refs = []; + this.execs = []; + } + + /** + * Create a CHILD builder for an inline-nested DTO. Shares the parent's reference arrays, + * `resolve`, `options`, `isAsync`, `inlineNestedClasses` set and circular-check flag; overrides + * `pathPrefix`/`varPrefix`/`inputExpr` and forces `exposeDefaultValues` off (inline nested + * doesn't use exposeDefaultValues). + */ + private createChild(pathPrefix: string, varPrefix: string, inputExpr: string): DeserializeBuilder { + const child = Object.create(DeserializeBuilder.prototype) as DeserializeBuilder & MutableBuilderState; + child.Class = this.Class; + child.merged = this.merged; + child.options = this.options; + child.needsCircularCheck = this.needsCircularCheck; + child.isAsync = this.isAsync; + child.resolve = this.resolve; + child.validateOnly = this.validateOnly; + child.stopAtFirstError = this.stopAtFirstError; + child.collectErrors = this.collectErrors; + // inline nested doesn't use exposeDefaultValues + child.exposeDefaultValues = false; + // Share reference arrays so executor ref indices stay identical. + child.regexes = this.regexes; + child.refs = this.refs; + child.execs = this.execs; + // Share the circular-tracking set (mutated in place during inline emission). + if (this.inlineNestedClasses) { + child.inlineNestedClasses = this.inlineNestedClasses; + } + child.pathPrefix = pathPrefix; + child.varPrefix = varPrefix; + child.inputExpr = inputExpr; + return child; + } + + // ── Entry point ──────────────────────────────────────────────────────────── + + 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})`; + + 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'; } - } 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`; + body += exposeDefaultValues ? `var ${GEN.out} = new _Cls();\n` : `var ${GEN.out} = Object.create(_Cls.prototype);\n`; } - } else { - code += emitRuleList(fieldKey, varName, generalRules, emitCtx, ctx, '', fieldGroups); - if (!ctx.validateOnly) { - code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + + // Error array (collectErrors mode) + if (collectErrors) { + body += `var ${GEN.errList} = [];\n`; } - } - return code; -} + // 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`; + } -/** 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; - } + // 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); + } + const allowedIdx = refs.length; + refs.push(allowedKeys); - // 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}`; + // 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`; } - 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; + // 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; + } + 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 (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)) { + body += this.generateFieldCode(fieldKey, meta); + } + + // ── 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`; + } + + // 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; } - return code; -} + // ── Field code generation ──────────────────────────────────────────────────── -/** 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 + private generateFieldCode(fieldKey: string, meta: RawPropertyMeta): string { + const { exposeDefaultValues } = this; + + // ⓪ 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 ''; + } + } + + // 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 ''; + } + + const varName = toVarName(fieldKey, this.varPrefix); + const extractKey = getDeserializeExtractKey(fieldKey, meta.expose); + const exposeGroups = getDeserializeExposeGroups(meta.expose); + const inputObj = this.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); + + let fieldCode = ''; + + // ① @ValidateIf guard + let validateIfIdx: number | null = null; + if (meta.flags.validateIf) { + validateIfIdx = this.refs.length; + this.refs.push(meta.flags.validateIf); } - 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})`; + // ③ 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 { - gateCondition = `typeof ${varName} !== '${resolved.effectiveGateType}'`; + // 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`; } - // Type gate fail — reflect message/context if typeAsserter rd exists - const gateEmitCtx = resolved.typeAsserter ? makeRuleEmitCtx(emitCtx, fieldKey, varName, resolved.typeAsserter, ctx) : emitCtx; + // 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'; + } - 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); - } + // inner content (extract + optional guard + validation + assign) + let innerCode = extractCode; - // Phase 4: Emit each rules - code += emitEachRules(fieldKey, varName, categorized.each, collectErrors, emitCtx, ctx, fieldGroups); + // ② null/undefined guard — @IsOptional, @IsNullable, @IsDefined combinations (§4.3, Phase5) + const useOptionalGuard = !!(meta.flags.isOptional && !meta.flags.isDefined); + const isNullable = meta.flags.isNullable === true; - return code; -} + const validationCode = this.generateValidationCode(fieldKey, varName, meta, emitCtx, exposeGroups); + const assignNull = this.validateOnly ? '' : `${GEN.out}[${JSON.stringify(fieldKey)}] = null;\n`; -// ───────────────────────────────────────────────────────────────────────────── -// generateCollectionCode — Map/Set auto conversion -// ───────────────────────────────────────────────────────────────────────────── + const guardKey = resolveGuardKey(isNullable, useOptionalGuard, meta.flags.isDefined ?? false); + innerCode += GUARD_STRATEGIES[guardKey]({ varName, emitCtx, assignNull, validationCode }); -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); + // ① @ValidateIf outer wrap + if (validateIfIdx !== null) { + fieldCode += fieldStart + `if (refs[${validateIfIdx}](${inputObj})) {\n` + innerCode + '}\n' + fieldEnd; + } else { + fieldCode += fieldStart + innerCode + fieldEnd; + } + + return fieldCode; } - let code = ''; + // ── Validation code generation — type guard + transform + validate + assign ── - if (collection === CollectionType.Set) { - // input: array → Set - code += `if (Array.isArray(${varName})) {\n`; + private generateValidationCode( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + fieldGroups?: string[], + ): string { + const { collectErrors } = this; - // array-level validation rules (e.g. arrayMinSize) - const nonEachRules = meta.validation.filter(rd => !rd.each); - code += emitRuleList(fieldKey, varName, nonEachRules, emitCtx, ctx, ' '); + let code = ''; - 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`; + // @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 = 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 { - 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}`); + 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`; + } } - 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`; - } - code += ` ${siVar}++;\n`; - code += ` }\n`; + // Collection (Map/Set) auto conversion + if (meta.type?.collection) { + code += this.validateOnly + ? this.generateCollectionCodeValidateOnly(fieldKey, varName, meta, emitCtx) + : this.generateCollectionCode(fieldKey, varName, meta, emitCtx); + return code; } - 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`; - } 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}`); + // @ValidateNested + @Type (§8.1) + if (meta.flags.validateNested && meta.type?.fn) { + code += this.validateOnly + ? this.generateNestedCodeValidateOnly(fieldKey, varName, meta, emitCtx) + : this.generateNestedCode(fieldKey, varName, meta, emitCtx); + return code; + } + + // 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`; } - code += ` } else { ${GEN.arr}${sk}.set(${kVar}, ${GEN.result}${sk}); }\n`; - 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`; + return code; } - code += `} else { ${emitCtx.fail('isObject')}; }\n`; - } + // Build validation with type gate + code += this.buildRulesCode(fieldKey, varName, meta.validation, collectErrors, emitCtx, meta, fieldGroups); - return code; -} + return code; + } -// ───────────────────────────────────────────────────────────────────────────── -// generateNestedCode — @ValidateNested + @Type (§8.1, §8.2) -// ───────────────────────────────────────────────────────────────────────────── + // ── 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 (context !== undefined) { + const ctxIdx = this.refs.length; + this.refs.push(context); + extra += `,context:refs[${ctxIdx}]`; + } + return extra; + } -function generateNestedCode( - fieldKey: string, - varName: string, - meta: RawPropertyMeta, - ctx: FieldCodeContext, - emitCtx: EmitContext, -): string { - const { collectErrors, execs } = ctx; + /** 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 (!meta.type) { - return `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\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); } - let code = ''; - const sk = sanitizeKey(fieldKey); + /** 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; + } + 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}}])`; + }, + }; + } - 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`; + 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 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 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`; } - 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`; + if (timeVar) { + code += `${indent}var ${timeVar} = ${varName}.getTime();\n`; } - } 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 ' : ''; - 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 += 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`; + 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: { 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 { - 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}`); + emitted = rd.rule.emit(varName, gatedCtx); } - code += ` } else { ${GEN.arr}${sk}.push(${GEN.result}${sk}); }\n`; - code += ` }\n`; - code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; - code += `} else { ${emitCtx.fail('isArray')}; }\n`; - } else { - const awaitKwS = ctx.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, ctx.pathPrefix); - code += `} else { ${emitCtx.fail('isObject')}; }\n`; + 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'; } + + return code; } - return code; -} + // ── buildRulesCode — type guard + marker pattern (§4.3, §4.10) ── + // Decomposed into: categorizeRules → resolveTypeGate → emitTypedRules / emitGeneralRules / emitEachRules -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` - ); + /** resolveTypeGate — determine effective gate type from asserters/conversion/type hints */ + private resolveTypeGate(fieldKey: string, categorized: CategorizedRules, meta: RawPropertyMeta | undefined): 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 = !!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; + } + } + } + + 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, + }; } - 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` - ); -} -// ───────────────────────────────────────────────────────────────────────────── -// generateNestedCodeValidateOnly — validate-only nested (inline when possible) -// ───────────────────────────────────────────────────────────────────────────── + /** 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 = 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' || + effectiveGateType === 'number' || + 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 (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 { + 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`; + } + } 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 (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + } else { + code += `if (${gateCondition}) ${gateEmitCtx.fail(gateErrorCode)};\n`; + code += emitInnerRules(''); + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + } + } + + return code; + } -// 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. + /** emitGeneralRules — generate type-agnostic rule code */ + private emitGeneralRules( + fieldKey: string, + varName: string, + generalRules: RuleDef[], + collectErrors: boolean, + emitCtx: EmitContext, + fieldGroups?: string[], + ): string { + let 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, - }; + 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}${sanitizeKey(fieldKey)}`; + 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 { + code += this.emitRuleList(fieldKey, varName, generalRules, emitCtx, '', fieldGroups); + if (!this.validateOnly) { + code += `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } + } - let code = ''; - for (const [fieldKey, meta] of Object.entries(nestedMerged)) { - code += generateFieldCode(fieldKey, meta, inlineCtx); + return code; } - inlinedSet.delete(nestedClass); - return code; -} + /** 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; + } -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 = ''; + // 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 = 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 = this.computeRuleExtras(rd, fieldKey, varName); + // 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 += ' ' + 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; + } - // Initialize inline tracking set if not present - if (!ctx.inlineNestedClasses) { - ctx.inlineNestedClasses = new Set(); + return code; } - 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); + /** 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 { - 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); + gateErrorCode = 'conversionFailed'; // @Type hint only — no asserter or deps } - 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`; + + 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}'`; + } + + // 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 { - code += ` default: return [{path:${discPathExprV},code:'invalidDiscriminator',context:{received:${discValueExprV},validSubTypes:${validSubTypeNamesJsonV}}}];\n`; + code += this.emitGeneralRules(fieldKey, varName, categorized.generalRules, collectErrors, emitCtx, fieldGroups); + } + + // Phase 4: Emit each rules + code += this.emitEachRules(fieldKey, varName, categorized.each, collectErrors, emitCtx, fieldGroups); + + return code; + } + + // ── generateCollectionCode — Map/Set auto conversion ── + + private generateCollectionCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { + const { collectErrors, execs } = this; + const sk = sanitizeKey(fieldKey); + const collection = meta.type!.collection!; + const awaitKw = this.isAsync ? 'await ' : ''; + + // nested DTO executor (if present) + let execIdx = -1; + if (meta.type!.resolvedCollectionValue) { + const nestedSealed = this.resolve(meta.type!.resolvedCollectionValue) as SealedExecutors; + execIdx = execs.length; + execs.push(nestedSealed); } - 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}`; + + 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, ' '); - - 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 - 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`; + 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 += ` if (isErr(${GEN.result}${sk})) {\n`; if (collectErrors) { - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.result}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; + 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, - `${ppVar}+${GEN.result}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.result}${sk}[${GEN.nestedIdx}${sk}]`, + `__bk$pp${sk}+${GEN.errors}${sk}[${GEN.nestedIdx}${sk}].path`, + `${GEN.errors}${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 += ` 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 += ` }\n`; + 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 = this.computeRuleExtras(rd, fieldKey, varName); + 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`; + } + code += ` ${siVar}++;\n`; + 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. + // Map: input plain object → Map 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); + 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`; + } 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}`); + } + code += ` } else { ${GEN.arr}${sk}.set(${kVar}, ${GEN.result}${sk}); }\n`; + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\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); + // 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 += `} 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) -// ───────────────────────────────────────────────────────────────────────────── + // ── generateNestedCode — @ValidateNested + @Type (§8.1, §8.2) ── -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 ' : ''; + private generateNestedCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { + const { collectErrors, execs } = this; - if (!ctx.inlineNestedClasses) { - ctx.inlineNestedClasses = new Set(); - } + if (!meta.type) { + return `${GEN.out}[${JSON.stringify(fieldKey)}] = ${varName};\n`; + } - // 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; - } - const useInline = nestedCls && nestedMerged && !ctx.inlineNestedClasses.has(nestedCls); - - 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`; - } else { - code += `return [{path:${ppVar},code:'invalidInput'}];\n`; - } - code += ` else {\n`; - code += emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppVar, vpPrefix, ctx); - code += ` }\n`; - } else { + let code = ''; + const sk = sanitizeKey(fieldKey); + + 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 = this.resolve(sub.value) as SealedExecutors | undefined; 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`; + execs.push(nestedSealed as SealedExecutors); + 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) + if (meta.type.keepDiscriminatorProperty) { + const fkJson = JSON.stringify(fieldKey); + code += `{var __dh=${GEN.out}[${fkJson}]; if(__dh!=null) __dh[${discProp}]=${GEN.disc}${sk};}\n`; + } + } else { + // §8.1 simple nested or §8.2 each array + const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); + const nestedSealed = this.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 = 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 += ` if (isErr(${GEN.result}${sk})) {\n`; if (collectErrors) { - code += ` for (var ${GEN.nestedIdx}${sk}=0; ${GEN.nestedIdx}${sk}<${GEN.result}${sk}.length; ${GEN.nestedIdx}${sk}++) {\n`; + 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, - `${ppVar}+${GEN.result}${sk}[${GEN.nestedIdx}${sk}].path`, - `${GEN.result}${sk}[${GEN.nestedIdx}${sk}]`, + `__bk$pp${sk}+${GEN.errors}${sk}[${GEN.nestedIdx}${sk}].path`, + `${GEN.errors}${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 += ` 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 += ` }\n`; + code += ` } else { ${GEN.arr}${sk}.push(${GEN.result}${sk}); }\n`; + code += ` }\n`; + code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\n`; + code += `} else { ${emitCtx.fail('isArray')}; }\n`; + } else { + 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 += ` }\n`; + return code; + } + + // ── 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); + + const child = this.createChild(pathPrefixExpr, varPrefix, inputExpr); + + let code = ''; + for (const [fieldKey, meta] of Object.entries(nestedMerged)) { + code += child.generateFieldCode(fieldKey, meta); } - // 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`; + inlinedSet.delete(nestedClass); + return 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 = ''; + + // Initialize inline tracking set if not present + if (!this.inlineNestedClasses) { + this.inlineNestedClasses = new Set(); + } + + 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 = this.resolve(sub.value) as SealedExecutors; + 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 { + 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 += ` 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 = this.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 && !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. + // 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 = this.pathPrefix + ? `${this.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 += this.emitInlineNestedBlock(nestedMerged!, nestedCls, itemVar, ppExpr, vpPrefix); + code += ` }\n`; + } else { + // 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`; + code += ` if (${GEN.result}${sk} !== null) {\n`; + const ppVar = `__bk$pp${sk}`; + const ppInit = this.pathPrefix + ? `${this.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`; + } + + 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 += ` ${rd.rule.emit(`${varName}[${eiVar}]`, colEmitCtx)}\n`; + + code += `} else { ${emitCtx.fail('isObject')}; }\n`; } - code += ` }\n`; } + return code; + } - 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`; + // ── generateCollectionCodeValidateOnly — validate-only collection (no Set/Map creation) ── + + private generateCollectionCodeValidateOnly( + fieldKey: string, + varName: string, + meta: RawPropertyMeta, + emitCtx: EmitContext, + ): string { + const { collectErrors, execs } = this; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); + const collection = meta.type!.collection!; + const awaitKw = this.isAsync ? 'await ' : ''; + + if (!this.inlineNestedClasses) { + this.inlineNestedClasses = new Set(); + } + + // 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 = this.resolve(nestedCls) as SealedExecutors; + nestedMerged = nestedSealed.merged; + } + const useInline = nestedCls && nestedMerged && !this.inlineNestedClasses.has(nestedCls); + + 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) { + // 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 = this.pathPrefix + ? `${this.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`; + } else { + code += `return [{path:${ppVar},code:'invalidInput'}];\n`; + } + code += ` else {\n`; + code += this.emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppVar, 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`; + code += ` if (${GEN.result}${sk} !== null) {\n`; + const ppVar = `__bk$pp${sk}`; + const ppInit = this.pathPrefix + ? `${this.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`; } - 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 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 = this.computeRuleExtras(rd, fieldKey, varName); + 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 = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['` + : `${JSON.stringify(fieldKey)}+'['`; + code += ` var ${prefixVar} = ${prefixInit};\n`; + } + code += ` ${rd.rule.emit(`${varName}[${eiVar}]`, colEmitCtx)}\n`; + } + code += ` }\n`; + } + + 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`; + code += ` if (${GEN.result}${sk} !== null) {\n`; + const ppVar = `__bk$pp${sk}`; + const ppInit = this.pathPrefix + ? `${this.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`; + } else { + code += ` ` + nestedErrReturn(`${ppVar}+${GEN.result}${sk}[0].path`, `${GEN.result}${sk}[0]`, `__ne${sk}`, true); + } + code += ` }\n`; } - code += ` }\n`; + + code += ` }\n`; } - code += ` }\n`; + code += `} else { ${emitCtx.fail('isObject')}; }\n`; } - code += `} else { ${emitCtx.fail('isObject')}; }\n`; + 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, + }; } +} - return code; +/** Writable view of the builder's data fields — used to populate a child instance created via + * Object.create (bypassing the constructor so reference arrays can be shared). */ +interface MutableBuilderState { + Class: Function; + merged: RawClassMeta; + options: SealOptions | undefined; + needsCircularCheck: boolean; + isAsync: boolean; + resolve: (cls: Function) => SealedExecutors | undefined; + stopAtFirstError: boolean; + collectErrors: boolean; + exposeDefaultValues: boolean; + validateOnly: boolean; + regexes: RegExp[]; + refs: unknown[]; + execs: SealedExecutors[]; + inlineNestedClasses?: Set; + pathPrefix?: string; + varPrefix?: string; + inputExpr?: string; } // ───────────────────────────────────────────────────────────────────────────── -// 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/serialize-builder.ts b/src/seal/serialize-builder.ts index dd8b38c..c34e60a 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -31,7 +31,7 @@ const GEN = { } as const; // ───────────────────────────────────────────────────────────────────────────── -// Helpers +// Pure stateless helpers // ───────────────────────────────────────────────────────────────────────────── /** Determine the output key for serialize direction */ @@ -70,379 +70,400 @@ function getSerializeExposeGroups(exposeStack: RawPropertyMeta['expose']): strin 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; - } - - // 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; -} - -/** - * 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};` : ''; -} - // ───────────────────────────────────────────────────────────────────────────── -// buildSerializeCode — new Function-based serialize executor generation (§4.3 serialize pipeline) +// SerializeBuilder — new Function-based serialize executor generation (§4.3 serialize pipeline) // ───────────────────────────────────────────────────────────────────────────── /** - * Generate serialize executor code. - * Assumes no validation — always returns Record (§4.3). + * 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 (§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`; +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; } - for (const [fieldKey, meta] of Object.entries(merged)) { - body += generateSerializeFieldCode(fieldKey, meta, refs, execs, isAsync, resolve, options, Class.name); - } - - body += `return ${GEN.out};\n`; + /** Generate and instantiate the serialize executor. */ + build(): (instance: T, opts?: RuntimeOptions) => Record | Promise> { + // ── Code generation ──────────────────────────────────────────────────────── + + let body = "'use strict';\n"; + body += `var ${GEN.out} = {};\n`; + + // Groups variable — only when fields referencing groups exist + const hasGroupsField = Object.values(this.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`; + } - // 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`; + for (const [fieldKey, meta] of Object.entries(this.merged)) { + body += this.generateFieldCode(fieldKey, meta); + } - // ── Execute new Function ─────────────────────────────────────────────────── + body += `return ${GEN.out};\n`; - 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>; + // sourceURL (§4.9) + // 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`; - return executor; -} + // ── Execute new Function ─────────────────────────────────────────────────── -// ───────────────────────────────────────────────────────────────────────────── -// Per-field serialize code generation -// ───────────────────────────────────────────────────────────────────────────── + 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>; -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) { - if (options?.debug) { - const reason = meta.exclude.serializeOnly ? 'serializeOnly' : 'bidirectional'; - return `// [baker] field ${JSON.stringify(fieldKey)} excluded (${reason} @Exclude)\n`; - } - return ''; - } + return executor; } - // 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 ''; - } + // ─────────────────────────────────────────────────────────────────────────── + // Per-field serialize code generation + // ─────────────────────────────────────────────────────────────────────────── - const outputKey = getSerializeOutputKey(fieldKey, meta.expose); - const exposeGroups = getSerializeExposeGroups(meta.expose); - const sk = sanitizeKey(fieldKey); - const fieldVal = `${GEN.fieldVal}${sk}`; + private generateFieldCode(fieldKey: string, meta: RawPropertyMeta): string { + const className = this.Class.name; + const options = this.options; - let fieldCode = ''; - fieldCode += `var ${fieldVal} = instance[${JSON.stringify(fieldKey)}];\n`; + // ⓪ 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 ''; + } + } - // 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'; - } + // 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 ''; + } - let innerCode = ''; + const outputKey = getSerializeOutputKey(fieldKey, meta.expose); + const exposeGroups = getSerializeExposeGroups(meta.expose); + const sk = sanitizeKey(fieldKey); + const fieldVal = `${GEN.fieldVal}${sk}`; - // ② @IsOptional → skip output if undefined (§4.3 serialize step 2) - const useOptionalGuard = meta.flags.isOptional; + let fieldCode = ''; + fieldCode += `var ${fieldVal} = instance[${JSON.stringify(fieldKey)}];\n`; - // Collect serialize-direction transforms once - const serTransforms = meta.transform.filter(td => !td.options?.deserializeOnly); + // 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'; + } - // ③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 (§4.3 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.resolve(meta.type.resolvedCollectionValue) as SealedExecutors; + const execIdx = this.execs.length; + this.execs.push(nestedSealed); + if (this.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); }`; + } 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};`; + } } 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}[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 = this.resolve(meta.type.resolvedCollectionValue) as SealedExecutors; + const execIdx = this.execs.length; + this.execs.push(nestedSealed); + const awaitKw = this.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};`; + } 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};`; + } } - } 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)}]`; + // ③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; + 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 + 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`; + // 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 = this.resolve(sub.value) as SealedExecutors; + 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} = ${awaitKw}execs[${execIdx}].serialize(${itemVar}, opts);\n`; + if (keepDisc) { + code += ` ${GEN.serResult}[${JSON.stringify(property)}] = ${JSON.stringify(sub.name)};\n`; + } + code += ` ${GEN.outItem} = ${GEN.serResult};\n`; + } + code += `} else { ${GEN.outItem} = ` + itemVar + '; }\n'; + return code; + }; + + if (hasEach) { + const awaitKw = this.isAsync ? 'await ' : ''; + if (this.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`; + } + nestedCode += ` var ${GEN.outItem};\n`; + nestedCode += buildInstanceofChain('__ser_item', awaitKw); + if (this.isAsync) { + nestedCode += ` return ${GEN.outItem};\n`; + nestedCode += `}));`; + } else { + nestedCode += ` ${GEN.discArr}.push(${GEN.outItem});\n`; + nestedCode += ` }\n`; + nestedCode += ` ${outputTarget} = ${GEN.discArr};`; } - 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};\n`; + nestedCode += buildInstanceofChain(fieldVal, awaitKw); + nestedCode += `${outputTarget} = ${GEN.outItem};`; } - nestedCode += ` var ${GEN.outItem};\n`; - nestedCode += buildInstanceofChain('__ser_item', awaitKw); - if (isAsync) { - nestedCode += ` return ${GEN.outItem};\n`; - nestedCode += `}));`; + } else { + // Existing simple nested logic + const nestedCls = meta.type!.resolvedClass ?? (meta.type!.fn() as Function); + const nestedSealed = this.resolve(nestedCls) as SealedExecutors; + 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} = [];\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};`; + } } 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 { + const refs = this.refs; + 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'; + 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; + } + + // 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; } - 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 (§4.3). */ -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 }; From b13dd4544a17a639757aed965f66184a9eebce38 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 22:50:54 +0900 Subject: [PATCH 10/31] refactor: relocate rule machinery into rules/ (create-rule, rule-plan, rule-metadata) git mv create-rule.ts/rule-plan.ts/rule-metadata.ts (+create-rule.spec) into src/rules/ and repoint importers (rules/* -> ./rule-plan, seal/deserialize-builder -> ../rules/rule-plan, index.ts public createRule -> ./src/rules/create-rule, root specs). Verbatim moves. tsc 0, 2350 pass/0 fail, codegen snapshot unchanged, deps/knip/lint clean, build ok. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.ts | 2 +- src/error-system.spec.ts | 2 +- src/rules/array.ts | 2 +- src/rules/binary.ts | 2 +- src/rules/combinators.spec.ts | 2 +- src/rules/combinators.ts | 2 +- src/rules/common.ts | 2 +- src/{ => rules}/create-rule.spec.ts | 2 +- src/{ => rules}/create-rule.ts | 8 ++++---- src/rules/date.ts | 2 +- src/rules/locales.ts | 2 +- src/rules/number.ts | 2 +- src/rules/object.ts | 2 +- src/{ => rules}/rule-metadata.ts | 2 +- src/{ => rules}/rule-plan.ts | 6 +++--- src/rules/string.ts | 2 +- src/rules/typechecker.ts | 2 +- src/seal/deserialize-builder.ts | 2 +- 18 files changed, 23 insertions(+), 23 deletions(-) rename src/{ => rules}/create-rule.spec.ts (99%) rename src/{ => rules}/create-rule.ts (95%) rename src/{ => rules}/rule-metadata.ts (93%) rename src/{ => rules}/rule-plan.ts (96%) diff --git a/index.ts b/index.ts index be3c399..0bf1172 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ // Public API — Core -export { createRule } from './src/create-rule'; +export { createRule } from './src/rules/create-rule'; // Decorators export { Field, arrayOf } from './src/decorators/index'; diff --git a/src/error-system.spec.ts b/src/error-system.spec.ts index a547c18..8f7233c 100644 --- a/src/error-system.spec.ts +++ b/src/error-system.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import { createRule } from './create-rule'; +import { createRule } from './rules/create-rule'; import { BakerError } from './common/errors'; import { isPassportNumber } from './rules/locales'; import { isDivisibleBy, max, min } from './rules/number'; diff --git a/src/rules/array.ts b/src/rules/array.ts index 76dc0b7..2feb844 100644 --- a/src/rules/array.ts +++ b/src/rules/array.ts @@ -2,7 +2,7 @@ import type { EmitContext, EmittableRule } from './types'; import { CacheKey } from '../common/enums'; import { RequiredType, RuleOp } from './enums'; -import { makePlannedRule, makeRule, planCompare, planLength } from '../rule-plan'; +import { makePlannedRule, makeRule, planCompare, planLength } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // arrayContains(values) — array contains all specified values diff --git a/src/rules/binary.ts b/src/rules/binary.ts index 5d93edd..ab0d10c 100644 --- a/src/rules/binary.ts +++ b/src/rules/binary.ts @@ -1,6 +1,6 @@ import type { EmitContext, EmittableRule } from './types'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // isUint8Array — instanceof guard (self-narrowing, no typeof gate; mirrors isRegExp) diff --git a/src/rules/combinators.spec.ts b/src/rules/combinators.spec.ts index dc6cf6c..fe33652 100644 --- a/src/rules/combinators.spec.ts +++ b/src/rules/combinators.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, mock } from 'bun:test'; import type { EmitContext } from './types'; -import { createRule } from '../create-rule'; +import { createRule } from './create-rule'; import { oneOf, arrayEvery } from './combinators'; import { isString, isBoolean, isNumber } from './typechecker'; diff --git a/src/rules/combinators.ts b/src/rules/combinators.ts index 505b3c4..b20adcf 100644 --- a/src/rules/combinators.ts +++ b/src/rules/combinators.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from './types'; import { BakerError } from '../common/errors'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // Helpers diff --git a/src/rules/common.ts b/src/rules/common.ts index ab80708..552f0c1 100644 --- a/src/rules/common.ts +++ b/src/rules/common.ts @@ -1,6 +1,6 @@ import type { EmitContext, EmittableRule } from './types'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // equals — strict equality (===). comparison value passed via refs (§4.8 C) 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 1b9ce71..45d8c6e 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 './rules/types'; +import type { EmitContext } from './types'; 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 8e88af1..122464c 100644 --- a/src/create-rule.ts +++ b/src/rules/create-rule.ts @@ -1,9 +1,9 @@ -import type { RequiredType } from './rules/enums'; -import type { EmittableRule, EmitContext, InternalRule } from './rules/types'; +import type { RequiredType } from './enums'; +import type { EmittableRule, EmitContext, InternalRule } from './types'; -import { BakerError } from './common/errors'; +import { BakerError } from '../common/errors'; import { defineRuleMetadata } from './rule-metadata'; -import { isAsyncFunction, isPromiseLike } from './common/utils'; +import { isAsyncFunction, isPromiseLike } from '../common/utils'; // ───────────────────────────────────────────────────────────────────────────── // createRule — Custom validation rule creation Public API (§1.1) diff --git a/src/rules/date.ts b/src/rules/date.ts index ca5d926..0cbb953 100644 --- a/src/rules/date.ts +++ b/src/rules/date.ts @@ -2,7 +2,7 @@ import type { EmittableRule } from './types'; import { CacheKey } from '../common/enums'; import { RequiredType, RuleOp } from './enums'; -import { makePlannedRule, planCompare, planLiteral, planOr, planTime } from '../rule-plan'; +import { makePlannedRule, planCompare, planLiteral, planOr, planTime } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // minDate — v >= date (inclusive, getTime comparison). (§4.8 C — refs function call) diff --git a/src/rules/locales.ts b/src/rules/locales.ts index ce923b1..5ecca17 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -2,7 +2,7 @@ import type { EmitContext, EmittableRule } from './types'; import { RequiredType } from './enums'; import { BakerError } from '../common/errors'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // Locale-specific Validators diff --git a/src/rules/number.ts b/src/rules/number.ts index c36ba14..a680dba 100644 --- a/src/rules/number.ts +++ b/src/rules/number.ts @@ -2,7 +2,7 @@ import type { EmitContext, EmittableRule } from './types'; import { RequiredType, RuleOp } from './enums'; import { BakerError } from '../common/errors'; -import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from '../rule-plan'; +import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // min — v >= n check. requiresType='number' (§4.7, §4.8 A) diff --git a/src/rules/object.ts b/src/rules/object.ts index c10fd52..1ba039e 100644 --- a/src/rules/object.ts +++ b/src/rules/object.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from './types'; import { RequiredType } from './enums'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // isNotEmptyObject(options?) — not an empty object (at least 1 key) diff --git a/src/rule-metadata.ts b/src/rules/rule-metadata.ts similarity index 93% rename from src/rule-metadata.ts rename to src/rules/rule-metadata.ts index 822d105..0d1f7c7 100644 --- a/src/rule-metadata.ts +++ b/src/rules/rule-metadata.ts @@ -1,4 +1,4 @@ -import type { EmittableRule, InternalRule, RulePlan } from './rules/types'; +import type { EmittableRule, InternalRule, RulePlan } from './types'; // Type boundary — the single place that brands a bare validator function with // the readonly metadata properties declared on InternalRule. All other modules diff --git a/src/rule-plan.ts b/src/rules/rule-plan.ts similarity index 96% rename from src/rule-plan.ts rename to src/rules/rule-plan.ts index 357cd49..2fd1698 100644 --- a/src/rule-plan.ts +++ b/src/rules/rule-plan.ts @@ -1,7 +1,7 @@ -import type { RequiredType } from './rules/enums'; -import type { EmitContext, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './rules/types'; +import type { RequiredType } from './enums'; +import type { EmitContext, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './types'; -import { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './rules/enums'; +import { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; import { defineRuleMetadata } from './rule-metadata'; type RulePlanCache = { diff --git a/src/rules/string.ts b/src/rules/string.ts index 54ba141..4b38294 100644 --- a/src/rules/string.ts +++ b/src/rules/string.ts @@ -2,7 +2,7 @@ import type { EmitContext, EmittableRule } from './types'; import { CacheKey } from '../common/enums'; import { RequiredType, RuleOp } from './enums'; -import { makePlannedRule, makeRule, planCompare, planLength, planOr } from '../rule-plan'; +import { makePlannedRule, makeRule, planCompare, planLength, planOr } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // Helpers diff --git a/src/rules/typechecker.ts b/src/rules/typechecker.ts index 345b744..892c938 100644 --- a/src/rules/typechecker.ts +++ b/src/rules/typechecker.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from './types'; import { RequiredType } from './enums'; -import { makeRule } from '../rule-plan'; +import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── // isString — typeof check (§4.8 A: operator inline) diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index fb391fb..53c6564 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -11,7 +11,7 @@ import type { SealedExecutors } from './types'; import { CacheKey } from '../common/enums'; import { CollectionType } from '../metadata/enums'; import { BakerError, type BakerIssue } from '../common/errors'; -import { emitRulePlan } from '../rule-plan'; +import { emitRulePlan } from '../rules/rule-plan'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; import { GuardKey } from './enums'; From 3ba09ead46394fb3b51127a5c8140cddde2e29b1 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Fri, 19 Jun 2026 23:03:33 +0900 Subject: [PATCH 11/31] refactor: per-directory barrels + strict exports (Phase F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add index.ts barrels for common/metadata/config/seal/runtime (strict named exports, only what's consumed cross-dir — knip-clean). Route cross-directory imports through the barrels (22 files). One import kept deep by design: rules/types -> seal/types (SealedExecutors), the documented type-only visitor edge — barreling it would risk a runtime cycle. Update REFACTORING.md status (A-F done; E string-split deferred). tsc 0, 2350 pass/0 fail, codegen snapshot unchanged, deps:check clean (no cycles), knip/lint clean, build ok, memory pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- REFACTORING.md | 31 +++++++++++++++++--------- index.ts | 8 +++---- src/baker.ts | 29 +++++++++++++++--------- src/common/index.ts | 7 ++++++ src/config/configure.ts | 4 ++-- src/config/index.ts | 3 +++ src/decorators/field.ts | 10 ++++----- src/metadata/index.ts | 13 +++++++++++ src/metadata/types.ts | 2 +- src/rules/array.ts | 2 +- src/rules/combinators.ts | 2 +- src/rules/create-rule.ts | 3 +-- src/rules/date.ts | 2 +- src/rules/locales.ts | 2 +- src/rules/number.ts | 2 +- src/rules/string.ts | 2 +- src/rules/types.ts | 4 +++- src/runtime/check-call-options.ts | 4 ++-- src/runtime/deserialize.ts | 6 ++--- src/runtime/index.ts | 4 ++++ src/runtime/serialize.ts | 6 ++--- src/runtime/validate.ts | 7 +++--- src/seal/async-analysis.ts | 5 ++--- src/seal/circular-analyzer.ts | 4 ++-- src/seal/circular-placeholder.ts | 2 +- src/seal/deserialize-builder.ts | 9 ++++---- src/seal/expose-validator.ts | 5 ++--- src/seal/index.ts | 4 ++++ src/seal/merge-inheritance.ts | 4 ++-- src/seal/seal.ts | 7 +++--- src/seal/serialize-builder.ts | 8 +++---- src/seal/types.ts | 5 ++--- src/seal/validate-meta.ts | 7 +++--- src/transformers/luxon.transformer.ts | 2 +- src/transformers/moment.transformer.ts | 2 +- 35 files changed, 128 insertions(+), 89 deletions(-) create mode 100644 src/common/index.ts create mode 100644 src/config/index.ts create mode 100644 src/metadata/index.ts create mode 100644 src/runtime/index.ts create mode 100644 src/seal/index.ts diff --git a/REFACTORING.md b/REFACTORING.md index daed274..f8eb467 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -185,17 +185,26 @@ invariant, which is about the body.) Land it as its own commit before any seal/ builder code is moved (Phases C/D feed/own codegen), so drift is machine-checked every commit. Phases A/B don't touch codegen but the harness should exist before C. -## Execution order (each step = one commit; `tsc` + suite green; codegen byte-identical) -1. ~~P0 enums~~, ~~P1 Baker/runtime/cache~~ (DONE). -2. **A** — extract `compile-cache.ts` (safest, spec-backed first win). -3. **snapshot harness** (machine-check codegen byte-identity). -4. **B** — skeleton: `functions/`→`runtime/`, create `common/` + `metadata/` + `config/`, move substrate. -5. **C** — dissolve `types/enums/interfaces` into owning domains; extract seal analysis modules. -6. **D** — builder decomposition (leaf modules verbatim, then `emitField` cycle-break as its own commit). -7. **F** — barrels/exports close-out + de-dupe nit. -8. **E** — string.ts split (deferred/last/optional). - -Each phase independently revertible; regressions isolate to one layer. +## Execution order + STATUS (each step = one commit; `tsc` + suite green; codegen byte-identical) +1. ~~P0 enums~~, ~~P1 Baker/runtime/cache~~ — **DONE** (5.0/5.1). +2. ~~**A** — extract `compile-cache.ts`~~ — **DONE**. +3. ~~**snapshot harness** (machine-check codegen byte-identity)~~ — **DONE** (15 snapshots). +4. ~~**B** — skeleton: `functions/`→`runtime/`, create `common/`+`metadata/`+`config/`, move substrate~~ — **DONE**. +5. ~~**C** — C1 dissolve `types/enums/interfaces` into owning domains (incl. shims then delete); C2 extract + `async-analysis`/`merge-inheritance`/`circular-placeholder`/`constants` from seal.ts~~ — **DONE**. +6. ~~**D** — builders → `DeserializeBuilder`/`SerializeBuilder` CLASSES (state as fields, methods; no + ctx-threading / fragment re-return / cycle-break callback; inline-nested = child builder). Byte-identical~~ — **DONE**. + ~~Plus: relocate rule machinery (`create-rule`/`rule-plan`/`rule-metadata`) into `rules/`~~ — **DONE**. +7. ~~**F** — per-directory barrels (`common`/`metadata`/`config`/`seal`/`runtime` index.ts) + strict + exports; cross-dir imports routed through barrels (one documented deep edge: `rules/types → + seal/types` for `SealedExecutors`)~~ — **DONE**. +8. **E** — `string.ts` split — **DEFERRED** (2525 lines but flat/low-coupling/low-value; behind a pure + re-export barrel keeping `./rules` stable; do only if file size becomes a real maintenance pain). + +Result so far: `src/` root holds only `baker.ts` (composition root) + `symbols.ts` (pinned). All other code +lives in its domain (`common/ metadata/ config/ rules/ transformers/ seal/ runtime/`). The builders are +classes. Junk-drawer `types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic (one documented type-only +`rules → seal` edge). Each phase independently revertible; regressions isolate to one layer. --- diff --git a/index.ts b/index.ts index 0bf1172..9e69e1c 100644 --- a/index.ts +++ b/index.ts @@ -13,13 +13,13 @@ export { ExcludeMode } from './src/decorators/enums'; export { RequiredType } from './src/rules/enums'; // Errors -export type { BakerIssue, BakerIssueSet } from './src/common/errors'; -export { isBakerIssueSet, BakerError } from './src/common/errors'; +export type { BakerIssue, BakerIssueSet } from './src/common'; +export { isBakerIssueSet, BakerError } from './src/common'; // Types export type { EmittableRule } from './src/rules/types'; export type { Transformer, TransformParams } from './src/transformers/types'; -export type { BakerConfig } from './src/config/configure'; +export type { BakerConfig } from './src/config'; // Interfaces / Options -export type { RuntimeOptions } from './src/common/interfaces'; +export type { RuntimeOptions } from './src/common'; diff --git a/src/baker.ts b/src/baker.ts index cbb576f..fd550d8 100644 --- a/src/baker.ts +++ b/src/baker.ts @@ -1,15 +1,22 @@ -import type { BakerConfig } from './config/configure'; -import type { BakerIssueSet } from './common/errors'; -import type { RuntimeOptions } from './common/interfaces'; -import type { SealOptions } from './seal/interfaces'; -import type { SealedExecutors } from './seal/types'; +import type { BakerConfig } from './config'; +import type { BakerIssueSet, RuntimeOptions } from './common'; +import type { SealOptions, SealedExecutors } from './seal'; -import { normalizeConfig } from './config/configure'; -import { BakerError } from './common/errors'; -import { runDeserialize, runDeserializeSync, runDeserializeAsync } from './runtime/deserialize'; -import { resolveSerializeClass, runSerialize, runSerializeSync, runSerializeAsync } from './runtime/serialize'; -import { runValidate, runValidateSync, runValidateAsync } from './runtime/validate'; -import { sealRegistry } from './seal/seal'; +import { normalizeConfig } from './config'; +import { BakerError } from './common'; +import { sealRegistry } from './seal'; +import { + runDeserialize, + runDeserializeSync, + runDeserializeAsync, + resolveSerializeClass, + runSerialize, + runSerializeSync, + runSerializeAsync, + runValidate, + runValidateSync, + runValidateAsync, +} from './runtime'; /** * A baker — an isolated registration + seal + runtime boundary. Each `new Baker()` owns its own 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/config/configure.ts b/src/config/configure.ts index fb80ac7..523855f 100644 --- a/src/config/configure.ts +++ b/src/config/configure.ts @@ -1,6 +1,6 @@ -import type { SealOptions } from '../seal/interfaces'; +import type { SealOptions } from '../seal'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; // ───────────────────────────────────────────────────────────────────────────── // BakerConfig — per-Baker configuration (passed to `new Baker(config)`) diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..3e9e729 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,3 @@ +// Directory barrel — config normalization (BakerConfig → SealOptions). +export { normalizeConfig } from './configure'; +export type { BakerConfig } from './configure'; diff --git a/src/decorators/field.ts b/src/decorators/field.ts index d7fa77c..7ce4b9a 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,13 +1,11 @@ -import type { ClassCtor } from '../common/types'; +import type { ClassCtor } from '../common'; import type { EmittableRule, InternalRule } from '../rules/types'; -import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata/types'; +import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata'; import type { Transformer } from '../transformers/types'; -import { ensureMeta } from '../metadata/collect'; -import { Direction } from '../common/enums'; +import { Direction, BakerError, isAsyncFunction, isPromiseLike } from '../common'; +import { ensureMeta } from '../metadata'; import { ExcludeMode } from './enums'; -import { BakerError } from '../common/errors'; -import { isAsyncFunction, isPromiseLike } from '../common/utils'; // ───────────────────────────────────────────────────────────────────────────── // arrayOf — Array element validation marker (replaces each: true) diff --git a/src/metadata/index.ts b/src/metadata/index.ts new file mode 100644 index 0000000..4fd540c --- /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, +} from './types'; +export { CollectionType } from './enums'; +export { deleteRaw, getRaw, requireRaw, setRaw, hasRawOwn } from './meta-access'; +export { ensureMeta } from './collect'; diff --git a/src/metadata/types.ts b/src/metadata/types.ts index dcc896b..591fddd 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -1,4 +1,4 @@ -import type { ClassCtor } from '../common/types'; +import type { ClassCtor } from '../common'; import type { InternalRule } from '../rules/types'; import type { TransformFunction } from '../transformers/types'; import type { CollectionType } from './enums'; diff --git a/src/rules/array.ts b/src/rules/array.ts index 2feb844..810a380 100644 --- a/src/rules/array.ts +++ b/src/rules/array.ts @@ -1,6 +1,6 @@ import type { EmitContext, EmittableRule } from './types'; -import { CacheKey } from '../common/enums'; +import { CacheKey } from '../common'; import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, makeRule, planCompare, planLength } from './rule-plan'; diff --git a/src/rules/combinators.ts b/src/rules/combinators.ts index b20adcf..54dac9c 100644 --- a/src/rules/combinators.ts +++ b/src/rules/combinators.ts @@ -1,6 +1,6 @@ import type { EmitContext, EmittableRule } from './types'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/create-rule.ts b/src/rules/create-rule.ts index 122464c..7378b73 100644 --- a/src/rules/create-rule.ts +++ b/src/rules/create-rule.ts @@ -1,9 +1,8 @@ import type { RequiredType } from './enums'; import type { EmittableRule, EmitContext, InternalRule } from './types'; -import { BakerError } from '../common/errors'; +import { BakerError, isAsyncFunction, isPromiseLike } from '../common'; import { defineRuleMetadata } from './rule-metadata'; -import { isAsyncFunction, isPromiseLike } from '../common/utils'; // ───────────────────────────────────────────────────────────────────────────── // createRule — Custom validation rule creation Public API (§1.1) diff --git a/src/rules/date.ts b/src/rules/date.ts index 0cbb953..a7168ea 100644 --- a/src/rules/date.ts +++ b/src/rules/date.ts @@ -1,6 +1,6 @@ import type { EmittableRule } from './types'; -import { CacheKey } from '../common/enums'; +import { CacheKey } from '../common'; import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, planCompare, planLiteral, planOr, planTime } from './rule-plan'; diff --git a/src/rules/locales.ts b/src/rules/locales.ts index 5ecca17..23ef874 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from './types'; import { RequiredType } from './enums'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/number.ts b/src/rules/number.ts index a680dba..7cbda0f 100644 --- a/src/rules/number.ts +++ b/src/rules/number.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from './types'; import { RequiredType, RuleOp } from './enums'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/string.ts b/src/rules/string.ts index 4b38294..1c11e9a 100644 --- a/src/rules/string.ts +++ b/src/rules/string.ts @@ -1,6 +1,6 @@ import type { EmitContext, EmittableRule } from './types'; -import { CacheKey } from '../common/enums'; +import { CacheKey } from '../common'; import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, makeRule, planCompare, planLength, planOr } from './rule-plan'; diff --git a/src/rules/types.ts b/src/rules/types.ts index 732b1f1..28d1e22 100644 --- a/src/rules/types.ts +++ b/src/rules/types.ts @@ -1,4 +1,6 @@ -import type { CacheKey } from '../common/enums'; +import type { CacheKey } from '../common'; +// Documented single upward type-only edge `rules → seal` (visitor: EmitContext.addExecutor). +// Kept as a deep import (not via `../seal` barrel) to avoid a runtime cycle through seal. import type { SealedExecutors } from '../seal/types'; import type { RuleOp, RulePlanCheckKind, RulePlanExprKind, RequiredType } from './enums'; diff --git a/src/runtime/check-call-options.ts b/src/runtime/check-call-options.ts index af6369f..7327395 100644 --- a/src/runtime/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -1,6 +1,6 @@ -import type { RuntimeOptions } from '../common/interfaces'; +import type { RuntimeOptions } from '../common'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; const CALL_OPTION_KEYS = new Set(['groups']); const SEAL_TIME_KEYS = new Set([ diff --git a/src/runtime/deserialize.ts b/src/runtime/deserialize.ts index 0df4b14..bafc90f 100644 --- a/src/runtime/deserialize.ts +++ b/src/runtime/deserialize.ts @@ -1,9 +1,9 @@ import { isErr } from '@zipbul/result'; -import type { RuntimeOptions } from '../common/interfaces'; -import type { SealedExecutors } from '../seal/types'; +import type { RuntimeOptions, BakerIssue, BakerIssueSet } from '../common'; +import type { SealedExecutors } from '../seal'; -import { toBakerIssueSet, BakerError, type BakerIssue, type BakerIssueSet } from '../common/errors'; +import { toBakerIssueSet, BakerError } from '../common'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── 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/runtime/serialize.ts b/src/runtime/serialize.ts index e8900b3..674d712 100644 --- a/src/runtime/serialize.ts +++ b/src/runtime/serialize.ts @@ -1,7 +1,7 @@ -import type { RuntimeOptions } from '../common/interfaces'; -import type { SealedExecutors } from '../seal/types'; +import type { RuntimeOptions } from '../common'; +import type { SealedExecutors } from '../seal'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/validate.ts b/src/runtime/validate.ts index c3ba59a..3f85bd8 100644 --- a/src/runtime/validate.ts +++ b/src/runtime/validate.ts @@ -1,8 +1,7 @@ -import type { BakerIssue, BakerIssueSet } from '../common/errors'; -import type { RuntimeOptions } from '../common/interfaces'; -import type { SealedExecutors } from '../seal/types'; +import type { BakerIssue, BakerIssueSet, RuntimeOptions } from '../common'; +import type { SealedExecutors } from '../seal'; -import { toBakerIssueSet, BakerError } from '../common/errors'; +import { toBakerIssueSet, BakerError } from '../common'; import { checkCallOptions } from './check-call-options'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/async-analysis.ts b/src/seal/async-analysis.ts index 7f27d46..bfab47c 100644 --- a/src/seal/async-analysis.ts +++ b/src/seal/async-analysis.ts @@ -1,8 +1,7 @@ -import type { RawClassMeta, RawPropertyMeta } from '../metadata/types'; +import type { RawClassMeta, RawPropertyMeta } from '../metadata'; import type { SealedExecutors } from './types'; -import { Direction } from '../common/enums'; -import { isAsyncFunction } from '../common/utils'; +import { Direction, isAsyncFunction } from '../common'; import { PRIMITIVE_CTORS } from './constants'; import { mergeInheritance } from './merge-inheritance'; diff --git a/src/seal/circular-analyzer.ts b/src/seal/circular-analyzer.ts index a87f602..8834cf5 100644 --- a/src/seal/circular-analyzer.ts +++ b/src/seal/circular-analyzer.ts @@ -1,5 +1,5 @@ -import { BakerError } from '../common/errors'; -import { getRaw } from '../metadata/meta-access'; +import { BakerError } from '../common'; +import { getRaw } from '../metadata'; /** * Static analysis for circular references (§4.6) diff --git a/src/seal/circular-placeholder.ts b/src/seal/circular-placeholder.ts index fd135d7..ed33294 100644 --- a/src/seal/circular-placeholder.ts +++ b/src/seal/circular-placeholder.ts @@ -1,6 +1,6 @@ import type { SealedExecutors } from './types'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; /** @internal Placeholder executor for circular dependency detection during seal */ export function circularPlaceholder(className: string): SealedExecutors { diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 53c6564..b4fefa1 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -2,15 +2,14 @@ import type { Result, ResultAsync } from '@zipbul/result'; import { err as resultErr, isErr as resultIsErr } from '@zipbul/result'; -import type { RuntimeOptions } from '../common/interfaces'; +import type { RuntimeOptions, BakerIssue } from '../common'; import type { SealOptions } from './interfaces'; -import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata/types'; +import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata'; import type { EmitContext } from '../rules/types'; import type { SealedExecutors } from './types'; -import { CacheKey } from '../common/enums'; -import { CollectionType } from '../metadata/enums'; -import { BakerError, type BakerIssue } from '../common/errors'; +import { CacheKey, BakerError } from '../common'; +import { CollectionType } from '../metadata'; import { emitRulePlan } from '../rules/rule-plan'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; import { GuardKey } from './enums'; diff --git a/src/seal/expose-validator.ts b/src/seal/expose-validator.ts index f70f2d1..7798781 100644 --- a/src/seal/expose-validator.ts +++ b/src/seal/expose-validator.ts @@ -1,7 +1,6 @@ -import type { RawClassMeta, ExposeDef } from '../metadata/types'; +import type { RawClassMeta, ExposeDef } from '../metadata'; -import { Direction } from '../common/enums'; -import { BakerError } from '../common/errors'; +import { Direction, BakerError } from '../common'; /** * Static validation of @Expose stacks (§4.1, §3.3) diff --git a/src/seal/index.ts b/src/seal/index.ts new file mode 100644 index 0000000..f19e14f --- /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 } from './types'; +export type { SealOptions } from './interfaces'; +export { sealRegistry } from './seal'; diff --git a/src/seal/merge-inheritance.ts b/src/seal/merge-inheritance.ts index c1c36d9..ceb7307 100644 --- a/src/seal/merge-inheritance.ts +++ b/src/seal/merge-inheritance.ts @@ -1,6 +1,6 @@ -import type { RawClassMeta } from '../metadata/types'; +import type { RawClassMeta } from '../metadata'; -import { getRaw, hasRawOwn } from '../metadata/meta-access'; +import { getRaw, hasRawOwn } from '../metadata'; // ───────────────────────────────────────────────────────────────────────────── // mergeInheritance() — merge inheritance metadata (§4.2) diff --git a/src/seal/seal.ts b/src/seal/seal.ts index b34c3b1..b7a4517 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -1,10 +1,9 @@ import type { SealOptions } from './interfaces'; -import type { ClassCtor } from '../common/types'; +import type { ClassCtor } from '../common'; import type { SealedExecutors } from './types'; -import { CollectionType } from '../metadata/enums'; -import { Direction } from '../common/enums'; -import { BakerError } from '../common/errors'; +import { CollectionType } from '../metadata'; +import { Direction, BakerError } from '../common'; import { analyzeAsync, nestedClassesOf } from './async-analysis'; import { analyzeCircular } from './circular-analyzer'; import { circularPlaceholder } from './circular-placeholder'; diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index c34e60a..2ec0ccf 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -1,10 +1,10 @@ -import type { RuntimeOptions } from '../common/interfaces'; +import type { RuntimeOptions } from '../common'; import type { SealOptions } from './interfaces'; -import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata/types'; +import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata'; import type { SealedExecutors } from './types'; -import { CollectionType } from '../metadata/enums'; -import { BakerError } from '../common/errors'; +import { CollectionType } from '../metadata'; +import { BakerError } from '../common'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/types.ts b/src/seal/types.ts index 9ddd812..aa092db 100644 --- a/src/seal/types.ts +++ b/src/seal/types.ts @@ -1,8 +1,7 @@ import type { Result, ResultAsync } from '@zipbul/result'; -import type { BakerIssue } from '../common/errors'; -import type { RuntimeOptions } from '../common/interfaces'; -import type { RawClassMeta } from '../metadata/types'; +import type { BakerIssue, RuntimeOptions } from '../common'; +import type { RawClassMeta } from '../metadata'; // ───────────────────────────────────────────────────────────────────────────── // SealedExecutors — Dual executor stored in the Baker's per-instance executor map (§2.1) diff --git a/src/seal/validate-meta.ts b/src/seal/validate-meta.ts index 845b5fe..327b910 100644 --- a/src/seal/validate-meta.ts +++ b/src/seal/validate-meta.ts @@ -1,8 +1,7 @@ -import type { RawClassMeta } from '../metadata/types'; +import type { RawClassMeta } from '../metadata'; -import { CollectionType } from '../metadata/enums'; -import { BakerError } from '../common/errors'; -import { hasRawOwn } from '../metadata/meta-access'; +import { CollectionType, hasRawOwn } from '../metadata'; +import { BakerError } from '../common'; /** * @internal — seal-time invariant checks invoked from sealOne after merge + type normalization, diff --git a/src/transformers/luxon.transformer.ts b/src/transformers/luxon.transformer.ts index be0499d..021eede 100644 --- a/src/transformers/luxon.transformer.ts +++ b/src/transformers/luxon.transformer.ts @@ -1,6 +1,6 @@ import type { Transformer } from './types'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; interface LuxonTransformerOptions { format?: string; diff --git a/src/transformers/moment.transformer.ts b/src/transformers/moment.transformer.ts index cc3eca5..ebedec9 100644 --- a/src/transformers/moment.transformer.ts +++ b/src/transformers/moment.transformer.ts @@ -1,6 +1,6 @@ import type { Transformer } from './types'; -import { BakerError } from '../common/errors'; +import { BakerError } from '../common'; interface MomentTransformerOptions { format?: string; From 7b5195451b53978c86b307e35f8da033e7830228 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 12:26:07 +0900 Subject: [PATCH 12/31] refactor(seal): drop Object.create/readonly-cast hack in DeserializeBuilder; tidy root - createChild now constructs the child via a real constructor 'scope' argument (shared reference arrays + circular-tracking set as the single mutable accumulator) instead of Object.create(prototype) + a readonly-bypass MutableBuilderState cast. No hack, no any/unknown. - Move orphan src/error-system.spec.ts -> src/common/ (error domain); src/ root now holds only baker.ts + symbols.ts (+ their co-located specs). tsc 0, 2350 pass/0 fail, codegen snapshot unchanged, deps/knip/lint clean, build ok, memory pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/{ => common}/error-system.spec.ts | 8 +-- src/seal/deserialize-builder.ts | 89 +++++++++++++-------------- 2 files changed, 46 insertions(+), 51 deletions(-) rename src/{ => common}/error-system.spec.ts (90%) diff --git a/src/error-system.spec.ts b/src/common/error-system.spec.ts similarity index 90% rename from src/error-system.spec.ts rename to src/common/error-system.spec.ts index 8f7233c..0425fb7 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 './rules/create-rule'; -import { BakerError } from './common/errors'; -import { isPassportNumber } from './rules/locales'; -import { isDivisibleBy, max, min } from './rules/number'; +import { createRule } from '../rules/create-rule'; +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/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index b4fefa1..61885ff 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -461,6 +461,8 @@ class DeserializeBuilder { 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; @@ -472,44 +474,45 @@ class DeserializeBuilder { this.stopAtFirstError = options?.stopAtFirstError ?? false; this.collectErrors = !this.stopAtFirstError; - this.exposeDefaultValues = options?.exposeDefaultValues ?? false; - this.regexes = []; - this.refs = []; - this.execs = []; + 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; + 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 = []; + } } /** - * Create a CHILD builder for an inline-nested DTO. Shares the parent's reference arrays, - * `resolve`, `options`, `isAsync`, `inlineNestedClasses` set and circular-check flag; overrides - * `pathPrefix`/`varPrefix`/`inputExpr` and forces `exposeDefaultValues` off (inline nested - * doesn't use exposeDefaultValues). + * 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 { - const child = Object.create(DeserializeBuilder.prototype) as DeserializeBuilder & MutableBuilderState; - child.Class = this.Class; - child.merged = this.merged; - child.options = this.options; - child.needsCircularCheck = this.needsCircularCheck; - child.isAsync = this.isAsync; - child.resolve = this.resolve; - child.validateOnly = this.validateOnly; - child.stopAtFirstError = this.stopAtFirstError; - child.collectErrors = this.collectErrors; - // inline nested doesn't use exposeDefaultValues - child.exposeDefaultValues = false; - // Share reference arrays so executor ref indices stay identical. - child.regexes = this.regexes; - child.refs = this.refs; - child.execs = this.execs; - // Share the circular-tracking set (mutated in place during inline emission). - if (this.inlineNestedClasses) { - child.inlineNestedClasses = this.inlineNestedClasses; - } - child.pathPrefix = pathPrefix; - child.varPrefix = varPrefix; - child.inputExpr = inputExpr; - return child; + 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, + inlineNestedClasses: this.inlineNestedClasses, + pathPrefix, + varPrefix, + inputExpr, + }); } // ── Entry point ──────────────────────────────────────────────────────────── @@ -1941,24 +1944,16 @@ class DeserializeBuilder { /** Writable view of the builder's data fields — used to populate a child instance created via * Object.create (bypassing the constructor so reference arrays can be shared). */ -interface MutableBuilderState { - Class: Function; - merged: RawClassMeta; - options: SealOptions | undefined; - needsCircularCheck: boolean; - isAsync: boolean; - resolve: (cls: Function) => SealedExecutors | undefined; - stopAtFirstError: boolean; - collectErrors: boolean; - exposeDefaultValues: boolean; - validateOnly: boolean; +/** 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. */ +interface ChildScope { regexes: RegExp[]; refs: unknown[]; execs: SealedExecutors[]; - inlineNestedClasses?: Set; - pathPrefix?: string; - varPrefix?: string; - inputExpr?: string; + inlineNestedClasses: Set | undefined; + pathPrefix: string; + varPrefix: string; + inputExpr: string; } // ───────────────────────────────────────────────────────────────────────────── From 12b7bdee35d908a772e47aa55070be18c563edb7 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 12:37:53 +0900 Subject: [PATCH 13/31] refactor(seal): extract pure codegen utilities from DeserializeBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the stateless module-level codegen helpers + data tables (GEN, nestedErr*, guard strategies, generateConversionCode, categorizeRules, nested-result emitters, type-hint/asserter tables) out of deserialize-builder.ts into a dedicated seal/deserialize-codegen.ts. DeserializeBuilder (the stateful builder class) now imports them — SRP split between 'pure codegen utilities' and 'the builder'. deserialize-builder.ts 2003 -> 1624 lines; deserialize-codegen.ts 404 lines. tsc 0, 2350 pass/0 fail, codegen snapshot byte-identical (15/0), deps/knip/lint clean, build ok. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/deserialize-builder.ts | 419 ++------------------------------ src/seal/deserialize-codegen.ts | 404 ++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+), 399 deletions(-) create mode 100644 src/seal/deserialize-codegen.ts diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 61885ff..de76faa 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -12,405 +12,26 @@ import { CacheKey, BakerError } from '../common'; import { CollectionType } from '../metadata'; import { emitRulePlan } from '../rules/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; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers — code generation utilities (pure, module-level) -// ───────────────────────────────────────────────────────────────────────────── - -/** 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); - } - } - return all === null ? undefined : [...all]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// nullable/optional guard — truth-table strategy pattern (D-3) -// ───────────────────────────────────────────────────────────────────────────── - -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; - } - 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; - }, -}; - -// ───────────────────────────────────────────────────────────────────────────── -// 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`; -} - -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 -// ───────────────────────────────────────────────────────────────────────────── - -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}"`); - } -} - -/** `@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']); - -/** 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; -} - -/** categorizeRules — separate each/nonEach rules, detect mixed gate conflicts (pure) */ -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 }; -} - -/** 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; -} - -/** 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; -} - -/** Generate nested-result handling for deserialize mode (pure) */ -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` - ); -} - -/** Generate validate-mode nested result handling (null check instead of isErr) (pure) */ -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` - ); -} +import type { CategorizedRules, ResolvedTypeGate, TypeGateConfig } from './deserialize-codegen'; +import { + GEN, + nestedErrPush, + nestedErrReturn, + toVarName, + getDeserializeExtractKey, + getDeserializeExposeGroups, + resolveGuardKey, + GUARD_STRATEGIES, + wrapGroupsGuard, + sameGroups, + generateConversionCode, + PRIMITIVE_TYPE_HINTS, + ASSERTER_TO_GATE, + GATE_ONLY_ASSERTERS, + categorizeRules, + generateNestedResultCode, + generateValidateNestedResult, +} from './deserialize-codegen'; // ───────────────────────────────────────────────────────────────────────────── // DeserializeBuilder — new Function-based executor generation (§4.9) diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts new file mode 100644 index 0000000..75b2f24 --- /dev/null +++ b/src/seal/deserialize-codegen.ts @@ -0,0 +1,404 @@ +import type { RawPropertyMeta, RuleDef } from '../metadata'; +import type { EmitContext } from '../rules/types'; + +import { BakerError } from '../common'; +import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; +import { GuardKey } from './enums'; + +// ───────────────────────────────────────────────────────────────────────────── +// Generated variable name prefixes — centralised to prevent typo-related bugs +// ───────────────────────────────────────────────────────────────────────────── + +export 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; + +// ───────────────────────────────────────────────────────────────────────────── +// 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`); + 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) */ +export function toVarName(key: string, prefix?: string): string { + return GEN.field + (prefix || '') + sanitizeKey(key); +} + +/** Determine the extraction key for deserialization (§4.3 step 3) */ +export 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 */ +export 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); + } + } + return all === null ? undefined : [...all]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// nullable/optional guard — truth-table strategy pattern (D-3) +// ───────────────────────────────────────────────────────────────────────────── + +export 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; + } + if (useOptionalGuard) { + return GuardKey.Optional; + } + return GuardKey.Default; +} + +export interface GuardParams { + varName: string; + emitCtx: EmitContext; + assignNull: string; + validationCode: string; +} + +export 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; + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// 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). + */ +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}"`); + } +} + +/** `@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']); + +/** Result of categorizeRules — each/nonEach split and typed dependency classification */ +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; +} + +/** 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 }; +} + +/** 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; +} + +/** 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` + ); +} + +/** 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` + ); +} From 4a4135c7c12b8a79b0a1da954fa112c7110f476c Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 12:44:15 +0900 Subject: [PATCH 14/31] refactor(seal): unify mirror expose-resolution helpers into a single source of truth getDeserializeExtractKey/getSerializeOutputKey and getDeserializeExposeGroups/ getSerializeExposeGroups were direction-mirror copies (differing only in which directional @Expose flag they honour). Collapse each pair into one Direction- parameterized helper (resolveExposeName / resolveExposeGroups) in codegen-utils.ts, the shared codegen source of truth. Both builders call them with their Direction. Removes 4 near-duplicate functions -> 2. tsc 0, 2350 pass/0 fail, codegen snapshot byte-identical (15/0), deps acyclic, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/codegen-utils.ts | 51 +++++++++++++++++++++++++++++++++ src/seal/deserialize-builder.ts | 14 ++++----- src/seal/deserialize-codegen.ts | 38 ++---------------------- src/seal/serialize-builder.ts | 51 +++++---------------------------- 4 files changed, 66 insertions(+), 88 deletions(-) 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/deserialize-builder.ts b/src/seal/deserialize-builder.ts index de76faa..1a3daa3 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -8,18 +8,16 @@ import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../met import type { EmitContext } from '../rules/types'; import type { SealedExecutors } from './types'; -import { CacheKey, BakerError } from '../common'; +import { CacheKey, BakerError, Direction } from '../common'; import { CollectionType } from '../metadata'; import { emitRulePlan } from '../rules/rule-plan'; -import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; +import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; import type { CategorizedRules, ResolvedTypeGate, TypeGateConfig } from './deserialize-codegen'; import { GEN, nestedErrPush, nestedErrReturn, toVarName, - getDeserializeExtractKey, - getDeserializeExposeGroups, resolveGuardKey, GUARD_STRATEGIES, wrapGroupsGuard, @@ -184,7 +182,7 @@ class DeserializeBuilder { if (options?.whitelist) { const allowedKeys = new Set(); for (const [fieldKey, meta] of Object.entries(merged)) { - const extractKey = getDeserializeExtractKey(fieldKey, meta.expose); + const extractKey = resolveExposeName(fieldKey, meta.expose, Direction.Deserialize); allowedKeys.add(extractKey); } const allowedIdx = refs.length; @@ -205,7 +203,7 @@ class DeserializeBuilder { let hasGroupsField = false; for (const fk in merged) { const meta = merged[fk]!; - const exposeGroups = getDeserializeExposeGroups(meta.expose); + const exposeGroups = resolveExposeGroups(meta.expose, Direction.Deserialize); if (exposeGroups && exposeGroups.length > 0) { hasGroupsField = true; break; @@ -298,8 +296,8 @@ class DeserializeBuilder { } const varName = toVarName(fieldKey, this.varPrefix); - const extractKey = getDeserializeExtractKey(fieldKey, meta.expose); - const exposeGroups = getDeserializeExposeGroups(meta.expose); + const extractKey = resolveExposeName(fieldKey, meta.expose, Direction.Deserialize); + const exposeGroups = resolveExposeGroups(meta.expose, Direction.Deserialize); const inputObj = this.inputExpr || 'input'; // Create EmitContext — bake field-level message/context so EVERY field-own-path failure diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts index 75b2f24..fe38f30 100644 --- a/src/seal/deserialize-codegen.ts +++ b/src/seal/deserialize-codegen.ts @@ -66,42 +66,8 @@ export function toVarName(key: string, prefix?: string): string { return GEN.field + (prefix || '') + sanitizeKey(key); } -/** Determine the extraction key for deserialization (§4.3 step 3) */ -export 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 */ -export 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); - } - } - return all === null ? undefined : [...all]; -} +// 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) diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index 2ec0ccf..e75402a 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -4,8 +4,8 @@ import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata'; import type { SealedExecutors } from './types'; import { CollectionType } from '../metadata'; -import { BakerError } from '../common'; -import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; +import { BakerError, Direction } from '../common'; +import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; // ───────────────────────────────────────────────────────────────────────────── // Generated variable name prefixes — centralised to prevent typo-related bugs @@ -30,45 +30,8 @@ const GEN = { nestedItem: '__bk$nitem', } as const; -// ───────────────────────────────────────────────────────────────────────────── -// Pure stateless helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** 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; -} - -/** 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; - } - if (all === null) { - all = new Set(); - } - for (const g of e.groups) { - all.add(g); - } - } - return all === null ? undefined : [...all]; -} +// Field rename + expose-group resolution (both directions) live in codegen-utils as the single +// source of truth — see resolveExposeName / resolveExposeGroups. // ───────────────────────────────────────────────────────────────────────────── // SerializeBuilder — new Function-based serialize executor generation (§4.3 serialize pipeline) @@ -120,7 +83,7 @@ class SerializeBuilder { // Groups variable — only when fields referencing groups exist const hasGroupsField = Object.values(this.merged).some(meta => { - const groups = getSerializeExposeGroups(meta.expose); + const groups = resolveExposeGroups(meta.expose, Direction.Serialize); return groups && groups.length > 0; }); if (hasGroupsField) { @@ -179,8 +142,8 @@ class SerializeBuilder { return ''; } - const outputKey = getSerializeOutputKey(fieldKey, meta.expose); - const exposeGroups = getSerializeExposeGroups(meta.expose); + const outputKey = resolveExposeName(fieldKey, meta.expose, Direction.Serialize); + const exposeGroups = resolveExposeGroups(meta.expose, Direction.Serialize); const sk = sanitizeKey(fieldKey); const fieldVal = `${GEN.fieldVal}${sk}`; From 11bd285ca5c211289979e3711677f541c5a64827 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 12:48:59 +0900 Subject: [PATCH 15/31] refactor(rules): split string.ts into cohesive concern modules (Phase E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 2526-line flat string-rule list into a shared factory module plus six concern modules (basic/width/encoding/format/identifier/finance); string.ts becomes a pure re-export barrel preserving every export name and the original ordering, so the published ./rules surface is byte-stable. Every regex, data constant (incl. the ISO 3166-1 alpha-2/alpha-3 and ISO 4217 sets), checksum helper, and embedded codegen string moved verbatim — declarations are byte-identical to the original. Verified: tsc 0; codegen snapshot 15/0 (byte-identical); 2350 pass/0 fail; declaration hash identical (2154 lines); ./rules export set identical (83 exports); deps acyclic; knip/lint clean; build ok. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/rules/string-basic.ts | 262 ++++ src/rules/string-encoding.ts | 140 ++ src/rules/string-finance.ts | 622 ++++++++ src/rules/string-format.ts | 686 +++++++++ src/rules/string-identifier.ts | 725 +++++++++ src/rules/string-shared.ts | 24 + src/rules/string-width.ts | 59 + src/rules/string.ts | 2518 +------------------------------- 8 files changed, 2556 insertions(+), 2480 deletions(-) create mode 100644 src/rules/string-basic.ts create mode 100644 src/rules/string-encoding.ts create mode 100644 src/rules/string-finance.ts create mode 100644 src/rules/string-format.ts create mode 100644 src/rules/string-identifier.ts create mode 100644 src/rules/string-shared.ts create mode 100644 src/rules/string-width.ts diff --git a/src/rules/string-basic.ts b/src/rules/string-basic.ts new file mode 100644 index 0000000..2314f5d --- /dev/null +++ b/src/rules/string-basic.ts @@ -0,0 +1,262 @@ +import type { EmitContext, EmittableRule } from './types'; + +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 { + 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')};`; + }, + ); +} + +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-encoding.ts b/src/rules/string-encoding.ts new file mode 100644 index 0000000..ad414c4 --- /dev/null +++ b/src/rules/string-encoding.ts @@ -0,0 +1,140 @@ +import type { EmitContext, EmittableRule } from './types'; + +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})$/; +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 }, + ); +} + +export { isHexadecimal, isOctal, isHexColor, isRgbColor, isHSL, isBase32, isBase58, isBase64 }; +export type { IsBase64Options }; diff --git a/src/rules/string-finance.ts b/src/rules/string-finance.ts new file mode 100644 index 0000000..842eec1 --- /dev/null +++ b/src/rules/string-finance.ts @@ -0,0 +1,622 @@ +import type { EmitContext, EmittableRule } from './types'; + +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);` + + `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')}; }` + ); +}); + +// 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')};}}` + ); + }, + }); +} + +// 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 (§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 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.ts b/src/rules/string-format.ts new file mode 100644 index 0000000..32db996 --- /dev/null +++ b/src/rules/string-format.ts @@ -0,0 +1,686 @@ +import type { EmitContext, EmittableRule } from './types'; + +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 = ['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')};`; + }, + }); +} + +// 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')};`; + }, + }); +} + +// 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')};`; + }, +); + +// 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; + }, + }); +} + +// 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')};`; + }, +); + +// 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')};`; + }, + }); +} + +export { + 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, +}; +export type { IsURLOptions, IsMACAddressOptions, IsFQDNOptions, IsStrongPasswordOptions }; diff --git a/src/rules/string-identifier.ts b/src/rules/string-identifier.ts new file mode 100644 index 0000000..93acec3 --- /dev/null +++ b/src/rules/string-identifier.ts @@ -0,0 +1,725 @@ +import type { EmitContext, EmittableRule } from './types'; + +import { RequiredType } from './enums'; +import { makeRule } from './rule-plan'; +import { makeStringRule } from './string-shared'; + +// 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')};`; + }, +); + +// ISO 3166-1 Alpha-2 +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', +]); + +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')};`; + }, +}); + +// 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')};`; + }, +}); + +// 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 = 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')}; }` + ); + }); +} + +// 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..3f5cdaf --- /dev/null +++ b/src/rules/string-shared.ts @@ -0,0 +1,24 @@ +import type { EmitContext, EmittableRule } from './types'; + +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.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.ts b/src/rules/string.ts index 1c11e9a..991603e 100644 --- a/src/rules/string.ts +++ b/src/rules/string.ts @@ -1,2442 +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 } from '../common'; -import { 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, @@ -2455,49 +19,29 @@ 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, @@ -2506,21 +50,35 @@ export { isLongitude, isEthereumAddress, isBtcAddress, - isISO4217CurrencyCode, isPhoneNumber, isStrongPassword, isTaxId, +} from './string-format'; +export type { IsURLOptions, IsMACAddressOptions, IsFQDNOptions, IsStrongPasswordOptions } from './string-format'; + +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'; From f4c352aa76e0bcd5072f23a1621a3b66f20e640e Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 12:50:00 +0900 Subject: [PATCH 16/31] docs: mark Phase E (string.ts split) + post-D cleanup DONE Co-Authored-By: Claude Opus 4.8 (1M context) --- REFACTORING.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/REFACTORING.md b/REFACTORING.md index f8eb467..ada38f6 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -165,9 +165,11 @@ nested-codegen-validate` via an `emitField` callback (dependency inversion) — call path, so do it as its own separately-gated commit (extract the leaf modules verbatim first). Gate: byte-identical codegen — **mechanized** (see harness below), not eyeballed. -## Phase E — `rules/string.ts` split (DEFERRED — lowest value, last or skip) -2525 lines, flat, low-coupling. Split by concern behind a pure re-export barrel so `rules/index.ts` -(the `./rules` subpath) stays byte-stable. Pure churn; schedule last or defer. +## Phase E — `rules/string.ts` split (DONE) +2526 lines, flat, low-coupling. Split into `string-shared.ts` + six concern modules +(`basic`/`width`/`encoding`/`format`/`identifier`/`finance`) behind a pure re-export barrel so +`rules/index.ts` (the `./rules` subpath) stays byte-stable. Every regex/data constant/checksum helper +moved verbatim — declaration text byte-identical; `./rules` export set (83) unchanged; snapshot 15/0. ## Phase F — barrels / exports / `.d.ts` close-out Per-directory barrels; public barrels + root `/index.ts` + `./symbols` stable. `.d.ts` review, @@ -198,13 +200,21 @@ A/B don't touch codegen but the harness should exist before C. 7. ~~**F** — per-directory barrels (`common`/`metadata`/`config`/`seal`/`runtime` index.ts) + strict exports; cross-dir imports routed through barrels (one documented deep edge: `rules/types → seal/types` for `SealedExecutors`)~~ — **DONE**. -8. **E** — `string.ts` split — **DEFERRED** (2525 lines but flat/low-coupling/low-value; behind a pure - re-export barrel keeping `./rules` stable; do only if file size becomes a real maintenance pain). - -Result so far: `src/` root holds only `baker.ts` (composition root) + `symbols.ts` (pinned). All other code +8. ~~**E** — `string.ts` split into `string-shared` + six concern modules behind a byte-stable + `./rules` barrel~~ — **DONE**. +9. ~~**Post-D cleanup** — remove the `createChild` `Object.create`+readonly-cast hack (real constructor + `scope` arg); extract pure codegen utilities out of `DeserializeBuilder` into `seal/deserialize-codegen.ts` + (2003→1624 lines); unify the four direction-mirror expose helpers into `resolveExposeName`/ + `resolveExposeGroups` (single source of truth in `seal/codegen-utils.ts`); move orphan + `error-system.spec.ts` into `common/`~~ — **DONE**. + +Result: `src/` root holds only `baker.ts` (composition root) + `symbols.ts` (pinned). All other code lives in its domain (`common/ metadata/ config/ rules/ transformers/ seal/ runtime/`). The builders are -classes. Junk-drawer `types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic (one documented type-only -`rules → seal` edge). Each phase independently revertible; regressions isolate to one layer. +classes; their pure codegen utilities live in sibling `*-codegen`/`codegen-utils` modules. No +`Object.create`/`as`-cast hacks, no `any`/`@ts-ignore`/`eslint-disable` in source. Junk-drawer +`types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic (one documented type-only `rules → seal` edge). +Internal types are imported from `/types`; the published `` barrels expose only public surface. +Each phase independently revertible; regressions isolate to one layer. --- From ffa059fe91d719eae548d2240c4e7f063097073f Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 13:05:50 +0900 Subject: [PATCH 17/31] refactor(seal): convert seal pipeline to SealRun class (kill recursion state-threading) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sealOne/sealRegistry threaded executors/fp/options/sealed through every recursive call — the exact pattern the builders were classed to remove. Make a SealRun class holding those as fields; sealOne becomes a private method, recursion is this.sealOne(x). sealRegistry stays the thin module entry point (new SealRun(...).run()), mirroring how buildDeserializeCode fronts DeserializeBuilder, so seal/index.ts and baker.ts are unchanged. tsc 0, 2350 pass/0 fail, codegen snapshot byte-identical (15/0). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/seal.ts | 352 +++++++++++++++++++++++++---------------------- 1 file changed, 184 insertions(+), 168 deletions(-) diff --git a/src/seal/seal.ts b/src/seal/seal.ts index b7a4517..8bd20a4 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -18,207 +18,223 @@ import { validateMeta } from './validate-meta'; const BANNED_FIELD_NAMES = new Set(['__proto__', 'constructor', 'prototype']); /** - * 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. + * 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 `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). + * 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 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); - } - } 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; +class SealRun { + private readonly fp: string; + /** Classes compiled by THIS run (excludes cache hits) — committed to the shared cache on success. */ + private readonly sealed = new Set(); + private readonly resolve = (cls: Function): SealedExecutors | undefined => this.executors.get(cls); + + constructor( + private readonly executors: Map>, + private readonly options: SealOptions, + ) { + this.fp = configFingerprint(options); } - // 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)!); + /** + * 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); + } + } 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. + this.executors.clear(); + throw e; + } + + // Commit only the classes compiled by THIS run to the shared cache (cache hits are already there). + for (const Class of this.sealed) { + setCached(Class, this.fp, this.executors.get(Class)!); + } + registry.clear(); } - registry.clear(); -} -// ───────────────────────────────────────────────────────────────────────────── -// sealOne() — seal an individual class (§4.1) -// ───────────────────────────────────────────────────────────────────────────── + // ─────────────────────────────────────────────────────────────────────────── + // sealOne() — seal an individual class (§4.1) + // ─────────────────────────────────────────────────────────────────────────── -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 = getCached(Class, this.fp); + if (cached !== undefined) { + this.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)) { + 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 = circularPlaceholder(Class.name); + this.executors.set(Class, placeholder); - try { - // 1. Merge inheritance metadata - const merged = mergeInheritance(Class); + try { + // 1. Merge inheritance metadata + const merged = mergeInheritance(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)`); + // 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 }); - } + // 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; + // 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; + } } + merged[key] = { ...meta, type: typeCopy }; + continue; } - 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; + 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 }; } - merged[key] = { ...meta, type: typeCopy }; - } - // 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) + validateMeta(Class, merged); - // 3. Static analysis for circular references - const needsCircularCheck = analyzeCircular(Class); + // 3. Static analysis for circular references + const needsCircularCheck = analyzeCircular(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) — uses resolvedClass / resolvedCollectionValue + for (const meta of Object.values(merged)) { + if (meta.type?.resolvedClass) { + this.sealOne(meta.type.resolvedClass); + } + if (meta.type?.resolvedCollectionValue) { + this.sealOne(meta.type.resolvedCollectionValue); + } + if (meta.type?.discriminator) { + for (const sub of meta.type.discriminator.subTypes) { + this.sealOne(sub.value); + } } } + + // 5. Async analysis + const isAsync = analyzeAsync(merged, Direction.Deserialize, this.resolve); + const isSerializeAsync = analyzeAsync(merged, Direction.Serialize, this.resolve); + + // 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; } - // 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); - - // 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 so the run 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. + this.sealed.add(Class); } +} - // 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); +/** + * 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 sealRegistry( + registry: Set, + options: SealOptions, + executors: Map>, +): void { + new SealRun(executors, options).run(registry); } export { sealRegistry }; From d78a4bda2eb6ca2e2b1f6b310b2c1c9a7b199694 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 13:07:19 +0900 Subject: [PATCH 18/31] refactor(transformers): rename X.transformer.ts -> kebab plain; split datetime spec per-file Match the repo-wide kebab file convention (collection/date/luxon/moment/number/string.ts); only transformers/index.ts referenced them. Split datetime-transformer.spec.ts into luxon.spec.ts + moment.spec.ts so each transformer source has its co-located unit spec. tsc 0, transformer specs 8 pass, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ollection.transformer.ts => collection.ts} | 0 .../{date.transformer.ts => date.ts} | 0 src/transformers/datetime-transformer.spec.ts | 59 ------------------- src/transformers/index.ts | 16 ++--- src/transformers/luxon.spec.ts | 33 +++++++++++ .../{luxon.transformer.ts => luxon.ts} | 0 src/transformers/moment.spec.ts | 33 +++++++++++ .../{moment.transformer.ts => moment.ts} | 0 .../{number.transformer.ts => number.ts} | 0 .../{string.transformer.ts => string.ts} | 0 10 files changed, 74 insertions(+), 67 deletions(-) rename src/transformers/{collection.transformer.ts => collection.ts} (100%) rename src/transformers/{date.transformer.ts => date.ts} (100%) delete mode 100644 src/transformers/datetime-transformer.spec.ts create mode 100644 src/transformers/luxon.spec.ts rename src/transformers/{luxon.transformer.ts => luxon.ts} (100%) create mode 100644 src/transformers/moment.spec.ts rename src/transformers/{moment.transformer.ts => moment.ts} (100%) rename src/transformers/{number.transformer.ts => number.ts} (100%) rename src/transformers/{string.transformer.ts => string.ts} (100%) diff --git a/src/transformers/collection.transformer.ts b/src/transformers/collection.ts similarity index 100% rename from src/transformers/collection.transformer.ts rename to src/transformers/collection.ts diff --git a/src/transformers/date.transformer.ts b/src/transformers/date.ts similarity index 100% rename from src/transformers/date.transformer.ts rename to src/transformers/date.ts 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..8e6f6fc 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -1,8 +1,8 @@ -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'; +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 type { LuxonTransformerOptions } from './luxon'; +export { momentTransformer } from './moment'; +export type { MomentTransformerOptions } from './moment'; diff --git a/src/transformers/luxon.spec.ts b/src/transformers/luxon.spec.ts new file mode 100644 index 0000000..61abb63 --- /dev/null +++ b/src/transformers/luxon.spec.ts @@ -0,0 +1,33 @@ +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); + }); +}); diff --git a/src/transformers/luxon.transformer.ts b/src/transformers/luxon.ts similarity index 100% rename from src/transformers/luxon.transformer.ts rename to src/transformers/luxon.ts diff --git a/src/transformers/moment.spec.ts b/src/transformers/moment.spec.ts new file mode 100644 index 0000000..2daa5fd --- /dev/null +++ b/src/transformers/moment.spec.ts @@ -0,0 +1,33 @@ +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); + }); +}); diff --git a/src/transformers/moment.transformer.ts b/src/transformers/moment.ts similarity index 100% rename from src/transformers/moment.transformer.ts rename to src/transformers/moment.ts diff --git a/src/transformers/number.transformer.ts b/src/transformers/number.ts similarity index 100% rename from src/transformers/number.transformer.ts rename to src/transformers/number.ts diff --git a/src/transformers/string.transformer.ts b/src/transformers/string.ts similarity index 100% rename from src/transformers/string.transformer.ts rename to src/transformers/string.ts From ee3ebf1bb876b9462d38cf85d1bc3021ed2136cf Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 13:10:14 +0900 Subject: [PATCH 19/31] test(rules): split string.spec.ts into per-module specs mirroring the source split Relocate all 440 tests verbatim into string-{basic,width,encoding,format,identifier, finance}.spec.ts co-located with their source modules; delete the monolithic string.spec.ts. Test titles are an exact verbatim partition (0 lost / 0 added); sum 440 = original 440. tsc 0, full suite 2350 pass/0 fail, lint/knip clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/rules/string-basic.spec.ts | 691 +++++++ src/rules/string-encoding.spec.ts | 215 +++ src/rules/string-finance.spec.ts | 303 ++++ src/rules/string-format.spec.ts | 1048 +++++++++++ src/rules/string-identifier.spec.ts | 222 +++ src/rules/string-width.spec.ts | 143 ++ src/rules/string.spec.ts | 2573 --------------------------- 7 files changed, 2622 insertions(+), 2573 deletions(-) create mode 100644 src/rules/string-basic.spec.ts create mode 100644 src/rules/string-encoding.spec.ts create mode 100644 src/rules/string-finance.spec.ts create mode 100644 src/rules/string-format.spec.ts create mode 100644 src/rules/string-identifier.spec.ts create mode 100644 src/rules/string-width.spec.ts delete mode 100644 src/rules/string.spec.ts diff --git a/src/rules/string-basic.spec.ts b/src/rules/string-basic.spec.ts new file mode 100644 index 0000000..113b5fc --- /dev/null +++ b/src/rules/string-basic.spec.ts @@ -0,0 +1,691 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { RequiredType } from './enums'; + +import type { EmitContext } from './types'; + +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); + }); +}); + +// ─── 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'); + }); +}); diff --git a/src/rules/string-encoding.spec.ts b/src/rules/string-encoding.spec.ts new file mode 100644 index 0000000..af67fa0 --- /dev/null +++ b/src/rules/string-encoding.spec.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { RequiredType } from './enums'; + +import type { EmitContext } from './types'; + +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 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-finance.spec.ts b/src/rules/string-finance.spec.ts new file mode 100644 index 0000000..6ed6257 --- /dev/null +++ b/src/rules/string-finance.spec.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { RequiredType } from './enums'; + +import type { EmitContext } from './types'; + +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-format.spec.ts b/src/rules/string-format.spec.ts new file mode 100644 index 0000000..a293ce4 --- /dev/null +++ b/src/rules/string-format.spec.ts @@ -0,0 +1,1048 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { RequiredType } from './enums'; + +import type { EmitContext } from './types'; + +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('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 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('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('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 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'); + }); +}); + +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 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 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-identifier.spec.ts b/src/rules/string-identifier.spec.ts new file mode 100644 index 0000000..4851552 --- /dev/null +++ b/src/rules/string-identifier.spec.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, mock } from 'bun:test'; + +import type { EmitContext } from './types'; + +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); + }); + + 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 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); + }); + + 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-width.spec.ts b/src/rules/string-width.spec.ts new file mode 100644 index 0000000..e1d41a5 --- /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 './types'; + +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.spec.ts b/src/rules/string.spec.ts deleted file mode 100644 index 5b938b4..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); - }); -}); From 38ec26434003bda3a35a6d7ea7d816514b367da3 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 13:14:36 +0900 Subject: [PATCH 20/31] refactor: split published barrels into public.ts; route all cross-domain imports through barrels Eliminate cross-domain deep imports. Each published dir (rules/transformers/decorators) now has public.ts (the curated public surface) + index.ts (the FULL internal barrel that also re-exports the internal symbols other domains need: EmitContext/InternalRule/emitRulePlan/RequiredType, Transformer/TransformFunction, ExcludeMode). package.json ./rules ./transformers ./decorators point at public.ts, so internals never leak; src modules import everything via '../' (index barrel). Repointed: decorators/field, metadata/types, seal/deserialize-builder, seal/deserialize-codegen, root index.ts. The ONLY remaining cross-dir deep import is rules/types -> seal/types (documented type-only cycle-break; routing it via the barrel would form a rules<->seal module cycle). Verified: tsc 0, no circular deps, codegen snapshot 15/0 byte-identical, 2350 pass/0 fail, knip/lint clean, build ok; ./rules ./transformers ./decorators public surfaces byte-identical to the old barrels; root '.' re-exported names unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.ts | 14 ++-- package.json | 12 ++-- src/decorators/field.ts | 4 +- src/decorators/index.ts | 9 ++- src/decorators/public.ts | 2 + src/metadata/types.ts | 4 +- src/rules/index.ts | 121 +++----------------------------- src/rules/public.ts | 110 +++++++++++++++++++++++++++++ src/seal/deserialize-builder.ts | 4 +- src/seal/deserialize-codegen.ts | 2 +- src/transformers/index.ts | 16 ++--- src/transformers/public.ts | 8 +++ 12 files changed, 166 insertions(+), 140 deletions(-) create mode 100644 src/decorators/public.ts create mode 100644 src/rules/public.ts create mode 100644 src/transformers/public.ts diff --git a/index.ts b/index.ts index 9e69e1c..75c1201 100644 --- a/index.ts +++ b/index.ts @@ -1,24 +1,24 @@ // Public API — Core -export { createRule } from './src/rules/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 } from './src/decorators/enums'; -export { RequiredType } from './src/rules/enums'; +export { ExcludeMode } from './src/decorators'; +export { RequiredType } from './src/rules'; // Errors export type { BakerIssue, BakerIssueSet } from './src/common'; export { isBakerIssueSet, BakerError } from './src/common'; // Types -export type { EmittableRule } from './src/rules/types'; -export type { Transformer, TransformParams } from './src/transformers/types'; +export type { EmittableRule } from './src/rules'; +export type { Transformer, TransformParams } from './src/transformers'; export type { BakerConfig } from './src/config'; // Interfaces / Options 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/src/decorators/field.ts b/src/decorators/field.ts index 7ce4b9a..b1dea53 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,7 +1,7 @@ import type { ClassCtor } from '../common'; -import type { EmittableRule, InternalRule } from '../rules/types'; +import type { EmittableRule, InternalRule } from '../rules'; import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata'; -import type { Transformer } from '../transformers/types'; +import type { Transformer } from '../transformers'; import { Direction, BakerError, isAsyncFunction, isPromiseLike } from '../common'; import { ensureMeta } from '../metadata'; 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/public.ts b/src/decorators/public.ts new file mode 100644 index 0000000..aeff3bf --- /dev/null +++ b/src/decorators/public.ts @@ -0,0 +1,2 @@ +export { Field, arrayOf } from './field'; +export type { FieldOptions, ArrayOfMarker } from './field'; diff --git a/src/metadata/types.ts b/src/metadata/types.ts index 591fddd..ab080be 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -1,6 +1,6 @@ import type { ClassCtor } from '../common'; -import type { InternalRule } from '../rules/types'; -import type { TransformFunction } from '../transformers/types'; +import type { InternalRule } from '../rules'; +import type { TransformFunction } from '../transformers'; import type { CollectionType } from './enums'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/index.ts b/src/rules/index.ts index a14adde..26c2825 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,110 +1,11 @@ -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`. +export { createRule } from './create-rule'; +export { emitRulePlan } from './rule-plan'; +export { RequiredType } from './enums'; +export type { EmittableRule, InternalRule, EmitContext } from './types'; diff --git a/src/rules/public.ts b/src/rules/public.ts new file mode 100644 index 0000000..a14adde --- /dev/null +++ b/src/rules/public.ts @@ -0,0 +1,110 @@ +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/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 1a3daa3..ef5938a 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -5,12 +5,12 @@ import { err as resultErr, isErr as resultIsErr } from '@zipbul/result'; import type { RuntimeOptions, BakerIssue } from '../common'; import type { SealOptions } from './interfaces'; import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata'; -import type { EmitContext } from '../rules/types'; +import type { EmitContext } from '../rules'; import type { SealedExecutors } from './types'; import { CacheKey, BakerError, Direction } from '../common'; import { CollectionType } from '../metadata'; -import { emitRulePlan } from '../rules/rule-plan'; +import { emitRulePlan } from '../rules'; import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; import type { CategorizedRules, ResolvedTypeGate, TypeGateConfig } from './deserialize-codegen'; import { diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts index fe38f30..b42ef7f 100644 --- a/src/seal/deserialize-codegen.ts +++ b/src/seal/deserialize-codegen.ts @@ -1,5 +1,5 @@ import type { RawPropertyMeta, RuleDef } from '../metadata'; -import type { EmitContext } from '../rules/types'; +import type { EmitContext } from '../rules'; import { BakerError } from '../common'; import { sanitizeKey, buildGroupsHasExpr } from './codegen-utils'; diff --git a/src/transformers/index.ts b/src/transformers/index.ts index 8e6f6fc..c672374 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -1,8 +1,8 @@ -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 type { LuxonTransformerOptions } from './luxon'; -export { momentTransformer } from './moment'; -export type { MomentTransformerOptions } from './moment'; +// 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, TransformFunction } from './types'; diff --git a/src/transformers/public.ts b/src/transformers/public.ts new file mode 100644 index 0000000..8e6f6fc --- /dev/null +++ b/src/transformers/public.ts @@ -0,0 +1,8 @@ +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 type { LuxonTransformerOptions } from './luxon'; +export { momentTransformer } from './moment'; +export type { MomentTransformerOptions } from './moment'; From 4fbdac879e17923d16c716f00a581c93dc51f07f Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sat, 20 Jun 2026 13:15:04 +0900 Subject: [PATCH 21/31] docs: record audit cleanup (SealRun class, kebab transformers, spec split, public/internal barrels) Co-Authored-By: Claude Opus 4.8 (1M context) --- REFACTORING.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/REFACTORING.md b/REFACTORING.md index ada38f6..c4a9ce6 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -208,13 +208,22 @@ A/B don't touch codegen but the harness should exist before C. `resolveExposeGroups` (single source of truth in `seal/codegen-utils.ts`); move orphan `error-system.spec.ts` into `common/`~~ — **DONE**. +10. ~~**Audit cleanup** — convert the seal pipeline (`sealOne`/`sealRegistry`) to a `SealRun` class + (kill recursion state-threading); rename `transformers/*.transformer.ts` → kebab plain + per-file + transformer specs; split `string.spec.ts` into per-module specs mirroring the source; split each + published dir into `public.ts` (curated published surface) + `index.ts` (full internal barrel) so + every cross-domain import routes through `../` with no deep import~~ — **DONE**. + Result: `src/` root holds only `baker.ts` (composition root) + `symbols.ts` (pinned). All other code -lives in its domain (`common/ metadata/ config/ rules/ transformers/ seal/ runtime/`). The builders are -classes; their pure codegen utilities live in sibling `*-codegen`/`codegen-utils` modules. No -`Object.create`/`as`-cast hacks, no `any`/`@ts-ignore`/`eslint-disable` in source. Junk-drawer -`types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic (one documented type-only `rules → seal` edge). -Internal types are imported from `/types`; the published `` barrels expose only public surface. -Each phase independently revertible; regressions isolate to one layer. +lives in its domain (`common/ metadata/ config/ rules/ transformers/ seal/ runtime/`). The builders and +the seal pipeline are classes; their pure codegen utilities live in sibling `*-codegen`/`codegen-utils` +modules. No `Object.create`/`as`-cast hacks, no `any`/`@ts-ignore`/`eslint-disable` in source. Junk-drawer +`types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic. **Barrels:** every directory has an `index.ts`; +the three published dirs (`rules`/`transformers`/`decorators`) additionally have a `public.ts` — the +package.json subpath publishes `public.ts` (curated), while same-repo code imports the full `index.ts` +barrel, so internal symbols (`EmitContext`/`InternalRule`/`emitRulePlan`/…) reach consumers without +leaking publicly. The ONLY cross-dir deep import is `rules/types → seal/types` (type-only cycle-break). +Unit specs are co-located per source file. Each phase independently revertible; regressions isolate to one layer. --- From f7686942a6679384aa37e71effb637cfe7884729 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sun, 21 Jun 2026 23:16:48 +0900 Subject: [PATCH 22/31] refactor(seal,metadata,config): DI-class pipeline + package-wide audit fixes DI extraction (behavior-preserving, codegen byte-identical): - Lift seal/metadata/config stage logic into constructor-injected classes that own collaborators/state as private #fields: MetaStore (RAW key now injected), InheritanceMerger, CircularAnalyzer, AsyncAnalyzer, MetaValidator, ConfigNormalizer, CircularPlaceholder. SealRun's constructor wires the graph by injection; pure stateless helpers (codegen emitters, runtime dispatchers, validateExposeStacks) stay functions. - Smear cleanup: builder/codegen types -> seal/types.ts + seal/interfaces.ts; codegen data consts -> seal/constants.ts as distinct DES_GEN/SER_GEN (alias-imported as GEN, byte-identical). EmitContext-coupled GuardParams/TypeGateConfig stay internal to deserialize-codegen.ts to avoid a rules<->seal cycle. - Delete dissolved modules: collect, meta-access, merge-inheritance, validate-meta, async-analysis, config/configure. Audit bug fixes (package-wide line-by-line review; see .changeset/audit-bugfixes.md): - isEnum: numeric-enum reverse-map key names no longer accepted as valid values. - luxonTransformer: unparseable date passes through instead of laundering into an Invalid DateTime that serialized to null/"Invalid DateTime". - momentTransformer: parse in UTC (moment.utc) so zoneless strings are machine-independent. - checkCallOptions: validate per-call `groups` is string[] at the untyped call boundary. Consistency / dead-code / docs / coverage: - isISSN/isIBAN store resolved constraint booleans; emitGeneralRules markVar uses varPrefix; MetaStore test-only methods labeled + require throws BakerError; fix stale comments. - Add transformers/date.spec.ts (cover non-number/invalid pass-through branches); give CompileCache/MetaStore explicit constructors so coverage counts them (bun marks the synthesized constructor uncovered, which tripped the per-file 90% threshold). tsc clean; 2397 pass / 0 fail; 15 codegen snapshots byte-identical; lint/knip/deps clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/audit-bugfixes.md | 18 ++ REFACTORING.md | 26 +- src/baker.ts | 18 +- src/common/errors.ts | 6 +- src/common/interfaces.ts | 2 +- src/config/config-normalizer.ts | 40 +++ src/config/configure.ts | 54 ---- src/config/constants.ts | 13 + src/config/index.ts | 5 +- src/config/interfaces.ts | 16 + src/decorators/field-guards.spec.ts | 10 + src/decorators/field.ts | 81 +++-- src/decorators/transform.spec.ts | 12 +- src/metadata/collect.spec.ts | 56 ---- src/metadata/collect.ts | 33 -- src/metadata/index.ts | 13 +- src/metadata/interfaces.ts | 104 +++++++ src/metadata/meta-access.spec.ts | 60 ---- src/metadata/meta-access.ts | 66 ---- src/metadata/meta-store.spec.ts | 100 ++++++ src/metadata/meta-store.ts | 117 +++++++ src/metadata/types.ts | 111 +------ src/rules/array.spec.ts | 2 +- src/rules/array.ts | 2 +- src/rules/binary.spec.ts | 2 +- src/rules/binary.ts | 4 +- src/rules/combinators.spec.ts | 10 +- src/rules/combinators.ts | 35 +-- src/rules/common.spec.ts | 2 +- src/rules/common.ts | 12 +- src/rules/create-rule.spec.ts | 2 +- src/rules/create-rule.ts | 4 +- src/rules/date.spec.ts | 2 +- src/rules/date.ts | 6 +- src/rules/index.ts | 2 +- src/rules/interfaces.ts | 57 ++++ src/rules/locales.spec.ts | 2 +- src/rules/locales.ts | 2 +- src/rules/number.spec.ts | 2 +- src/rules/number.ts | 12 +- src/rules/object.spec.ts | 2 +- src/rules/object.ts | 4 +- src/rules/rule-metadata.ts | 4 +- src/rules/rule-plan.ts | 34 +-- src/rules/string-basic.spec.ts | 57 +++- src/rules/string-basic.ts | 15 +- src/rules/string-encoding.spec.ts | 10 +- src/rules/string-encoding.ts | 8 +- src/rules/string-finance.spec.ts | 2 +- src/rules/string-finance.ts | 22 +- src/rules/string-format.spec.ts | 22 +- src/rules/string-format.ts | 158 ++++------ src/rules/string-identifier.spec.ts | 6 +- src/rules/string-identifier.ts | 18 +- src/rules/string-shared.ts | 2 +- src/rules/string-width.spec.ts | 2 +- src/rules/typechecker.spec.ts | 31 +- src/rules/typechecker.ts | 49 +-- src/rules/types.ts | 63 +--- src/runtime/check-call-options.ts | 18 +- src/runtime/deserialize.spec.ts | 2 +- src/runtime/deserialize.ts | 44 +-- src/runtime/serialize.spec.ts | 2 +- src/runtime/serialize.ts | 15 +- src/runtime/validate.ts | 25 +- src/seal/async-analysis.ts | 98 ------ src/seal/async-analyzer.ts | 104 +++++++ src/seal/circular-analyzer.spec.ts | 105 ++++--- src/seal/circular-analyzer.ts | 66 ++-- src/seal/circular-placeholder.ts | 47 ++- src/seal/compile-cache.spec.ts | 42 +-- src/seal/compile-cache.ts | 87 +++--- src/seal/constants.ts | 77 +++++ src/seal/deserialize-builder.spec.ts | 33 +- src/seal/deserialize-builder.ts | 162 +++++----- src/seal/deserialize-codegen.ts | 110 ++----- src/seal/enums.ts | 3 +- src/seal/expose-validator.spec.ts | 2 +- src/seal/expose-validator.ts | 9 +- src/seal/index.ts | 3 +- src/seal/inheritance-merger.ts | 118 ++++++++ src/seal/interfaces.ts | 78 ++++- src/seal/merge-inheritance.ts | 109 ------- src/seal/meta-validator.ts | 81 +++++ src/seal/seal.spec.ts | 217 +++++++------ src/seal/seal.ts | 128 ++++---- src/seal/serialize-builder.spec.ts | 4 +- src/seal/serialize-builder.ts | 214 +++++++------ src/seal/types.ts | 27 +- src/seal/validate-meta.ts | 73 ----- src/transformers/collection.ts | 2 +- src/transformers/date.spec.ts | 77 +++++ src/transformers/date.ts | 20 +- src/transformers/index.ts | 3 +- src/transformers/interfaces.ts | 13 + src/transformers/luxon.spec.ts | 13 + src/transformers/luxon.ts | 19 +- src/transformers/moment.spec.ts | 10 + src/transformers/moment.ts | 9 +- src/transformers/number.ts | 2 +- src/transformers/string.ts | 2 +- src/transformers/types.ts | 13 +- test/e2e/circular-check.test.ts | 28 ++ test/e2e/discriminator-advanced.test.ts | 25 +- test/e2e/fuzz-parity.test.ts | 2 +- test/e2e/inheritance-message.test.ts | 54 ++++ test/e2e/prefix-collision-validate.test.ts | 32 ++ test/e2e/rule-semantics-parity.test.ts | 2 +- test/e2e/seal-error.test.ts | 2 +- test/e2e/serialize-parity-meta.test.ts | 3 +- test/e2e/set-each-groups.test.ts | 34 +++ test/e2e/string-semantics-parity-meta.test.ts | 4 +- test/e2e/string-validators-full.test.ts | 8 +- test/e2e/string-validators.test.ts | 6 + test/e2e/transformers.test.ts | 5 + .../codegen-snapshot.test.ts.snap | 285 +++++++++--------- test/integration/check-call-options.test.ts | 14 + test/integration/codegen-snapshot.test.ts | 10 +- test/integration/error-system.test.ts | 2 +- test/integration/helpers/unseal.ts | 4 +- test/integration/seal.test.ts | 4 +- 121 files changed, 2533 insertions(+), 1907 deletions(-) create mode 100644 .changeset/audit-bugfixes.md create mode 100644 src/config/config-normalizer.ts delete mode 100644 src/config/configure.ts create mode 100644 src/config/constants.ts create mode 100644 src/config/interfaces.ts delete mode 100644 src/metadata/collect.spec.ts delete mode 100644 src/metadata/collect.ts create mode 100644 src/metadata/interfaces.ts delete mode 100644 src/metadata/meta-access.spec.ts delete mode 100644 src/metadata/meta-access.ts create mode 100644 src/metadata/meta-store.spec.ts create mode 100644 src/metadata/meta-store.ts create mode 100644 src/rules/interfaces.ts delete mode 100644 src/seal/async-analysis.ts create mode 100644 src/seal/async-analyzer.ts create mode 100644 src/seal/inheritance-merger.ts delete mode 100644 src/seal/merge-inheritance.ts create mode 100644 src/seal/meta-validator.ts delete mode 100644 src/seal/validate-meta.ts create mode 100644 src/transformers/date.spec.ts create mode 100644 src/transformers/interfaces.ts create mode 100644 test/e2e/inheritance-message.test.ts create mode 100644 test/e2e/prefix-collision-validate.test.ts create mode 100644 test/e2e/set-each-groups.test.ts diff --git a/.changeset/audit-bugfixes.md b/.changeset/audit-bugfixes.md new file mode 100644 index 0000000..b3dd9ea --- /dev/null +++ b/.changeset/audit-bugfixes.md @@ -0,0 +1,18 @@ +--- +"@zipbul/baker": patch +--- + +Fix four bugs found in a package-wide line-by-line audit: + +- **`@IsEnum` with numeric enums** no longer accepts the enum member *names* as valid values. 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 key-name strings (e.g. `'Active'`). Values + are now read through the non-numeric keys, which is correct for string, numeric, and heterogeneous enums. +- **`luxonTransformer`** now passes an unparseable date string / `Date` through untouched instead of + laundering it into an Invalid `DateTime` (which serialized to `null` / `"Invalid DateTime"` and corrupted + data). This matches `momentTransformer`'s existing pass-through contract. +- **`momentTransformer`** now parses input in UTC (`moment.utc`) so a zoneless datetime string resolves to + the same instant on every host; previously local-time parsing made serialized output depend on the + machine timezone. Matches `luxonTransformer`'s UTC default. +- **Per-call `groups` option** is now validated at the call boundary: a non-`string[]` value throws a clear + `BakerError` instead of silently misbehaving inside the generated executor. diff --git a/REFACTORING.md b/REFACTORING.md index c4a9ce6..39f20f0 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -214,10 +214,30 @@ A/B don't touch codegen but the harness should exist before C. published dir into `public.ts` (curated published surface) + `index.ts` (full internal barrel) so every cross-domain import routes through `../` with no deep import~~ — **DONE**. +11. ~~**DI class extraction (collaborator-owned state)** — lift the seal/metadata/config stage logic + from free functions into constructor-injected classes whose methods read `this`/private `#fields` + (the "uses-`this`" test; genuinely stateless helpers stay functions — `validateExposeStacks`, all + codegen emitters, the `runtime/` dispatchers): `MetaStore` (the single RAW-access boundary; + `metaStore` singleton, injected into `SealRun`/`Baker`), `InheritanceMerger(#meta)`, + `CircularAnalyzer(#merger)`, `AsyncAnalyzer(#resolve,#merger)`, `MetaValidator(#meta)`, + `ConfigNormalizer(#validKeys)`, and `CircularPlaceholder(#message)` (writable own/arrow executor + fields so `sealOne` can `Object.assign`-replace them in place, preserving reference identity). + `SealRun`'s constructor wires the collaborator graph by injection. Smear cleanup: builder/codegen + types → `seal/types.ts` + `seal/interfaces.ts` (`DeserializeExecutor`/`ValidateExecutor`/ + `ChildScope`/`CategorizedRules`/`ResolvedTypeGate`); codegen data consts → `seal/constants.ts` as + distinct `DES_GEN`/`SER_GEN` (alias-imported as `GEN` → byte-identical) + + `PRIMITIVE_TYPE_HINTS`/`ASSERTER_TO_GATE`/`GATE_ONLY_ASSERTERS`. The `EmitContext`-coupled codegen + types (`GuardParams`/`TypeGateConfig`) stay internal to `deserialize-codegen.ts` so the + barrel-exported `interfaces.ts` keeps no `rules → seal` edge (which would close a `rules↔seal` + cycle)~~ — **DONE**. + Result: `src/` root holds only `baker.ts` (composition root) + `symbols.ts` (pinned). All other code lives in its domain (`common/ metadata/ config/ rules/ transformers/ seal/ runtime/`). The builders and -the seal pipeline are classes; their pure codegen utilities live in sibling `*-codegen`/`codegen-utils` -modules. No `Object.create`/`as`-cast hacks, no `any`/`@ts-ignore`/`eslint-disable` in source. Junk-drawer +the seal pipeline are classes; the seal/metadata/config stage logic is constructor-injected classes that +own their collaborators/state as private `#fields` (`MetaStore`, `InheritanceMerger`, `CircularAnalyzer`, +`AsyncAnalyzer`, `MetaValidator`, `ConfigNormalizer`, `CircularPlaceholder`), while genuinely stateless +helpers (codegen emitters, `runtime/` dispatchers, `validateExposeStacks`) stay functions. Pure codegen +utilities live in sibling `*-codegen`/`codegen-utils` modules. No `Object.create`/`as`-cast hacks, no `any`/`@ts-ignore`/`eslint-disable` in source. Junk-drawer `types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic. **Barrels:** every directory has an `index.ts`; the three published dirs (`rules`/`transformers`/`decorators`) additionally have a `public.ts` — the package.json subpath publishes `public.ts` (curated), while same-repo code imports the full `index.ts` @@ -228,7 +248,7 @@ Unit specs are co-located per source file. Each phase independently revertible; --- ## Invariants (every commit) -- `bunx tsc --noEmit` clean; `bun test` fully green (currently 2335 pass). +- `bunx tsc --noEmit` clean; `bun test` fully green (currently 2397 pass). - Generated `new Function` bodies byte-identical (snapshot-checked from Phase C onward). - Public surface unchanged: `/index.ts` names+shapes, subpath barrels (`./rules`, `./transformers`, `./decorators`, `./symbols`), `package.json` exports. `./symbols` keeps pointing at root `symbols.ts`. diff --git a/src/baker.ts b/src/baker.ts index fd550d8..4279d59 100644 --- a/src/baker.ts +++ b/src/baker.ts @@ -1,8 +1,8 @@ import type { BakerConfig } from './config'; -import type { BakerIssueSet, RuntimeOptions } from './common'; +import type { BakerIssueSet, ClassCtor, RuntimeOptions } from './common'; import type { SealOptions, SealedExecutors } from './seal'; -import { normalizeConfig } from './config'; +import { configNormalizer } from './config'; import { BakerError } from './common'; import { sealRegistry } from './seal'; import { @@ -47,7 +47,7 @@ export class Baker { #sealed = false; constructor(config?: BakerConfig) { - this.#options = config === undefined ? Object.freeze({}) : normalizeConfig(config); + this.#options = config === undefined ? Object.freeze({}) : configNormalizer.normalize(config); } /** Class decorator — registers the class as a root of this baker. Use as `@app.Recipe`. */ @@ -88,31 +88,31 @@ 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, + 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, + Class: ClassCtor, input: unknown, options?: RuntimeOptions, ): Promise => runValidateAsync(this.#require(Class), input, options); diff --git a/src/common/errors.ts b/src/common/errors.ts index 0748d03..0e70462 100644 --- a/src/common/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/interfaces.ts b/src/common/interfaces.ts index 547b890..5782e25 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,5 +1,5 @@ // ───────────────────────────────────────────────────────────────────────────── -// RuntimeOptions — per-call runtime options (§5.3). Seam type: seal threads it through +// RuntimeOptions — per-call runtime options. Seam type: seal threads it through // SealedExecutors' signature, runtime consumes it — neither stage owns it. // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/config/config-normalizer.ts b/src/config/config-normalizer.ts new file mode 100644 index 0000000..9b4e56b --- /dev/null +++ b/src/config/config-normalizer.ts @@ -0,0 +1,40 @@ +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}. Holds the set of + * valid config keys as an injected collaborator (default: {@link BAKER_CONFIG_KEYS}), so the unknown-key + * rejection reads from instance state. Used by `new Baker(config)` via the `configNormalizer` singleton. + */ +export class ConfigNormalizer { + readonly #validKeys: ReadonlySet; + + constructor(validKeys: ReadonlySet = BAKER_CONFIG_KEYS) { + this.#validKeys = validKeys; + } + + normalize(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 (!this.#validKeys.has(key as keyof BakerConfig)) { + throw new BakerError(`[baker] unknown key '${key}'. ` + `Valid keys: ${[...this.#validKeys].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 const configNormalizer = new ConfigNormalizer(); diff --git a/src/config/configure.ts b/src/config/configure.ts deleted file mode 100644 index 523855f..0000000 --- a/src/config/configure.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SealOptions } from '../seal'; - -import { BakerError } from '../common'; - -// ───────────────────────────────────────────────────────────────────────────── -// 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/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 index 3e9e729..04c1c81 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,3 +1,4 @@ // Directory barrel — config normalization (BakerConfig → SealOptions). -export { normalizeConfig } from './configure'; -export type { BakerConfig } from './configure'; +export { ConfigNormalizer, configNormalizer } 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/decorators/field-guards.spec.ts b/src/decorators/field-guards.spec.ts index abfa5c0..a5b1891 100644 --- a/src/decorators/field-guards.spec.ts +++ b/src/decorators/field-guards.spec.ts @@ -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 b1dea53..84e50e2 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -4,11 +4,11 @@ import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata'; import type { Transformer } from '../transformers'; import { Direction, BakerError, isAsyncFunction, isPromiseLike } from '../common'; -import { ensureMeta } from '../metadata'; +import { metaStore } from '../metadata'; 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'); @@ -28,8 +28,7 @@ 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 { @@ -173,35 +172,37 @@ 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). */ +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)); } } } @@ -209,25 +210,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 }); @@ -297,7 +286,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); @@ -310,6 +299,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/transform.spec.ts b/src/decorators/transform.spec.ts index 94c331c..185555c 100644 --- a/src/decorators/transform.spec.ts +++ b/src/decorators/transform.spec.ts @@ -1,13 +1,13 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import type { EmittableRule } from '../rules/types'; -import type { RawPropertyMeta, TransformDef, TypeDef } from '../metadata/types'; -import type { TransformParams } from '../transformers/types'; +import type { EmittableRule } from '../rules/interfaces'; +import type { RawPropertyMeta, TransformDef, TypeDef } from '../metadata/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 '../metadata/meta-access'; +import { metaStore } from '../metadata'; import { Field } from './field'; const createdCtors: Function[] = []; @@ -19,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`); } @@ -44,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/metadata/collect.spec.ts b/src/metadata/collect.spec.ts deleted file mode 100644 index 886b578..0000000 --- a/src/metadata/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/metadata/collect.ts b/src/metadata/collect.ts deleted file mode 100644 index 08a327e..0000000 --- a/src/metadata/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/metadata/index.ts b/src/metadata/index.ts index 4fd540c..a946429 100644 --- a/src/metadata/index.ts +++ b/src/metadata/index.ts @@ -1,13 +1,4 @@ // Directory barrel — the RAW metadata IR layer consumed by decorators and seal. -export type { - RawClassMeta, - RawPropertyMeta, - RuleDef, - TransformDef, - ExposeDef, - TypeDef, - MessageArgs, -} from './types'; +export type { RawClassMeta, RawPropertyMeta, RuleDef, TransformDef, ExposeDef, TypeDef, MessageArgs } from './interfaces'; export { CollectionType } from './enums'; -export { deleteRaw, getRaw, requireRaw, setRaw, hasRawOwn } from './meta-access'; -export { ensureMeta } from './collect'; +export { MetaStore, metaStore } from './meta-store'; diff --git a/src/metadata/interfaces.ts b/src/metadata/interfaces.ts new file mode 100644 index 0000000..36da803 --- /dev/null +++ b/src/metadata/interfaces.ts @@ -0,0 +1,104 @@ +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; +} + +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 — 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-access.spec.ts b/src/metadata/meta-access.spec.ts deleted file mode 100644 index e311ca4..0000000 --- a/src/metadata/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/metadata/meta-access.ts b/src/metadata/meta-access.ts deleted file mode 100644 index c41d7b6..0000000 --- a/src/metadata/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/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..0268360 --- /dev/null +++ b/src/metadata/meta-store.ts @@ -0,0 +1,117 @@ +import type { MetaObject, MetaCarrier } from './types'; +import type { RawClassMeta, RawPropertyMeta } from './interfaces'; + +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 index ab080be..6e01f30 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -1,106 +1,13 @@ -import type { ClassCtor } from '../common'; -import type { InternalRule } from '../rules'; -import type { TransformFunction } from '../transformers'; -import type { CollectionType } from './enums'; +import type { RawClassMeta } from './interfaces'; -// ───────────────────────────────────────────────────────────────────────────── -// RuleDef / TransformDef / ExposeDef / ExcludeDef / TypeDef (§2.1) -// ───────────────────────────────────────────────────────────────────────────── +import { RAW } from '../symbols'; -/** Arguments for user-defined message callback */ -export interface MessageArgs { - property: string; - value: unknown; - constraints: Record; -} +// `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. -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; -} +/** The TC39 decorator-metadata object that carries the baker RAW slot (`Class[Symbol.metadata]`). */ +export type MetaObject = Record & { [RAW]?: RawClassMeta }; -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; -} - -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; -} +/** 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 be8bd54..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 810a380..c6816ac 100644 --- a/src/rules/array.ts +++ b/src/rules/array.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { CacheKey } from '../common'; import { RequiredType, RuleOp } from './enums'; diff --git a/src/rules/binary.spec.ts b/src/rules/binary.spec.ts index 1bcfbdd..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 ab0d10c..e415fb5 100644 --- a/src/rules/binary.ts +++ b/src/rules/binary.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { makeRule } from './rule-plan'; @@ -29,7 +29,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 fe33652..379e9a0 100644 --- a/src/rules/combinators.spec.ts +++ b/src/rules/combinators.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'; import { oneOf, arrayEvery } from './combinators'; @@ -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 54dac9c..8d27889 100644 --- a/src/rules/combinators.ts +++ b/src/rules/combinators.ts @@ -1,26 +1,8 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { BakerError } from '../common'; 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.`, - ); -} - // ───────────────────────────────────────────────────────────────────────────── // oneOf — OR combinator: value matches at least one of the given rules. // (Not JSON-Schema `oneOf`/exactly-one — semantics is "matches at least one", @@ -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 1c5c506..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 552f0c1..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'; // ───────────────────────────────────────────────────────────────────────────── -// 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/create-rule.spec.ts b/src/rules/create-rule.spec.ts index 45d8c6e..2aa4254 100644 --- a/src/rules/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/rules/create-rule.ts b/src/rules/create-rule.ts index 7378b73..0a8fc28 100644 --- a/src/rules/create-rule.ts +++ b/src/rules/create-rule.ts @@ -1,11 +1,11 @@ import type { RequiredType } from './enums'; -import type { EmittableRule, EmitContext, InternalRule } from './types'; +import type { EmittableRule, EmitContext, InternalRule } from './interfaces'; import { BakerError, isAsyncFunction, isPromiseLike } from '../common'; import { defineRuleMetadata } from './rule-metadata'; // ───────────────────────────────────────────────────────────────────────────── -// 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 54cad50..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 a7168ea..501ff39 100644 --- a/src/rules/date.ts +++ b/src/rules/date.ts @@ -1,11 +1,11 @@ -import type { EmittableRule } from './types'; +import type { EmittableRule } from './interfaces'; 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 { @@ -24,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/index.ts b/src/rules/index.ts index 26c2825..9573b3f 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -8,4 +8,4 @@ export * from './public'; export { createRule } from './create-rule'; export { emitRulePlan } from './rule-plan'; export { RequiredType } from './enums'; -export type { EmittableRule, InternalRule, EmitContext } from './types'; +export type { EmittableRule, InternalRule, EmitContext } from './interfaces'; 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 6e4c7c0..e854a4b 100644 --- a/src/rules/locales.spec.ts +++ b/src/rules/locales.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { isMobilePhone, isPostalCode, isIdentityCard, isPassportNumber } from './locales'; diff --git a/src/rules/locales.ts b/src/rules/locales.ts index 23ef874..eb8a75d 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { BakerError } from '../common'; diff --git a/src/rules/number.spec.ts b/src/rules/number.spec.ts index f655ac9..a4304ea 100644 --- a/src/rules/number.spec.ts +++ b/src/rules/number.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { min, max, isPositive, isNegative, isDivisibleBy } from './number'; diff --git a/src/rules/number.ts b/src/rules/number.ts index 7cbda0f..059405e 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 '../common'; 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 72759b3..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 1ba039e..ac95dac 100644 --- a/src/rules/object.ts +++ b/src/rules/object.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; @@ -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/rule-metadata.ts b/src/rules/rule-metadata.ts index 0d1f7c7..e8d7bca 100644 --- a/src/rules/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/rules/rule-plan.ts b/src/rules/rule-plan.ts index 2fd1698..6fe071d 100644 --- a/src/rules/rule-plan.ts +++ b/src/rules/rule-plan.ts @@ -1,5 +1,6 @@ import type { RequiredType } from './enums'; -import type { EmitContext, InternalRule, RulePlan, RulePlanCheck, RulePlanExpr } from './types'; +import type { EmitContext, InternalRule, RulePlan } from './interfaces'; +import type { RulePlanCheck, RulePlanExpr } from './types'; import { RuleOp, RulePlanCheckKind, RulePlanExprKind } from './enums'; import { defineRuleMetadata } from './rule-metadata'; @@ -11,17 +12,9 @@ type RulePlanCache = { 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 +110,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 +142,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 index 113b5fc..8f400fd 100644 --- a/src/rules/string-basic.spec.ts +++ b/src/rules/string-basic.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { minLength, @@ -252,6 +252,31 @@ describe('matches', () => { 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 ────────────────────────────────────────── @@ -626,33 +651,33 @@ describe('isNumberString', () => { }); }); -describe('isNumberString — no_symbols option', () => { - it('should reject "+123" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('+123')).toBe(false); +describe('isNumberString — noSymbols option', () => { + it('should reject "+123" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('+123')).toBe(false); }); - it('should reject "-456" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('-456')).toBe(false); + it('should reject "-456" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: 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 "1.5" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('1.5')).toBe(false); }); - it('should reject "1e5" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('1e5')).toBe(false); + it('should reject "1e5" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('1e5')).toBe(false); }); - it('should accept "123" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('123')).toBe(true); + it('should accept "123" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: true })('123')).toBe(true); }); - it('should accept "0" when no_symbols is true', () => { - expect(isNumberString({ no_symbols: true })('0')).toBe(true); + it('should accept "0" when noSymbols is true', () => { + expect(isNumberString({ noSymbols: 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 noSymbols is false (default)', () => { + expect(isNumberString({ noSymbols: false })('+123')).toBe(true); }); it('should accept "+123" when no options provided', () => { diff --git a/src/rules/string-basic.ts b/src/rules/string-basic.ts index 2314f5d..00efd2c 100644 --- a/src/rules/string-basic.ts +++ b/src/rules/string-basic.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { CacheKey } from '../common'; import { RequiredType, RuleOp } from './enums'; @@ -72,7 +72,12 @@ function notContains(seed: string): EmittableRule { } function matches(pattern: string | RegExp, modifiers?: string): EmittableRule { - const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, modifiers); + // 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, @@ -202,7 +207,7 @@ const isBooleanString = makeRule({ }); interface IsNumberStringOptions { - no_symbols?: boolean; + noSymbols?: boolean; } const NO_SYMBOLS_RE = /^[0-9]+$/; @@ -212,7 +217,7 @@ const NO_SYMBOLS_RE = /^[0-9]+$/; const NUMERIC_STRING_RE = /^[+-]?(?:[0-9]*\.)?[0-9]+$/; function isNumberString(options?: IsNumberStringOptions): EmittableRule { - const noSymbols = options?.no_symbols ?? false; + const noSymbols = options?.noSymbols ?? false; const re = noSymbols ? NO_SYMBOLS_RE : NUMERIC_STRING_RE; return makeStringRule( @@ -223,7 +228,7 @@ function isNumberString(options?: IsNumberStringOptions): EmittableRule { return `if (!re[${i}].test(${varName})) ${ctx.fail('isNumberString')};`; }, RequiredType.String, - { no_symbols: noSymbols }, + { noSymbols }, ); } diff --git a/src/rules/string-encoding.spec.ts b/src/rules/string-encoding.spec.ts index af67fa0..566f236 100644 --- a/src/rules/string-encoding.spec.ts +++ b/src/rules/string-encoding.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { isHexadecimal, isOctal, isHexColor, isRgbColor, isHSL, isBase32, isBase58, isBase64 } from './string'; @@ -205,6 +205,14 @@ describe('isBase64', () => { 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); diff --git a/src/rules/string-encoding.ts b/src/rules/string-encoding.ts index ad414c4..ac9a53f 100644 --- a/src/rules/string-encoding.ts +++ b/src/rules/string-encoding.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; @@ -115,7 +115,9 @@ const isBase58 = makeStringRule( // 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}$/; +// 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; @@ -132,7 +134,7 @@ function isBase64(options?: IsBase64Options): EmittableRule { return `if (!re[${i}].test(${varName})) ${ctx.fail('isBase64')};`; }, RequiredType.String, - { urlSafe: options?.urlSafe }, + options?.urlSafe !== undefined ? { urlSafe: options.urlSafe } : {}, ); } diff --git a/src/rules/string-finance.spec.ts b/src/rules/string-finance.spec.ts index 6ed6257..a7cd48f 100644 --- a/src/rules/string-finance.spec.ts +++ b/src/rules/string-finance.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { isISBN, diff --git a/src/rules/string-finance.ts b/src/rules/string-finance.ts index 842eec1..4e552a6 100644 --- a/src/rules/string-finance.ts +++ b/src/rules/string-finance.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; @@ -136,9 +136,9 @@ const isISIN = makeStringRule('isISIN', validateISINStr, (varName, ctx) => { 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;` + + `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')}; }` ); @@ -157,7 +157,8 @@ function validateISSN(value: string, options?: IsISSNOptions): boolean { if (!re.test(s)) { return false; } - const digits = s.replace(/-/g, ''); + // `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); @@ -176,15 +177,16 @@ function isISSN(options?: IsISSNOptions): EmittableRule { return makeRule({ name: 'isISSN', requiresType: RequiredType.String, - constraints: { requireHyphen: options?.requireHyphen }, + 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=issn.replace(/-/g,''),iss=0;` + + `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')};}}` @@ -250,7 +252,7 @@ function isCurrency(): EmittableRule { ); } -// Credit Card — Luhn algorithm (§4.8 C) +// Credit Card — Luhn algorithm function luhn(str: string): boolean { const s = str.replace(/[\s-]/g, ''); if (s.length === 0 || !/^\d+$/.test(s)) { @@ -404,7 +406,7 @@ function isIBAN(options?: IsIBANOptions): EmittableRule { return makeRule({ name: 'isIBAN', requiresType: RequiredType.String, - constraints: { allowSpaces: options?.allowSpaces }, + constraints: { allowSpaces }, validate: validateIban, emit: (varName: string, ctx: EmitContext): string => { const baseRi = ctx.addRegex(/^[A-Z]{2}\d{2}[A-Z0-9]+$/); @@ -424,7 +426,7 @@ function isIBAN(options?: IsIBANOptions): EmittableRule { }); } -// isISO4217CurrencyCode — ISO 4217 currency code set (§4.8 C: ref-based) +// isISO4217CurrencyCode — ISO 4217 currency code set (ref-based) const ISO4217_CODES = new Set([ 'AED', diff --git a/src/rules/string-format.spec.ts b/src/rules/string-format.spec.ts index a293ce4..861f593 100644 --- a/src/rules/string-format.spec.ts +++ b/src/rules/string-format.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { isEmail, @@ -237,9 +237,9 @@ describe('isMACAddress', () => { expect(isMACAddress().ruleName).toBe('isMACAddress'); }); - it('should generate no-separator regex check code when emit() is called with no_separators:true', () => { + it('should generate no-separator regex check code when emit() is called with noSeparators:true', () => { const { ctx, addRegexMock, failMock } = makeCtx(0); - const code = isMACAddress({ no_separators: true }).emit('v', ctx); + const code = isMACAddress({ noSeparators: true }).emit('v', ctx); expect(addRegexMock).toHaveBeenCalledTimes(1); expect(code).toBeTruthy(); expect(failMock).toHaveBeenCalledWith('isMACAddress'); @@ -305,6 +305,10 @@ describe('isLocale', () => { 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); @@ -859,6 +863,18 @@ describe('isBtcAddress', () => { 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); }); diff --git a/src/rules/string-format.ts b/src/rules/string-format.ts index 32db996..4d3cb1c 100644 --- a/src/rules/string-format.ts +++ b/src/rules/string-format.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; @@ -74,7 +74,7 @@ function isUUID(version?: 1 | 2 | 3 | 4 | 5 | 'all'): EmittableRule { 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?)$/; + /^(?:[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({ @@ -111,7 +111,7 @@ function isIP(version?: 4 | 6): EmittableRule { // MAC Address interface IsMACAddressOptions { - no_separators?: boolean; + noSeparators?: boolean; } const MAC_COLON_RE = /^[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}$/; @@ -119,21 +119,22 @@ 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: { no_separators: options?.no_separators }, + constraints: { noSeparators }, validate: value => { if (typeof value !== 'string') { return false; } - if (options?.no_separators) { + 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 (options?.no_separators) { + if (noSeparators) { const i = ctx.addRegex(MAC_NO_SEP_RE); return `if (!re[${i}].test(${varName})) ${ctx.fail('isMACAddress')};`; } @@ -169,8 +170,9 @@ function isLatLong(): EmittableRule { ); } -// 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})*$/; +// 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), @@ -193,15 +195,15 @@ const isDataURI = makeStringRule( // FQDN interface IsFQDNOptions { - require_tld?: boolean; - allow_underscores?: boolean; - allow_trailing_dot?: boolean; + requireTld?: boolean; + allowUnderscores?: boolean; + allowTrailingDot?: 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 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-]+$/; @@ -222,7 +224,8 @@ function isFQDN(options?: IsFQDNOptions): EmittableRule { } if (requireTld) { const tld = parts[parts.length - 1]; - if (!tld || tld.length < 2 || !/^[a-zA-Z]{2,}$/.test(tld)) { + // `/^[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; } } @@ -243,11 +246,7 @@ function isFQDN(options?: IsFQDNOptions): EmittableRule { 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, - }, + constraints: { requireTld, allowUnderscores, allowTrailingDot }, validate: validateFqdn, emit: (varName: string, ctx: EmitContext): string => { const ri = ctx.addRegex(partRe); @@ -268,7 +267,7 @@ function isFQDN(options?: IsFQDNOptions): EmittableRule { if (requireTld) { code += `if(fp.length<2)${ctx.fail('isFQDN')};`; code += `else{var tld=fp[fp.length-1];`; - code += `if(!tld||tld.length<2||!re[${tldRi}].test(tld))${ctx.fail('isFQDN')};`; + code += `if(!tld||!re[${tldRi}].test(tld))${ctx.fail('isFQDN')};`; code += `else{${loopBlock}}`; // close tld inner else block code += '}'; // close tld outer else block } else { @@ -368,7 +367,7 @@ function isByteLength(min: number, max?: number): EmittableRule { }); } -// isHash — per-algorithm hex regex (§4.8 B: regex inline) +// isHash — per-algorithm hex regex (regex inline) const HASH_REGEXES: Record = { md5: /^[a-f0-9]{32}$/i, @@ -407,7 +406,7 @@ function isHash(algorithm: string): EmittableRule { }); } -// isRFC3339 — RFC 3339 datetime (§4.8 B) +// isRFC3339 — RFC 3339 datetime const RFC3339_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i; @@ -420,7 +419,7 @@ const isRFC3339 = makeStringRule( }, ); -// isMilitaryTime — HH:MM 24-hour format (§4.8 B) +// isMilitaryTime — HH:MM 24-hour format const MILITARY_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; @@ -433,79 +432,46 @@ const isMilitaryTime = makeStringRule( }, ); -// 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')};}` - ); - }, -}); +// isLatitude / isLongitude — a number, or a strictly-numeric string, within [lo, hi] (requiresType none) -// isLongitude — string or number, -180 to 180 (requiresType none) +const NUMERIC_RANGE_RE = /^-?\d+(\.\d+)?$/; -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; +function rangeNumberOrString(name: string, lo: number, hi: number): EmittableRule { + const check = (value: unknown): boolean => { + if (typeof value === 'number') { + return value >= lo && value <= hi; } - if (!/^-?\d+(\.\d+)?$/.test(value)) { - return false; + 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 n >= -180 && n <= 180; - } - return false; + 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)};}` + ); + }, + }); } -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')};}` - ); - }, -}); +const isLatitude = rangeNumberOrString('isLatitude', -90, 90); +const isLongitude = rangeNumberOrString('isLongitude', -180, 180); -// isEthereumAddress — 0x + 40 hex chars (§4.8 B) +// isEthereumAddress — 0x + 40 hex chars const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; @@ -518,11 +484,13 @@ const isEthereumAddress = makeStringRule( }, ); -// isBtcAddress — P2PKH (1...), P2SH (3...), bech32 (bc1...) (§4.8 B) +// isBtcAddress — P2PKH (1...), P2SH (3...), bech32 (bc1.../tb1...) 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}$/; +// 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', @@ -535,7 +503,7 @@ const isBtcAddress = makeStringRule( }, ); -// isPhoneNumber — E.164 international phone number (§4.8 B) +// isPhoneNumber — E.164 international phone number const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/; @@ -548,7 +516,7 @@ const isPhoneNumber = makeStringRule( }, ); -// isStrongPassword — strong password check (§4.8 C: factory) +// isStrongPassword — strong password check (factory) interface IsStrongPasswordOptions { minLength?: number; @@ -624,7 +592,7 @@ function isStrongPassword(options?: IsStrongPasswordOptions): EmittableRule { }); } -// isTaxId — locale-specific tax identifier (§4.8 C: factory) +// isTaxId — locale-specific tax identifier (factory) const TAX_ID_REGEXES: Record = { US: /^\d{2}-\d{7}$/, // EIN format: XX-XXXXXXX diff --git a/src/rules/string-identifier.spec.ts b/src/rules/string-identifier.spec.ts index 4851552..0037100 100644 --- a/src/rules/string-identifier.spec.ts +++ b/src/rules/string-identifier.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 { isISO8601, @@ -132,6 +132,10 @@ describe('isISO31661Alpha3', () => { 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); diff --git a/src/rules/string-identifier.ts b/src/rules/string-identifier.ts index 93acec3..7c1bcdf 100644 --- a/src/rules/string-identifier.ts +++ b/src/rules/string-identifier.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; @@ -52,16 +52,21 @@ function isISO8601(options?: IsISO8601Options): EmittableRule { 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 dm=${varName}.match(/^(\\d{4})-(\\d{2})(?:-(\\d{2}))?/);` + + `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){${ctx.fail('isISO8601')}}` + + `if(mo<1||mo>12){__iso_ok=false;}` + `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(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)${ctx.fail('isISO8601')};} }` + `if(hh<0||hh>23||mm<0||mm>59||ss<0||ss>60)__iso_ok=false;}}` + + `if(!__iso_ok)${ctx.fail('isISO8601')};}` ); }, }); @@ -363,7 +368,6 @@ const ISO31661A3_CODES = new Set([ 'ALA', 'ALB', 'AND', - 'ANT', 'ARE', 'ARG', 'ARM', diff --git a/src/rules/string-shared.ts b/src/rules/string-shared.ts index 3f5cdaf..7939f05 100644 --- a/src/rules/string-shared.ts +++ b/src/rules/string-shared.ts @@ -1,4 +1,4 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; diff --git a/src/rules/string-width.spec.ts b/src/rules/string-width.spec.ts index e1d41a5..ffd67ff 100644 --- a/src/rules/string-width.spec.ts +++ b/src/rules/string-width.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 { isFullWidth, isHalfWidth, isVariableWidth, isMultibyte, isSurrogatePair } from './string'; diff --git a/src/rules/typechecker.spec.ts b/src/rules/typechecker.spec.ts index 5a65214..27f4e0c 100644 --- a/src/rules/typechecker.spec.ts +++ b/src/rules/typechecker.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; -import type { EmitContext } from './types'; +import type { EmitContext } from './interfaces'; import { isString, @@ -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 892c938..e91239d 100644 --- a/src/rules/typechecker.ts +++ b/src/rules/typechecker.ts @@ -1,10 +1,10 @@ -import type { EmitContext, EmittableRule } from './types'; +import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── -// isString — typeof check (§4.8 A: operator inline) +// isString — typeof check (operator inline) // ───────────────────────────────────────────────────────────────────────────── export const isString = makeRule({ @@ -15,7 +15,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 +29,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 +67,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 +77,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 ' : ''}{ let exp=${varName}.toExponential().split('e'); let mant=(exp[0].split('.')[1]||'').length; let exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; } return code; } @@ -80,7 +89,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 { let exp=${varName}.toExponential().split('e'); let mant=(exp[0].split('.')[1]||'').length; let exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; } return code; }, @@ -88,7 +97,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { } // ───────────────────────────────────────────────────────────────────────────── -// isBoolean — typeof check (§4.8 A) +// isBoolean — typeof check // ───────────────────────────────────────────────────────────────────────────── export const isBoolean = makeRule({ @@ -99,7 +108,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 +120,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 +154,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 +169,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 +180,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({ diff --git a/src/rules/types.ts b/src/rules/types.ts index 28d1e22..a57581c 100644 --- a/src/rules/types.ts +++ b/src/rules/types.ts @@ -1,66 +1,13 @@ -import type { CacheKey } from '../common'; -// Documented single upward type-only edge `rules → seal` (visitor: EmitContext.addExecutor). -// Kept as a deep import (not via `../seal` barrel) to avoid a runtime cycle through seal. -import type { SealedExecutors } from '../seal/types'; -import type { RuleOp, RulePlanCheckKind, RulePlanExprKind, RequiredType } from './enums'; - -// ───────────────────────────────────────────────────────────────────────────── -// 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; -} +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; object: RulePlanExpr; property: 'length' } - | { kind: RulePlanExprKind.Call0; object: RulePlanExpr; method: 'getTime' } + | { 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[] }; - -export interface RulePlan { - cacheKey?: CacheKey; - failure: RulePlanCheck; -} diff --git a/src/runtime/check-call-options.ts b/src/runtime/check-call-options.ts index 7327395..c6d3145 100644 --- a/src/runtime/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -1,16 +1,13 @@ import type { RuntimeOptions } from '../common'; import { BakerError } from '../common'; +import { BAKER_CONFIG_KEYS } from '../config'; 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 aliases they normalize to. 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) + ...BAKER_CONFIG_KEYS, 'enableImplicitConversion', 'exposeDefaultValues', 'whitelist', @@ -41,6 +38,13 @@ export function checkCallOptions(opts: unknown): RuntimeOptions | undefined { } 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/deserialize.spec.ts b/src/runtime/deserialize.spec.ts index e5afdbe..40cca24 100644 --- a/src/runtime/deserialize.spec.ts +++ b/src/runtime/deserialize.spec.ts @@ -2,7 +2,7 @@ import { err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; import type { RuntimeOptions } from '../common/interfaces'; -import type { SealedExecutors } from '../seal/types'; +import type { SealedExecutors } from '../seal/interfaces'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { Baker } from '../baker'; diff --git a/src/runtime/deserialize.ts b/src/runtime/deserialize.ts index bafc90f..05d25af 100644 --- a/src/runtime/deserialize.ts +++ b/src/runtime/deserialize.ts @@ -1,5 +1,6 @@ import { isErr } from '@zipbul/result'; +import type { Result } from '@zipbul/result'; import type { RuntimeOptions, BakerIssue, BakerIssueSet } from '../common'; import type { SealedExecutors } from '../seal'; @@ -7,9 +8,18 @@ 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 +27,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; - }); - } - const result = sealed.deserialize(input, checkedOpts); - if (isErr(result)) { - return toBakerIssueSet(result.data as BakerIssue[]); + return Promise.resolve(sealed.deserialize(input, checkedOpts)).then(r => unwrapDeserialize(r)); } - return result as T; + return unwrapDeserialize(sealed.deserialize(input, checkedOpts)); } function runDeserializeSync( @@ -41,11 +42,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 +52,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/serialize.spec.ts b/src/runtime/serialize.spec.ts index e24add9..6d4d6ca 100644 --- a/src/runtime/serialize.spec.ts +++ b/src/runtime/serialize.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import type { RuntimeOptions } from '../common/interfaces'; -import type { SealedExecutors } from '../seal/types'; +import type { SealedExecutors } from '../seal/interfaces'; import { Baker } from '../baker'; import { Field } from '../decorators/field'; diff --git a/src/runtime/serialize.ts b/src/runtime/serialize.ts index 674d712..8d4d714 100644 --- a/src/runtime/serialize.ts +++ b/src/runtime/serialize.ts @@ -5,7 +5,7 @@ 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/runtime/validate.ts b/src/runtime/validate.ts index 3f85bd8..954c1b8 100644 --- a/src/runtime/validate.ts +++ b/src/runtime/validate.ts @@ -5,9 +5,14 @@ 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, @@ -15,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( @@ -33,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( @@ -44,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-analysis.ts b/src/seal/async-analysis.ts deleted file mode 100644 index bfab47c..0000000 --- a/src/seal/async-analysis.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { RawClassMeta, RawPropertyMeta } from '../metadata'; -import type { SealedExecutors } from './types'; - -import { Direction, isAsyncFunction } from '../common'; -import { PRIMITIVE_CTORS } from './constants'; -import { mergeInheritance } from './merge-inheritance'; - -// ───────────────────────────────────────────────────────────────────────────── -// analyzeAsync — static analysis to determine if a sealed DTO requires an async executor (C1) -// ───────────────────────────────────────────────────────────────────────────── - -export 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; -} - -/** - * 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. - */ -export 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); - } - 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); - } - } else { - const resolved = Array.isArray(result) ? (result as unknown[])[0] : result; - if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { - out.push(resolved as Function); - } - } - } - return out; -} diff --git a/src/seal/async-analyzer.ts b/src/seal/async-analyzer.ts new file mode 100644 index 0000000..e375cab --- /dev/null +++ b/src/seal/async-analyzer.ts @@ -0,0 +1,104 @@ +import type { RawClassMeta, RawPropertyMeta } from '../metadata'; +import type { SealedExecutors } from './interfaces'; +import type { InheritanceMerger } from './inheritance-merger'; + +import { Direction, isAsyncFunction } from '../common'; +import { PRIMITIVE_CTORS } from './constants'; + +/** + * 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 result = t.fn(); + if (result === Map || result === Set) { + const cv = t.collectionValue?.(); + if (typeof cv === 'function' && !PRIMITIVE_CTORS.has(cv)) { + out.push(cv); + } + } else { + const resolved = Array.isArray(result) ? (result as unknown[])[0] : result; + 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 e075aac..1906c15 100644 --- a/src/seal/circular-analyzer.spec.ts +++ b/src/seal/circular-analyzer.spec.ts @@ -1,10 +1,13 @@ import { describe, it, expect, afterEach } from 'bun:test'; import type { ClassCtor } from '../common/types'; -import type { RawClassMeta } from '../metadata/types'; +import type { RawClassMeta } from '../metadata/interfaces'; -import { setRaw } from '../metadata/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 @@ -53,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); }); @@ -65,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); }); @@ -83,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); }); @@ -98,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); }); @@ -113,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); }); @@ -147,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: [], @@ -177,7 +200,7 @@ describe('analyzeCircular', () => { }); // Act - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert expect(result).toBe(true); }); @@ -189,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); }); @@ -201,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); }); @@ -218,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' }, @@ -241,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); @@ -254,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' }, @@ -268,7 +291,7 @@ describe('analyzeCircular', () => { ); // Act - const result = analyzeCircular(ADto); + const result = analyzer.analyze(ADto); // Assert — no cycle expect(result).toBe(false); @@ -279,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'); @@ -300,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 8834cf5..1579b08 100644 --- a/src/seal/circular-analyzer.ts +++ b/src/seal/circular-analyzer.ts @@ -1,27 +1,42 @@ +import type { InheritanceMerger } from './inheritance-merger'; + import { BakerError } from '../common'; -import { getRaw } from '../metadata'; /** - * 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 +44,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)) { + if (typeof nested === 'function' && walk(nested)) { return true; } } @@ -50,18 +65,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.ts b/src/seal/circular-placeholder.ts index ed33294..f8190cd 100644 --- a/src/seal/circular-placeholder.ts +++ b/src/seal/circular-placeholder.ts @@ -1,21 +1,36 @@ -import type { SealedExecutors } from './types'; +import type { RuntimeOptions } from '../common'; +import type { SealedExecutors } from './interfaces'; import { BakerError } from '../common'; -/** @internal Placeholder executor for circular dependency detection during seal */ -export 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, +/** + * @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/compile-cache.spec.ts b/src/seal/compile-cache.spec.ts index 77d1739..0e8cd41 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 './compile-cache'; +import { CompileCache, 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 index f2da0fc..961b1f8 100644 --- a/src/seal/compile-cache.ts +++ b/src/seal/compile-cache.ts @@ -1,5 +1,4 @@ -import type { SealOptions } from './interfaces'; -import type { SealedExecutors } from './types'; +import type { SealOptions, SealedExecutors } from './interfaces'; // ───────────────────────────────────────────────────────────────────────────── // (class, config) executor cache — content-addressed sharing across bakers @@ -16,45 +15,59 @@ import type { SealedExecutors } from './types'; * 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. */ -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. */ -export 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') - ); -} +class CompileCache { + #cache: WeakMap>>; -export function getCached(cls: Function, fp: string): SealedExecutors | undefined { - return compileCache.get(cls)?.get(fp); -} + constructor() { + this.#cache = new WeakMap(); + } -export function setCached(cls: Function, fp: string, exec: SealedExecutors): void { - let m = compileCache.get(cls); - if (m === undefined) { - m = new Map(); - compileCache.set(cls, m); + /** + * 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. + */ + static fingerprint(o: SealOptions): string { + return ( + (o.enableImplicitConversion ? '1' : '0') + + (o.exposeDefaultValues ? '1' : '0') + + (o.stopAtFirstError ? '1' : '0') + + (o.whitelist ? '1' : '0') + + (o.debug ? '1' : '0') + ); } - m.set(fp, exec); -} -/** Test-only: drop a single class's cached executors so a re-seal recompiles it. */ -export function clearCached(cls: Function): void { - compileCache.delete(cls); -} + get(cls: Function, fp: string): SealedExecutors | undefined { + return this.#cache.get(cls)?.get(fp); + } -/** - * 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). - */ -export function clearAllCached(): void { - compileCache = new WeakMap(); + 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 { CompileCache }; +export const compileCache = new CompileCache(); diff --git a/src/seal/constants.ts b/src/seal/constants.ts index f6e175e..e151876 100644 --- a/src/seal/constants.ts +++ b/src/seal/constants.ts @@ -1,2 +1,79 @@ /** Built-in constructors that are NOT treated as nested DTOs during seal. */ export const PRIMITIVE_CTORS = new Set([Number, String, Boolean, Date]); + +/** + * 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 a5055f1..91c6b84 100644 --- a/src/seal/deserialize-builder.spec.ts +++ b/src/seal/deserialize-builder.spec.ts @@ -2,10 +2,9 @@ import { isErr, err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; import type { BakerIssue } from '../common/errors'; -import type { SealOptions } from './interfaces'; -import type { RawClassMeta } from '../metadata/types'; -import type { EmittableRule } from '../rules/types'; -import type { SealedExecutors } from './types'; +import type { SealOptions, SealedExecutors } from './interfaces'; +import type { RawClassMeta } from '../metadata/interfaces'; +import type { EmittableRule } from '../rules/interfaces'; import { assertIsErr } from '../../test/integration/helpers/assert'; import { isNotEmpty } from '../rules/common'; @@ -289,28 +288,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 () => { @@ -844,7 +821,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('../rules/types').EmitContext): string => '', + emit: (_varName: string, _ctx: import('../rules/interfaces').EmitContext): string => '', ruleName: 'alwaysPass', }); @@ -961,7 +938,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('../rules/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) diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index ef5938a..1ff1ae6 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -3,18 +3,18 @@ import type { Result, ResultAsync } from '@zipbul/result'; import { err as resultErr, isErr as resultIsErr } from '@zipbul/result'; import type { RuntimeOptions, BakerIssue } from '../common'; -import type { SealOptions } from './interfaces'; +import type { SealOptions, SealedExecutors, ChildScope, CategorizedRules, ResolvedTypeGate } from './interfaces'; +import type { DeserializeExecutor, ValidateExecutor } from './types'; import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata'; import type { EmitContext } from '../rules'; -import type { SealedExecutors } 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 type { CategorizedRules, ResolvedTypeGate, TypeGateConfig } from './deserialize-codegen'; +import { DES_GEN as GEN, PRIMITIVE_TYPE_HINTS, ASSERTER_TO_GATE, GATE_ONLY_ASSERTERS } from './constants'; +import type { TypeGateConfig } from './deserialize-codegen'; import { - GEN, nestedErrPush, nestedErrReturn, toVarName, @@ -23,21 +23,15 @@ import { wrapGroupsGuard, sameGroups, generateConversionCode, - PRIMITIVE_TYPE_HINTS, - ASSERTER_TO_GATE, - GATE_ONLY_ASSERTERS, categorizeRules, generateNestedResultCode, generateValidateNestedResult, } from './deserialize-codegen'; // ───────────────────────────────────────────────────────────────────────────── -// DeserializeBuilder — new Function-based executor generation (§4.9) +// DeserializeBuilder — new Function-based executor generation // ───────────────────────────────────────────────────────────────────────────── -type DeserializeExecutor = (input: unknown, opts?: RuntimeOptions) => Result | ResultAsync; -type ValidateExecutor = (input: unknown, opts?: RuntimeOptions) => BakerIssue[] | null | Promise; - /** * 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 @@ -63,6 +57,15 @@ class DeserializeBuilder { 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) */ @@ -102,6 +105,7 @@ class DeserializeBuilder { this.regexes = scope.regexes; this.refs = scope.refs; this.execs = scope.execs; + this.inlineCounter = scope.inlineCounter; if (scope.inlineNestedClasses) { this.inlineNestedClasses = scope.inlineNestedClasses; } @@ -114,6 +118,7 @@ class DeserializeBuilder { this.regexes = []; this.refs = []; this.execs = []; + this.inlineCounter = { n: 0 }; } } @@ -127,6 +132,7 @@ class DeserializeBuilder { regexes: this.regexes, refs: this.refs, execs: this.execs, + inlineCounter: this.inlineCounter, inlineNestedClasses: this.inlineNestedClasses, pathPrefix, varPrefix, @@ -159,7 +165,7 @@ class DeserializeBuilder { body += `var ${GEN.errList} = [];\n`; } - // preamble: input type guard (§4.9) + // 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 @@ -178,7 +184,7 @@ class DeserializeBuilder { body += `try {\n`; } - // Whitelist check (§7.2) — reject undeclared fields + // Whitelist check — reject undeclared fields if (options?.whitelist) { const allowedKeys = new Set(); for (const [fieldKey, meta] of Object.entries(merged)) { @@ -198,7 +204,7 @@ class DeserializeBuilder { } } - // Groups variable — only when expose groups or validation rule groups exist (§4.9, §M4). + // 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) { @@ -244,7 +250,7 @@ class DeserializeBuilder { body += `} finally { __seen.delete(input); }\n`; } - // sourceURL (§4.9) + // 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`; @@ -333,7 +339,7 @@ class DeserializeBuilder { extractCode = `var ${varName} = ${inputObj}[${extractKeyJson}];\n`; } - // groups check wrap (§4.5) + // groups check wrap let fieldStart = ''; let fieldEnd = ''; if (exposeGroups && exposeGroups.length > 0) { @@ -344,14 +350,14 @@ class DeserializeBuilder { // inner content (extract + optional guard + validation + assign) let innerCode = extractCode; - // ② null/undefined guard — @IsOptional, @IsNullable, @IsDefined combinations (§4.3, Phase5) - const useOptionalGuard = !!(meta.flags.isOptional && !meta.flags.isDefined); + // ② null/undefined guard — optional / nullable combinations + const useOptionalGuard = meta.flags.isOptional === true; 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 guardKey = resolveGuardKey(isNullable, useOptionalGuard, meta.flags.isDefined ?? false); + const guardKey = resolveGuardKey(isNullable, useOptionalGuard); innerCode += GUARD_STRATEGIES[guardKey]({ varName, emitCtx, assignNull, validationCode }); // ① @ValidateIf outer wrap @@ -377,7 +383,7 @@ class DeserializeBuilder { let code = ''; - // @Transform (deserialize direction) — before validation (§4.3 ⑤) + // @Transform (deserialize direction) — before validation const dsTransforms = meta.transform.filter(td => !td.options?.serializeOnly); if (dsTransforms.length > 0) { const fkJson = JSON.stringify(fieldKey); @@ -417,7 +423,7 @@ class DeserializeBuilder { return code; } - // @ValidateNested + @Type (§8.1) + // @ValidateNested + @Type if (meta.flags.validateNested && meta.type?.fn) { code += this.validateOnly ? this.generateNestedCodeValidateOnly(fieldKey, varName, meta, emitCtx) @@ -534,7 +540,7 @@ class DeserializeBuilder { timeCount += 1; } } - const sk = sanitizeKey(fieldKey); + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); const lengthVar = lengthCount > 1 ? `${GEN.arr}${sk}len` : null; const timeVar = timeCount > 1 ? `${GEN.arr}${sk}time` : null; @@ -572,7 +578,7 @@ class DeserializeBuilder { return code; } - // ── buildRulesCode — type guard + marker pattern (§4.3, §4.10) ── + // ── buildRulesCode — type guard + marker pattern ── // Decomposed into: categorizeRules → resolveTypeGate → emitTypedRules / emitGeneralRules / emitEachRules /** resolveTypeGate — determine effective gate type from asserters/conversion/type hints */ @@ -640,7 +646,7 @@ class DeserializeBuilder { fieldGroups?: string[], ): string { let code = ''; - const sk = sanitizeKey(fieldKey); // cached — was called up to 4× in this function before + 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; @@ -656,14 +662,14 @@ class DeserializeBuilder { return this.emitRuleList(fieldKey, varName, rules, emitCtx, indent, fieldGroups, true); }; - if (collectErrors) { - const canConvert = - enableConversion && - (effectiveGateType === 'string' || - effectiveGateType === 'number' || - effectiveGateType === 'boolean' || - effectiveGateType === 'date'); + const canConvert = + enableConversion && + (effectiveGateType === 'string' || + effectiveGateType === 'number' || + effectiveGateType === 'boolean' || + effectiveGateType === 'date'); + if (collectErrors) { if (canConvert) { // Conversion mode: try convert on gate failure, skip field if conversion fails const skipVar = `${GEN.skip}${sk}`; @@ -695,13 +701,6 @@ class DeserializeBuilder { 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); @@ -732,6 +731,7 @@ class DeserializeBuilder { fieldGroups?: string[], ): string { let code = ''; + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); if (collectErrors) { if (generalRules.length === 0) { @@ -741,7 +741,7 @@ class DeserializeBuilder { } else if (this.validateOnly) { code += this.emitRuleList(fieldKey, varName, generalRules, emitCtx, '', fieldGroups); } else { - const markVar = `${GEN.mark}${sanitizeKey(fieldKey)}`; + 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`; @@ -773,7 +773,7 @@ class DeserializeBuilder { // 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 = sanitizeKey(fieldKey); + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); const iVar = `${GEN.index}${sk}`; const siVar = `${GEN.setIdx}${sk}`; const svVar = `${GEN.setVal}${sk}`; @@ -943,18 +943,32 @@ class DeserializeBuilder { return code; } + /** + * 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; + } + // ── generateCollectionCode — Map/Set auto conversion ── private generateCollectionCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { const { collectErrors, execs } = this; - const sk = sanitizeKey(fieldKey); - const collection = meta.type!.collection!; + 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 (meta.type!.resolvedCollectionValue) { - const nestedSealed = this.resolve(meta.type!.resolvedCollectionValue) as SealedExecutors; + if (type.resolvedCollectionValue) { + const nestedSealed = this.resolveExecutor(type.resolvedCollectionValue); execIdx = execs.length; execs.push(nestedSealed); } @@ -1078,7 +1092,7 @@ class DeserializeBuilder { return code; } - // ── generateNestedCode — @ValidateNested + @Type (§8.1, §8.2) ── + // ── generateNestedCode — @ValidateNested + @Type ── private generateNestedCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { const { collectErrors, execs } = this; @@ -1088,17 +1102,17 @@ class DeserializeBuilder { } let code = ''; - const sk = sanitizeKey(fieldKey); + const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); if (meta.type.discriminator) { - // §8.3 discriminator + // 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.resolve(sub.value) as SealedExecutors | undefined; + const nestedSealed = this.resolveExecutor(sub.value); const execIdx = execs.length; - execs.push(nestedSealed as SealedExecutors); + execs.push(nestedSealed); const awaitKwD = this.isAsync ? 'await ' : ''; code += ` case ${JSON.stringify(sub.name)}:\n`; code += ` var ${GEN.result}${sk} = ${awaitKwD}execs[${execIdx}].deserialize(${varName}, opts);\n`; @@ -1116,17 +1130,18 @@ class DeserializeBuilder { 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) - if (meta.type.keepDiscriminatorProperty) { + // 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`; } } else { - // §8.1 simple nested or §8.2 each array + // simple nested or each array const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); - const nestedSealed = this.resolve(nestedCls) as SealedExecutors | undefined; + const nestedSealed = this.resolveExecutor(nestedCls); const execIdx = execs.length; - execs.push(nestedSealed as SealedExecutors); + 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); @@ -1199,7 +1214,10 @@ class DeserializeBuilder { const inlinedSet = this.inlineNestedClasses!; inlinedSet.add(nestedClass); - const child = this.createChild(pathPrefixExpr, varPrefix, inputExpr); + // 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)) { @@ -1229,7 +1247,7 @@ class DeserializeBuilder { 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.resolve(sub.value) as SealedExecutors; + 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`; @@ -1257,7 +1275,7 @@ class DeserializeBuilder { code += `}\n`; } else { const nestedCls = meta.type.resolvedClass ?? (meta.type.fn() as Function); - const nestedSealed = this.resolve(nestedCls) as SealedExecutors; + const nestedSealed = this.resolveExecutor(nestedCls); const nestedMerged = nestedSealed.merged; const hasEach = meta.type.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); @@ -1360,7 +1378,8 @@ class DeserializeBuilder { ): string { const { collectErrors, execs } = this; const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); - const collection = meta.type!.collection!; + const type = meta.type!; + const collection = type.collection!; const awaitKw = this.isAsync ? 'await ' : ''; if (!this.inlineNestedClasses) { @@ -1371,9 +1390,9 @@ class DeserializeBuilder { let nestedCls: Function | undefined; let nestedSealed: SealedExecutors | undefined; let nestedMerged: RawClassMeta | undefined; - if (meta.type!.resolvedCollectionValue) { - nestedCls = meta.type!.resolvedCollectionValue; - nestedSealed = this.resolve(nestedCls) as SealedExecutors; + if (type.resolvedCollectionValue) { + nestedCls = type.resolvedCollectionValue; + nestedSealed = this.resolveExecutor(nestedCls); nestedMerged = nestedSealed.merged; } const useInline = nestedCls && nestedMerged && !this.inlineNestedClasses.has(nestedCls); @@ -1443,16 +1462,20 @@ class DeserializeBuilder { const eachRules = meta.validation.filter(rd => rd.each); if (eachRules.length > 0) { const eiVar = `${GEN.index}${sk}e`; + const prefixVar = `__bk$ep_${sk}`; code += ` for (var ${eiVar}=0; ${eiVar}<${varName}.length; ${eiVar}++) {\n`; + // Declare the shared path-prefix var on the first each-rule only (a local flag, not a scan of + // the generated text — `var` hoists, so one declaration serves every rule in this loop). + let prefixDeclared = false; for (const rd of eachRules) { - const prefixVar = `__bk$ep_${sk}`; const extra = this.computeRuleExtras(rd, fieldKey, varName); 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}`)) { + if (!prefixDeclared) { + prefixDeclared = true; const prefixInit = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['` : `${JSON.stringify(fieldKey)}+'['`; @@ -1561,19 +1584,6 @@ class DeserializeBuilder { } } -/** Writable view of the builder's data fields — used to populate a child instance created via - * Object.create (bypassing the constructor so reference arrays can be shared). */ -/** 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. */ -interface ChildScope { - regexes: RegExp[]; - refs: unknown[]; - execs: SealedExecutors[]; - inlineNestedClasses: Set | undefined; - pathPrefix: string; - varPrefix: string; - inputExpr: string; -} // ───────────────────────────────────────────────────────────────────────────── // Exported entry functions — thin wrappers over DeserializeBuilder (signatures unchanged) diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts index b42ef7f..1e8cca8 100644 --- a/src/seal/deserialize-codegen.ts +++ b/src/seal/deserialize-codegen.ts @@ -1,36 +1,12 @@ 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'; -// ───────────────────────────────────────────────────────────────────────────── -// Generated variable name prefixes — centralised to prevent typo-related bugs -// ───────────────────────────────────────────────────────────────────────────── - -export 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; - // ───────────────────────────────────────────────────────────────────────────── // Helpers — code generation utilities (pure, module-level) // ───────────────────────────────────────────────────────────────────────────── @@ -52,11 +28,14 @@ export function nestedErrPush(errList: string, pathExpr: string, errItemExpr: st /** 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 ( - `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` + + `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}]`)}` ); } @@ -73,22 +52,23 @@ export function toVarName(key: string, prefix?: string): string { // nullable/optional guard — truth-table strategy pattern (D-3) // ───────────────────────────────────────────────────────────────────────────── -export function resolveGuardKey(isNullable: boolean, useOptionalGuard: boolean, isDefined: boolean): GuardKey { +export function resolveGuardKey(isNullable: boolean, useOptionalGuard: boolean): GuardKey { if (isNullable && useOptionalGuard) { return GuardKey.NullableOptional; } if (isNullable) { return GuardKey.Nullable; } - if (isDefined) { - return GuardKey.Defined; - } 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; @@ -97,7 +77,7 @@ export interface GuardParams { } export const GUARD_STRATEGIES: Record string> = { - // Case 4: @IsNullable + @IsOptional — assign null, skip undefined + // 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`; @@ -105,7 +85,7 @@ export const GUARD_STRATEGIES: Record string> = { code += '}\n'; return code; }, - // Case 3: @IsNullable (+ optional @IsDefined — same behavior) + // 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`; @@ -113,20 +93,14 @@ export const GUARD_STRATEGIES: Record string> = { 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 + // 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 + // 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`; @@ -137,7 +111,7 @@ export const GUARD_STRATEGIES: Record string> = { }; // ───────────────────────────────────────────────────────────────────────────── -// wrapGroupsGuard — per-rule validation groups check wrapper (§M4) +// wrapGroupsGuard — per-rule validation groups check wrapper // ───────────────────────────────────────────────────────────────────────────── /** @@ -200,36 +174,7 @@ export function generateConversionCode( } } -/** `@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']); - /** Result of categorizeRules — each/nonEach split and typed dependency classification */ -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; -} - /** 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 @@ -282,23 +227,6 @@ export function categorizeRules(fieldKey: string, validation: RawPropertyMeta['v return { each, generalRules, typedDeps: chosen }; } -/** 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; -} - /** Config object for emitTypedRules — bundles closure-captured vars into explicit parameter */ export interface TypeGateConfig { effectiveGateType: string; 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 de2ab5d..799de58 100644 --- a/src/seal/expose-validator.spec.ts +++ b/src/seal/expose-validator.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import type { RawClassMeta } from '../metadata/types'; +import type { RawClassMeta } from '../metadata/interfaces'; 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 7798781..28ca19c 100644 --- a/src/seal/expose-validator.ts +++ b/src/seal/expose-validator.ts @@ -1,9 +1,10 @@ import type { RawClassMeta, ExposeDef } from '../metadata'; 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 @@ -23,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.`, ); } } @@ -51,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 index f19e14f..261a33e 100644 --- a/src/seal/index.ts +++ b/src/seal/index.ts @@ -1,4 +1,3 @@ // Directory barrel — the compile stage's output, options, and entry point. -export type { SealedExecutors } from './types'; -export type { SealOptions } from './interfaces'; +export type { SealedExecutors, SealOptions } from './interfaces'; export { sealRegistry } from './seal'; diff --git a/src/seal/inheritance-merger.ts b/src/seal/inheritance-merger.ts new file mode 100644 index 0000000..5d33b3d --- /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 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 + */ +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 index 07ab5e8..4f01d5f 100644 --- a/src/seal/interfaces.ts +++ b/src/seal/interfaces.ts @@ -1,5 +1,10 @@ +import type { Result, ResultAsync } from '@zipbul/result'; + +import type { BakerIssue, RuntimeOptions } from '../common'; +import type { RawClassMeta, RuleDef } from '../metadata'; + // ───────────────────────────────────────────────────────────────────────────── -// SealOptions — seal-time options resolved from a Baker's config (§1.4) +// SealOptions — seal-time options resolved from a Baker's config // ───────────────────────────────────────────────────────────────────────────── export interface SealOptions { @@ -18,3 +23,74 @@ export interface SealOptions { /** 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 +// ───────────────────────────────────────────────────────────────────────────── + +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; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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/merge-inheritance.ts b/src/seal/merge-inheritance.ts deleted file mode 100644 index ceb7307..0000000 --- a/src/seal/merge-inheritance.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { RawClassMeta } from '../metadata'; - -import { getRaw, hasRawOwn } from '../metadata'; - -// ───────────────────────────────────────────────────────────────────────────── -// 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 - */ -export 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; -} diff --git a/src/seal/meta-validator.ts b/src/seal/meta-validator.ts new file mode 100644 index 0000000..428bf2d --- /dev/null +++ b/src/seal/meta-validator.ts @@ -0,0 +1,81 @@ +import type { RawClassMeta, MetaStore } from '../metadata'; + +import { CollectionType } from '../metadata'; +import { BakerError } from '../common'; +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 c3ef987..ec766f8 100644 --- a/src/seal/seal.spec.ts +++ b/src/seal/seal.spec.ts @@ -1,16 +1,20 @@ import { describe, it, expect, afterEach, spyOn } from 'bun:test'; -import type { RawClassMeta, RuleDef } from '../metadata/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 '../common/errors'; -import { setRaw } from '../metadata/meta-access'; +import { metaStore } from '../metadata'; import { min, max } from '../rules/number'; import { isString } from '../rules/typechecker'; -import { circularPlaceholder } from './circular-placeholder'; -import { mergeInheritance } from './merge-inheritance'; +import { CircularPlaceholder } from './circular-placeholder'; +import { InheritanceMerger } from './inheritance-merger'; +import { sealRegistry } from './seal'; + +const merger = new InheritanceMerger(metaStore); // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -51,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" @@ -62,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' }); @@ -75,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 }); @@ -88,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: [], @@ -112,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: [], @@ -131,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: [], @@ -151,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(() => { @@ -165,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' }); @@ -184,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 = { @@ -207,7 +211,7 @@ describe('sealClass', () => { flags: { validateNested: true }, }, }; - setRaw(AnimalContainerDto, raw); + metaStore.set(AnimalContainerDto, raw); // Act const b = sealClass(AnimalContainerDto); @@ -223,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: [], @@ -250,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: [], @@ -275,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: [], @@ -294,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: [], @@ -318,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 } }], @@ -343,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' }], @@ -374,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); @@ -385,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); }); @@ -405,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); @@ -423,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); @@ -440,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'); }); @@ -456,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'); @@ -473,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 }); }); @@ -490,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); }); @@ -506,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); }); @@ -523,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); }); @@ -541,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); @@ -554,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); }); @@ -568,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(); @@ -585,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); }); @@ -624,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); }); @@ -634,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); }); @@ -644,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); }); @@ -665,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); }); @@ -673,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); + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -688,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 }], @@ -700,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: [], @@ -736,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: [], @@ -747,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: [], @@ -760,7 +809,7 @@ describe('analyzeAsync — discriminator', () => { }); class CircParent {} - setRaw(CircParent, { + metaStore.set(CircParent, { child: { validation: [], transform: [], @@ -796,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: [], @@ -807,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: [], @@ -818,7 +867,7 @@ describe('analyzeAsync — discriminator', () => { flags: { validateNested: true }, }, }); - setRaw(DiscA, { + metaStore.set(DiscA, { child: { validation: [], transform: [], @@ -858,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 8bd20a4..52a54d1 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -1,21 +1,19 @@ -import type { SealOptions } from './interfaces'; +import type { SealOptions, SealedExecutors } from './interfaces'; import type { ClassCtor } from '../common'; -import type { SealedExecutors } from './types'; +import type { MetaStore } from '../metadata'; -import { CollectionType } from '../metadata'; +import { CollectionType, metaStore } from '../metadata'; import { Direction, BakerError } from '../common'; -import { analyzeAsync, nestedClassesOf } from './async-analysis'; -import { analyzeCircular } from './circular-analyzer'; -import { circularPlaceholder } from './circular-placeholder'; -import { configFingerprint, getCached, setCached } from './compile-cache'; -import { PRIMITIVE_CTORS } from './constants'; +import { AsyncAnalyzer } from './async-analyzer'; +import { CircularAnalyzer } from './circular-analyzer'; +import { CircularPlaceholder } from './circular-placeholder'; +import { CompileCache, compileCache } from './compile-cache'; +import { PRIMITIVE_CTORS, RESERVED_PROPERTY_NAMES } from './constants'; import { buildDeserializeCode, buildValidateCode } from './deserialize-builder'; import { validateExposeStacks } from './expose-validator'; -import { mergeInheritance } from './merge-inheritance'; +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']); /** * One seal operation. Holds the per-operation state — the calling Baker's executor map, the resolved @@ -29,15 +27,29 @@ const BANNED_FIELD_NAMES = new Set(['__proto__', 'constructor', 'prototype']); */ class SealRun { private readonly fp: string; - /** Classes compiled by THIS run (excludes cache hits) — committed to the shared cache on success. */ - private readonly sealed = new Set(); + /** 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 = configFingerprint(options); + 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); } /** @@ -50,21 +62,24 @@ class SealRun { this.sealOne(Class); } } 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. - this.executors.clear(); + // 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; } // Commit only the classes compiled by THIS run to the shared cache (cache hits are already there). - for (const Class of this.sealed) { - setCached(Class, this.fp, this.executors.get(Class)!); + for (const [Class, executor] of this.sealed) { + compileCache.set(Class, this.fp, executor); } registry.clear(); } // ─────────────────────────────────────────────────────────────────────────── - // sealOne() — seal an individual class (§4.1) + // sealOne() — seal an individual class // ─────────────────────────────────────────────────────────────────────────── private sealOne(Class: Function): void { @@ -75,16 +90,17 @@ class SealRun { } // Cache hit: another baker already compiled this class under the SAME config — reuse its executor. - const cached = getCached(Class, this.fp); + 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 nestedClassesOf(meta)) { + for (const nested of this.#async.nestedClassesOf(meta)) { this.sealOne(nested); } } @@ -93,22 +109,24 @@ class SealRun { } // 0. Register placeholder — prevent infinite recursion on circular references - const placeholder = circularPlaceholder(Class.name); + const placeholder = new CircularPlaceholder(Class.name); this.executors.set(Class, placeholder); + this.inserted.add(Class); try { // 1. Merge inheritance metadata - const merged = mergeInheritance(Class); + 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)) { + if (RESERVED_PROPERTY_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) + // Prevent original RAW mutation: copy the shared RAW `type` before mutating (C-16 root fix). + // `flags` is already cloned per-seal by mergeInheritance, so it is mutated in place below. for (const [key, meta] of Object.entries(merged)) { if (!meta.type?.fn) { continue; @@ -117,7 +135,9 @@ class SealRun { try { typeResult = meta.type.fn(); } catch (e) { - throw new BakerError(`${Class.name}.${key}: type function threw: ${(e as Error).message}`, { cause: e }); + throw new BakerError(`${Class.name}.${key}: type function threw: ${e instanceof Error ? e.message : String(e)}`, { + cause: e, + }); } // Detect Map/Set collection @@ -130,7 +150,9 @@ class SealRun { try { valCls = meta.type.collectionValue(); } catch (e) { - throw new BakerError(`${Class.name}.${key}: collectionValue function threw: ${(e as Error).message}`, { cause: e }); + throw new BakerError(`${Class.name}.${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; @@ -151,15 +173,13 @@ class SealRun { 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; - } + // 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 }; @@ -169,29 +189,23 @@ class SealRun { validateExposeStacks(merged, Class.name); // 2b. W2: seal-time invariant checks (D7 discriminator/Set·Map + D9 async-in-sync) - validateMeta(Class, merged); + this.#validator.validateShape(Class, merged); // 3. Static analysis for circular references - const needsCircularCheck = analyzeCircular(Class); + const needsCircularCheck = this.#circular.analyze(Class); - // 4. Seal nested @Type referenced DTOs first (recursive) — uses resolvedClass / resolvedCollectionValue + // 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)) { - if (meta.type?.resolvedClass) { - this.sealOne(meta.type.resolvedClass); - } - if (meta.type?.resolvedCollectionValue) { - this.sealOne(meta.type.resolvedCollectionValue); - } - if (meta.type?.discriminator) { - for (const sub of meta.type.discriminator.subTypes) { - this.sealOne(sub.value); - } + for (const nested of this.#async.nestedClassesOf(meta)) { + this.sealOne(nested); } } // 5. Async analysis - const isAsync = analyzeAsync(merged, Direction.Deserialize, this.resolve); - const isSerializeAsync = analyzeAsync(merged, Direction.Serialize, this.resolve); + 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); @@ -218,10 +232,10 @@ class SealRun { throw e; } - // Record success so the run 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. - this.sealed.add(Class); + // 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); } } diff --git a/src/seal/serialize-builder.spec.ts b/src/seal/serialize-builder.spec.ts index f0514f4..4802246 100644 --- a/src/seal/serialize-builder.spec.ts +++ b/src/seal/serialize-builder.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock } from 'bun:test'; import type { RuntimeOptions } from '../common/interfaces'; -import type { RawClassMeta } from '../metadata/types'; -import type { SealedExecutors } from './types'; +import type { RawClassMeta } from '../metadata/interfaces'; +import type { SealedExecutors } from './interfaces'; import { CollectionType } from '../metadata/enums'; import { isString } from '../rules/typechecker'; diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index e75402a..cadad53 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -1,40 +1,28 @@ import type { RuntimeOptions } from '../common'; -import type { SealOptions } from './interfaces'; +import type { SealOptions, SealedExecutors } from './interfaces'; import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata'; -import type { SealedExecutors } from './types'; import { CollectionType } from '../metadata'; import { BakerError, Direction } from '../common'; import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; - -// ───────────────────────────────────────────────────────────────────────────── -// Generated variable name prefixes — centralised to prevent typo-related bugs -// ───────────────────────────────────────────────────────────────────────────── - -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; +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; +} + // ───────────────────────────────────────────────────────────────────────────── -// SerializeBuilder — new Function-based serialize executor generation (§4.3 serialize pipeline) +// SerializeBuilder — new Function-based serialize executor generation (serialize pipeline) // ───────────────────────────────────────────────────────────────────────────── /** @@ -46,7 +34,7 @@ const GEN = { * passed around. * * Assumes no validation — the generated executor always returns - * Record (§4.3). + * Record. */ class SerializeBuilder { /** Runtime references injected into the generated function (transform fns, classes). */ @@ -81,11 +69,20 @@ class SerializeBuilder { let body = "'use strict';\n"; body += `var ${GEN.out} = {};\n`; - // Groups variable — only when fields referencing groups exist - const hasGroupsField = Object.values(this.merged).some(meta => { + // 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); - return groups && groups.length > 0; - }); + if (groups && groups.length > 0) { + 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`; @@ -98,7 +95,7 @@ class SerializeBuilder { body += `return ${GEN.out};\n`; - // sourceURL (§4.9) + // 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`; @@ -115,6 +112,19 @@ class SerializeBuilder { return executor; } + /** + * 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; + } + // ─────────────────────────────────────────────────────────────────────────── // Per-field serialize code generation // ─────────────────────────────────────────────────────────────────────────── @@ -150,7 +160,7 @@ class SerializeBuilder { let fieldCode = ''; fieldCode += `var ${fieldVal} = instance[${JSON.stringify(fieldKey)}];\n`; - // groups check wrap (§4.5) + // groups check wrap let fieldStart = ''; let fieldEnd = ''; if (exposeGroups && exposeGroups.length > 0) { @@ -160,7 +170,7 @@ class SerializeBuilder { let innerCode = ''; - // ② @IsOptional → skip output if undefined (§4.3 serialize step 2) + // ② @IsOptional → skip output if undefined (serialize step 2) const useOptionalGuard = meta.flags.isOptional; // Collect serialize-direction transforms once @@ -174,42 +184,42 @@ class SerializeBuilder { if (collection === CollectionType.Set) { if (meta.type.resolvedCollectionValue) { - const nestedSealed = this.resolve(meta.type.resolvedCollectionValue) as SealedExecutors; + const nestedSealed = this.resolveExecutor(meta.type.resolvedCollectionValue); const execIdx = this.execs.length; this.execs.push(nestedSealed); if (this.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); }`; + 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} = [];\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 = `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};`; + nestedCode += ` ${outputTarget} = ${GEN.setArr}${sk};`; } } else { nestedCode = `${outputTarget} = [...${fieldVal}];`; } } 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 `; + 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.resolve(meta.type.resolvedCollectionValue) as SealedExecutors; + 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} = Object.create(null);\n`; - nestedCode += ` for (var ${GEN.mapEntry} of ${fieldVal}) {\n`; + nestedCode = `var ${GEN.mapObj}${sk} = Object.create(null);\n`; + nestedCode += ` for (var ${GEN.mapEntry}${sk} 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 += `${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};`; + nestedCode += ` ${outputTarget} = ${GEN.mapObj}${sk};`; } else { - nestedCode = `var ${GEN.mapObj} = Object.create(null);\n`; - nestedCode += ` for (var ${GEN.mapEntry} of ${fieldVal}) {\n`; + nestedCode = `var ${GEN.mapObj}${sk} = Object.create(null);\n`; + nestedCode += ` for (var ${GEN.mapEntry}${sk} of ${fieldVal}) {\n`; nestedCode += ` ${keyCheck}`; - nestedCode += `${GEN.mapObj}[${GEN.mapEntry}[0]] = ${GEN.mapEntry}[1];\n`; + nestedCode += `${GEN.mapObj}${sk}[${GEN.mapEntry}${sk}[0]] = ${GEN.mapEntry}${sk}[1];\n`; nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.mapObj};`; + nestedCode += ` ${outputTarget} = ${GEN.mapObj}${sk};`; } } @@ -227,80 +237,79 @@ class SerializeBuilder { } // ③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)) { + const type = meta.type; + if (type && (type.resolvedClass || type.discriminator || (type.fn && meta.flags.validateNested))) { // Determine array/each mode - const hasEach = meta.type?.isArray || meta.flags.validateNestedEach || meta.validation.some(rd => rd.each); + const hasEach = 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 + 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 (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; - }); + // 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.resolve(sub.value) as SealedExecutors; + 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} = ${awaitKw}execs[${execIdx}].serialize(${itemVar}, opts);\n`; + code += ` var ${GEN.serResult}${sk} = ${awaitKw}execs[${execIdx}].serialize(${itemVar}, opts);\n`; if (keepDisc) { - code += ` ${GEN.serResult}[${JSON.stringify(property)}] = ${JSON.stringify(sub.name)};\n`; + code += ` ${GEN.serResult}${sk}[${JSON.stringify(property)}] = ${JSON.stringify(sub.name)};\n`; } - code += ` ${GEN.outItem} = ${GEN.serResult};\n`; + code += ` ${GEN.outItem}${sk} = ${GEN.serResult}${sk};\n`; } - code += `} else { ${GEN.outItem} = ` + itemVar + '; }\n'; + code += `} else { ${GEN.outItem}${sk} = ` + itemVar + '; }\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(__ser_item) {\n`; + nestedCode = `${outputTarget} = await Promise.all(${fieldVal}.map(async function(${discItem}) {\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`; + 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};\n`; - nestedCode += buildInstanceofChain('__ser_item', awaitKw); + nestedCode += ` var ${GEN.outItem}${sk};\n`; + nestedCode += buildInstanceofChain(discItem, awaitKw); if (this.isAsync) { - nestedCode += ` return ${GEN.outItem};\n`; + nestedCode += ` return ${GEN.outItem}${sk};\n`; nestedCode += `}));`; } else { - nestedCode += ` ${GEN.discArr}.push(${GEN.outItem});\n`; + nestedCode += ` ${GEN.discArr}${sk}.push(${GEN.outItem}${sk});\n`; nestedCode += ` }\n`; - nestedCode += ` ${outputTarget} = ${GEN.discArr};`; + nestedCode += ` ${outputTarget} = ${GEN.discArr}${sk};`; } } else { const awaitKw = this.isAsync ? 'await ' : ''; - nestedCode = `var ${GEN.outItem};\n`; + nestedCode = `var ${GEN.outItem}${sk};\n`; nestedCode += buildInstanceofChain(fieldVal, awaitKw); - nestedCode += `${outputTarget} = ${GEN.outItem};`; + nestedCode += `${outputTarget} = ${GEN.outItem}${sk};`; } } else { // Existing simple nested logic - const nestedCls = meta.type!.resolvedClass ?? (meta.type!.fn() as Function); - const nestedSealed = this.resolve(nestedCls) as SealedExecutors; + const nestedCls = type.resolvedClass ?? (type.fn() as Function); + const nestedSealed = this.resolveExecutor(nestedCls); const execIdx = this.execs.length; this.execs.push(nestedSealed); @@ -308,12 +317,12 @@ class SerializeBuilder { 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} = [];\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 = `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};`; + nestedCode += ` ${outputTarget} = ${GEN.nestedArr}${sk};`; } } else { const awaitKw = this.isAsync ? 'await ' : ''; @@ -351,31 +360,12 @@ class SerializeBuilder { * Serialize direction reverses declaration order (codec stack unwrapping). */ private buildTransformExpr(inputExpr: string, fieldKey: string, serTransforms: TransformDef[]): string | null { - const refs = this.refs; 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; - } - - // Walk serTransforms backwards in place — avoids [...arr].reverse() clone allocation + 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]!; @@ -417,7 +407,7 @@ class SerializeBuilder { /** * Generate serialize executor code. * Thin wrapper preserving the historical free-function entry point: instantiates - * SerializeBuilder and returns its built executor (§4.3). + * SerializeBuilder and returns its built executor. */ function buildSerializeCode( Class: Function, diff --git a/src/seal/types.ts b/src/seal/types.ts index aa092db..4f2720b 100644 --- a/src/seal/types.ts +++ b/src/seal/types.ts @@ -1,23 +1,12 @@ import type { Result, ResultAsync } from '@zipbul/result'; -import type { BakerIssue, RuntimeOptions } from '../common'; -import type { RawClassMeta } from '../metadata'; +import type { RuntimeOptions, BakerIssue } from '../common'; -// ───────────────────────────────────────────────────────────────────────────── -// SealedExecutors — Dual executor stored in the Baker's per-instance executor map (§2.1) -// ───────────────────────────────────────────────────────────────────────────── +/** Compiled deserialize executor — Result pattern (or its async variant), produced by the builder. */ +export type DeserializeExecutor = ( + input: unknown, + opts?: RuntimeOptions, +) => Result | ResultAsync; -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; -} +/** 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 327b910..0000000 --- a/src/seal/validate-meta.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { RawClassMeta } from '../metadata'; - -import { CollectionType, hasRawOwn } from '../metadata'; -import { BakerError } from '../common'; - -/** - * @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.ts b/src/transformers/collection.ts index af2c288..30aca4f 100644 --- a/src/transformers/collection.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/date.spec.ts b/src/transformers/date.spec.ts new file mode 100644 index 0000000..453203f --- /dev/null +++ b/src/transformers/date.spec.ts @@ -0,0 +1,77 @@ +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); + }); +}); + +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.ts b/src/transformers/date.ts index 02df92b..a764e39 100644 --- a/src/transformers/date.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/index.ts b/src/transformers/index.ts index c672374..512898b 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -5,4 +5,5 @@ export * from './public'; // Internal surface — consumed cross-domain but NOT necessarily part of the published `./transformers`. -export type { Transformer, TransformParams, TransformFunction } from './types'; +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..fd6bf3d --- /dev/null +++ b/src/transformers/interfaces.ts @@ -0,0 +1,13 @@ +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; +} diff --git a/src/transformers/luxon.spec.ts b/src/transformers/luxon.spec.ts index 61abb63..b8242c9 100644 --- a/src/transformers/luxon.spec.ts +++ b/src/transformers/luxon.spec.ts @@ -30,4 +30,17 @@ describe('luxonTransformer — happy path', () => { 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.ts b/src/transformers/luxon.ts index 021eede..0d301a4 100644 --- a/src/transformers/luxon.ts +++ b/src/transformers/luxon.ts @@ -1,4 +1,4 @@ -import type { Transformer } from './types'; +import type { Transformer } from './interfaces'; import { BakerError } from '../common'; @@ -28,16 +28,27 @@ async function luxonTransformer(opts?: LuxonTransformerOptions): Promise { + // 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') { - return DateTime.fromISO(value, { zone }); + const dt = DateTime.fromISO(value, { zone }); + return dt.isValid ? dt : value; } if (value instanceof Date) { - return DateTime.fromJSDate(value, { zone }); + const dt = DateTime.fromJSDate(value, { zone }); + return dt.isValid ? dt : value; } return value; }, serialize: ({ value }) => { - if (value && typeof value === 'object' && typeof (value as LuxonLike).toISO === 'function') { + // 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(); } diff --git a/src/transformers/moment.spec.ts b/src/transformers/moment.spec.ts index 2daa5fd..8cfc1d6 100644 --- a/src/transformers/moment.spec.ts +++ b/src/transformers/moment.spec.ts @@ -30,4 +30,14 @@ describe('momentTransformer — happy path', () => { 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.ts b/src/transformers/moment.ts index ebedec9..0a8bf76 100644 --- a/src/transformers/moment.ts +++ b/src/transformers/moment.ts @@ -1,4 +1,4 @@ -import type { Transformer } from './types'; +import type { Transformer } from './interfaces'; import { BakerError } from '../common'; @@ -26,7 +26,12 @@ async function momentTransformer(opts?: MomentTransformerOptions): Promise { if (typeof value === 'string' || value instanceof Date) { - return moment(value); + // 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; }, diff --git a/src/transformers/number.ts b/src/transformers/number.ts index ee20167..926c714 100644 --- a/src/transformers/number.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/string.ts b/src/transformers/string.ts index 046847f..92d3fef 100644 --- a/src/transformers/string.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 index cb34abc..af4235f 100644 --- a/src/transformers/types.ts +++ b/src/transformers/types.ts @@ -1,13 +1,4 @@ -export interface TransformParams { - value: unknown; - key: string; - obj: Record; -} - -export interface Transformer { - deserialize(params: TransformParams): unknown | Promise; - serialize(params: TransformParams): unknown | Promise; -} +import type { TransformParams } from './interfaces'; /** Internal — direction-specific transform function stored after @Field processing */ -export type TransformFunction = (params: TransformParams) => unknown | Promise; +export type TransformFunction = (params: TransformParams) => unknown; 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/discriminator-advanced.test.ts b/test/e2e/discriminator-advanced.test.ts index 4f1486f..c47738c 100644 --- a/test/e2e/discriminator-advanced.test.ts +++ b/test/e2e/discriminator-advanced.test.ts @@ -114,22 +114,33 @@ class OwnerArrayDto { } 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(); }); }); diff --git a/test/e2e/fuzz-parity.test.ts b/test/e2e/fuzz-parity.test.ts index 7a542d3..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/rules/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/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/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/rule-semantics-parity.test.ts b/test/e2e/rule-semantics-parity.test.ts index 9d0f67a..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/rules/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 d4e3bf3..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/rules/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/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 1feff7d..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/rules/types').EmittableRule; + rule: import('../../src/rules/interfaces').EmittableRule; samples: unknown[]; }; -async function dtoPasses(rule: import('../../src/rules/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..dc1cf05 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; diff --git a/test/e2e/string-validators.test.ts b/test/e2e/string-validators.test.ts index 3ede0e5..3c73c4a 100644 --- a/test/e2e/string-validators.test.ts +++ b/test/e2e/string-validators.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { Baker, isBakerIssueSet, Field } from '../../index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; import { isString, isEmail, @@ -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/integration/__snapshots__/codegen-snapshot.test.ts.snap b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap index d818d9c..603f666 100644 --- a/test/integration/__snapshots__/codegen-snapshot.test.ts.snap +++ b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap @@ -200,12 +200,12 @@ 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_k = __bk$f_inner["k"]; -if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +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_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); -else if (isNaN(__bk$f_inner_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); -else if (__bk$f_inner_k === Infinity || __bk$f_inner_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +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"}); } } @@ -306,22 +306,22 @@ return __bk$out; var __bk$out = {}; var __bk$fv_set = instance["set"]; if (__bk$fv_set != null) { - var __bk$sa = []; - for (var __bk$si of __bk$fv_set) { - __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + 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$sa; + __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$m = Object.create(null); - for (var __bk$me of __bk$fv_map) { - if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } - __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + 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$m; + __bk$out["map"] = __bk$mmap; } else { __bk$out["map"] = __bk$fv_map; } @@ -342,12 +342,12 @@ if (Array.isArray(__bk$f_set)) { var __bk$ppset = "set"+'['+__bk$i_set+'].'; if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); else { -var __bk$f_setc_k = __il$setci["k"]; -if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isDefined"}); else { -if (typeof __bk$f_setc_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (isNaN(__bk$f_setc_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +if (typeof __bk$f_setc_0_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); } } } @@ -363,12 +363,12 @@ if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$ 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_k = __il$mapmi["k"]; -if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +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_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); -else if (isNaN(__bk$f_mapm_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); -else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +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"}); } } } @@ -626,16 +626,16 @@ 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_k = __bk$f_inner["k"]; -if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +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_k = false; -if (typeof __bk$f_inner_k !== 'number' || isNaN(__bk$f_inner_k)) { - __bk$f_inner_k = Number(__bk$f_inner_k); - if (isNaN(__bk$f_inner_k)) { __bk$errors.push({path:"inner."+"k",code:"conversionFailed"}); __bk$skip_k = true; } +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_k) { - if (__bk$f_inner_k === Infinity || __bk$f_inner_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +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"}); } @@ -737,22 +737,22 @@ return __bk$out; var __bk$out = {}; var __bk$fv_set = instance["set"]; if (__bk$fv_set != null) { - var __bk$sa = []; - for (var __bk$si of __bk$fv_set) { - __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + 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$sa; + __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$m = Object.create(null); - for (var __bk$me of __bk$fv_map) { - if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } - __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + 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$m; + __bk$out["map"] = __bk$mmap; } else { __bk$out["map"] = __bk$fv_map; } @@ -773,16 +773,16 @@ if (Array.isArray(__bk$f_set)) { var __bk$ppset = "set"+'['+__bk$i_set+'].'; if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); else { -var __bk$f_setc_k = __il$setci["k"]; -if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isDefined"}); else { -var __bk$skip_k = false; -if (typeof __bk$f_setc_k !== 'number' || isNaN(__bk$f_setc_k)) { - __bk$f_setc_k = Number(__bk$f_setc_k); - if (isNaN(__bk$f_setc_k)) { __bk$errors.push({path:__bk$ppset+"k",code:"conversionFailed"}); __bk$skip_k = true; } +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:__bk$ppset+"k",code:"conversionFailed"}); __bk$skip_setc_0_k = true; } } -if (!__bk$skip_k) { - if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +if (!__bk$skip_setc_0_k) { + if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); } } } @@ -799,16 +799,16 @@ if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$ 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_k = __il$mapmi["k"]; -if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +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_k = false; -if (typeof __bk$f_mapm_k !== 'number' || isNaN(__bk$f_mapm_k)) { - __bk$f_mapm_k = Number(__bk$f_mapm_k); - if (isNaN(__bk$f_mapm_k)) { __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"conversionFailed"}); __bk$skip_k = true; } +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_k) { - if (__bk$f_mapm_k === Infinity || __bk$f_mapm_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +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"}); } } } @@ -930,10 +930,11 @@ if (__bk$f_inner != null && typeof __bk$f_inner === 'object' && !Array.isArray(_ if (isErr(__bk$r_inner)) { var __bk$re_inner = __bk$r_inner.data; var __bk$ppinner = "inner."; - if(__bk$re_inner[0].message===undefined&&__bk$re_inner[0].context===undefined)return err([{path:__bk$ppinner+__bk$re_inner[0].path,code:__bk$re_inner[0].code}]); - var __neinner={path:__bk$ppinner+__bk$re_inner[0].path,code:__bk$re_inner[0].code}; - if(__bk$re_inner[0].message!==undefined)__neinner.message=__bk$re_inner[0].message; - if(__bk$re_inner[0].context!==undefined)__neinner.context=__bk$re_inner[0].context; + 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"}]); } @@ -996,12 +997,12 @@ 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_k = __bk$f_inner["k"]; -if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) return [{path:"inner."+"k",code:"isDefined"}]; +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_k !== 'number') return [{path:"inner."+"k",code:"isNumber"}]; -else if (isNaN(__bk$f_inner_k)) return [{path:"inner."+"k",code:"isNumber"}]; -else if (__bk$f_inner_k === Infinity || __bk$f_inner_k === -Infinity) return [{path:"inner."+"k",code:"isNumber"}]; +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"}]; } } @@ -1052,10 +1053,11 @@ if (Array.isArray(__bk$f_set)) { if (isErr(__bk$r_set)) { var __bk$re_set = __bk$r_set.data; var __bk$ppset = "set"+'['+__bk$i_set+'].'; - if(__bk$re_set[0].message===undefined&&__bk$re_set[0].context===undefined)return err([{path:__bk$ppset+__bk$re_set[0].path,code:__bk$re_set[0].code}]); - var __neset={path:__bk$ppset+__bk$re_set[0].path,code:__bk$re_set[0].code}; - if(__bk$re_set[0].message!==undefined)__neset.message=__bk$re_set[0].message; - if(__bk$re_set[0].context!==undefined)__neset.context=__bk$re_set[0].context; + 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); } } @@ -1074,10 +1076,11 @@ if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$ if (isErr(__bk$r_map)) { var __bk$re_map = __bk$r_map.data; var __bk$ppmap = "map"+'['+__bk$kmap+'].'; - if(__bk$re_map[0].message===undefined&&__bk$re_map[0].context===undefined)return err([{path:__bk$ppmap+__bk$re_map[0].path,code:__bk$re_map[0].code}]); - var __nemap={path:__bk$ppmap+__bk$re_map[0].path,code:__bk$re_map[0].code}; - if(__bk$re_map[0].message!==undefined)__nemap.message=__bk$re_map[0].message; - if(__bk$re_map[0].context!==undefined)__nemap.context=__bk$re_map[0].context; + 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); } } @@ -1093,22 +1096,22 @@ return __bk$out; var __bk$out = {}; var __bk$fv_set = instance["set"]; if (__bk$fv_set != null) { - var __bk$sa = []; - for (var __bk$si of __bk$fv_set) { - __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + 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$sa; + __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$m = Object.create(null); - for (var __bk$me of __bk$fv_map) { - if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } - __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + 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$m; + __bk$out["map"] = __bk$mmap; } else { __bk$out["map"] = __bk$fv_map; } @@ -1128,12 +1131,12 @@ if (Array.isArray(__bk$f_set)) { var __bk$ppset = "set"+'['+__bk$i_set+'].'; if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) return [{path:__bk$ppset,code:'invalidInput'}]; else { -var __bk$f_setc_k = __il$setci["k"]; -if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) return [{path:__bk$ppset+"k",code:"isDefined"}]; +var __bk$f_setc_0_k = __il$setci["k"]; +if (__bk$f_setc_0_k === undefined || __bk$f_setc_0_k === null) return [{path:__bk$ppset+"k",code:"isDefined"}]; else { -if (typeof __bk$f_setc_k !== 'number') return [{path:__bk$ppset+"k",code:"isNumber"}]; -else if (isNaN(__bk$f_setc_k)) return [{path:__bk$ppset+"k",code:"isNumber"}]; -else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) return [{path:__bk$ppset+"k",code:"isNumber"}]; +if (typeof __bk$f_setc_0_k !== 'number') return [{path:__bk$ppset+"k",code:"isNumber"}]; +else if (isNaN(__bk$f_setc_0_k)) return [{path:__bk$ppset+"k",code:"isNumber"}]; +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) return [{path:__bk$ppset+"k",code:"isNumber"}]; } } } @@ -1149,12 +1152,12 @@ if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$ 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_k = __il$mapmi["k"]; -if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}]; +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_k !== 'number') return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; -else if (isNaN(__bk$f_mapm_k)) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; -else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_k === -Infinity) return [{path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}]; +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"}]; } } } @@ -1371,12 +1374,12 @@ 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_k = __bk$f_inner["k"]; -if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +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_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); -else if (isNaN(__bk$f_inner_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); -else if (__bk$f_inner_k === Infinity || __bk$f_inner_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +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"}); } } @@ -1478,22 +1481,22 @@ return __bk$out; var __bk$out = {}; var __bk$fv_set = instance["set"]; if (__bk$fv_set != null) { - var __bk$sa = []; - for (var __bk$si of __bk$fv_set) { - __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + 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$sa; + __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$m = Object.create(null); - for (var __bk$me of __bk$fv_map) { - if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } - __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + 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$m; + __bk$out["map"] = __bk$mmap; } else { __bk$out["map"] = __bk$fv_map; } @@ -1515,12 +1518,12 @@ if (Array.isArray(__bk$f_set)) { var __bk$ppset = "set"+'['+__bk$i_set+'].'; if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); else { -var __bk$f_setc_k = __il$setci["k"]; -if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isDefined"}); else { -if (typeof __bk$f_setc_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (isNaN(__bk$f_setc_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +if (typeof __bk$f_setc_0_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); } } } @@ -1536,12 +1539,12 @@ if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$ 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_k = __il$mapmi["k"]; -if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +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_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); -else if (isNaN(__bk$f_mapm_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); -else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +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"}); } } } @@ -1757,12 +1760,12 @@ var __bk$f_inner = Object.hasOwn(input, "inner") ? input["inner"] : __bk$defs["i 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_k = __bk$f_inner["k"]; -if (__bk$f_inner_k === undefined || __bk$f_inner_k === null) __bk$errors.push({path:"inner."+"k",code:"isDefined"}); +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_k !== 'number') __bk$errors.push({path:"inner."+"k",code:"isNumber"}); -else if (isNaN(__bk$f_inner_k)) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); -else if (__bk$f_inner_k === Infinity || __bk$f_inner_k === -Infinity) __bk$errors.push({path:"inner."+"k",code:"isNumber"}); +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"}); } } @@ -1863,22 +1866,22 @@ return __bk$out; var __bk$out = {}; var __bk$fv_set = instance["set"]; if (__bk$fv_set != null) { - var __bk$sa = []; - for (var __bk$si of __bk$fv_set) { - __bk$sa.push(__bk$si == null ? __bk$si : execs[0].serialize(__bk$si, opts)); + 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$sa; + __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$m = Object.create(null); - for (var __bk$me of __bk$fv_map) { - if (typeof __bk$me[0] !== 'string') { throw new BakerError("CollectionDto" + ': Map field ' + "map" + ' has non-string key (' + typeof __bk$me[0] + '). Map serialization requires string keys.'); } - __bk$m[__bk$me[0]] = __bk$me[1] == null ? __bk$me[1] : execs[1].serialize(__bk$me[1], opts); + 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$m; + __bk$out["map"] = __bk$mmap; } else { __bk$out["map"] = __bk$fv_map; } @@ -1900,12 +1903,12 @@ if (Array.isArray(__bk$f_set)) { var __bk$ppset = "set"+'['+__bk$i_set+'].'; if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); else { -var __bk$f_setc_k = __il$setci["k"]; -if (__bk$f_setc_k === undefined || __bk$f_setc_k === null) __bk$errors.push({path:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isDefined"}); else { -if (typeof __bk$f_setc_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (isNaN(__bk$f_setc_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (__bk$f_setc_k === Infinity || __bk$f_setc_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +if (typeof __bk$f_setc_0_k !== 'number') __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); } } } @@ -1921,12 +1924,12 @@ if (__bk$f_map != null && typeof __bk$f_map === 'object' && !Array.isArray(__bk$ 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_k = __il$mapmi["k"]; -if (__bk$f_mapm_k === undefined || __bk$f_mapm_k === null) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isDefined"}); +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_k !== 'number') __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); -else if (isNaN(__bk$f_mapm_k)) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); -else if (__bk$f_mapm_k === Infinity || __bk$f_mapm_k === -Infinity) __bk$errors.push({path:"map"+'['+__bk$kmap+'].'+"k",code:"isNumber"}); +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"}); } } } diff --git a/test/integration/check-call-options.test.ts b/test/integration/check-call-options.test.ts index d03915f..60ac3ef 100644 --- a/test/integration/check-call-options.test.ts +++ b/test/integration/check-call-options.test.ts @@ -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 index 66ad44f..1a874fa 100644 --- a/test/integration/codegen-snapshot.test.ts +++ b/test/integration/codegen-snapshot.test.ts @@ -6,11 +6,11 @@ // (refs/regexes/execs) is not part of Function.prototype.toString(). import { describe, expect, it } from 'bun:test'; -import type { BakerConfig } from '../../src/config/configure'; +import type { BakerConfig } from '../../src/config'; import { Baker, Field, arrayOf } from '../../index'; -import { normalizeConfig } from '../../src/config/configure'; -import { configFingerprint, getCached } from '../../src/seal/compile-cache'; +import { configNormalizer } from '../../src/config'; +import { CompileCache, compileCache } from '../../src/seal/compile-cache'; import { isBoolean, isEmail, @@ -20,14 +20,14 @@ import { minLength, } from '../../src/rules/index'; -const fpOf = (cfg?: BakerConfig): string => configFingerprint(cfg ? normalizeConfig(cfg) : {}); +const fpOf = (cfg?: BakerConfig): string => CompileCache.fingerprint(cfg ? configNormalizer.normalize(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 = getCached(Dto, fpOf(cfg)); + const sealed = compileCache.get(Dto, fpOf(cfg)); if (!sealed) { throw new Error('executor not cached'); } diff --git a/test/integration/error-system.test.ts b/test/integration/error-system.test.ts index 31931fb..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/transformers/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/unseal.ts b/test/integration/helpers/unseal.ts index 0776d1c..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/compile-cache'; +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/seal.test.ts b/test/integration/seal.test.ts index 96d7a43..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/metadata/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: [], From 5aa93e4dba947d15d5efb1f2dfa89d445651c2c0 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sun, 21 Jun 2026 23:27:14 +0900 Subject: [PATCH 23/31] test(seal): add dedicated unit specs for the extracted DI collaborators inheritance-merger, meta-validator, async-analyzer, and circular-placeholder were tested only transitively through seal.spec, while their siblings (circular-analyzer, compile-cache, expose-validator) each have an isolated spec. Add per-unit specs to make the testing axis consistent and to lock each collaborator's contract in isolation: - inheritance-merger.spec: own-meta passthrough, validation union-merge + dedup-by-ruleName, transform/exclude/type child-priority-else-inherit, flag supplementing, deep-copy isolation. - meta-validator.spec: every discriminator-shape rejection + Set value-class @Field check. - async-analyzer.spec: sync/async rule + transform detection, direction filtering, nested-DTO flag propagation via the resolver, and nestedClassesOf resolution. - circular-placeholder.spec: non-async flags, BakerError-throwing members, writable own fields. tsc clean; 2427 pass / 0 fail; 15 snapshots byte-identical; lint/knip/deps clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/async-analyzer.spec.ts | 85 +++++++++++++++++++++++++++ src/seal/circular-placeholder.spec.ts | 28 +++++++++ src/seal/inheritance-merger.spec.ts | 84 ++++++++++++++++++++++++++ src/seal/meta-validator.spec.ts | 79 +++++++++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 src/seal/async-analyzer.spec.ts create mode 100644 src/seal/circular-placeholder.spec.ts create mode 100644 src/seal/inheritance-merger.spec.ts create mode 100644 src/seal/meta-validator.spec.ts 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/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/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/meta-validator.spec.ts b/src/seal/meta-validator.spec.ts new file mode 100644 index 0000000..88fade0 --- /dev/null +++ b/src/seal/meta-validator.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'bun:test'; + +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: Function; 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(); + }); +}); From eccce96a86f3e160798200eb75dd5e633c4cdf71 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sun, 21 Jun 2026 23:37:22 +0900 Subject: [PATCH 24/31] chore(changeset): bump audit fixes to minor (moment/isEnum change observable behavior) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/audit-bugfixes.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/.changeset/audit-bugfixes.md b/.changeset/audit-bugfixes.md index b3dd9ea..5836aa1 100644 --- a/.changeset/audit-bugfixes.md +++ b/.changeset/audit-bugfixes.md @@ -1,18 +1,24 @@ --- -"@zipbul/baker": patch +"@zipbul/baker": minor --- -Fix four bugs found in a package-wide line-by-line audit: +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** no longer accepts the enum member *names* as valid values. 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 key-name strings (e.g. `'Active'`). Values - are now read through the non-numeric keys, which is correct for string, numeric, and heterogeneous enums. -- **`luxonTransformer`** now passes an unparseable date string / `Date` through untouched instead of - laundering it into an Invalid `DateTime` (which serialized to `null` / `"Invalid DateTime"` and corrupted - data). This matches `momentTransformer`'s existing pass-through contract. -- **`momentTransformer`** now parses input in UTC (`moment.utc`) so a zoneless datetime string resolves to - the same instant on every host; previously local-time parsing made serialized output depend on the - machine timezone. Matches `luxonTransformer`'s UTC default. -- **Per-call `groups` option** is now validated at the call boundary: a non-`string[]` value throws a clear - `BakerError` instead of silently misbehaving inside the generated executor. +- **`@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. From 96ed92c80bfc7cb77eb6a87423aa8de6b77b3494 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 00:39:50 +0900 Subject: [PATCH 25/31] fix(seal,rules): repair discriminated arrays + four validator/codegen bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced five bugs (RED test first for each), plus type-organization/dedup cleanups. Bugs: - seal: discriminator + `type: () => [Base]` now dispatches per element in deserialize/validate (was reading the discriminator off the array → invalidDiscriminator on valid input) - seal: serialize throws BakerError on an instance matching no discriminator subtype instead of leaking the raw, un-serialized object - seal: `each` rule message/context functions receive the failing element as `value` (not the whole collection), matching the element-level path - rules: isDateString / isISO8601({strict}) use the proleptic Gregorian leap rule for years 0–99 (new Date(0..99,…) remapped to 1900–1999, so 0000-02-29 was wrongly rejected); no Date alloc - rules: isHash / isTaxId throw at construction on an unknown algorithm/locale (was a runtime always-fail), matching the locale rules' fail-fast model Cleanups (no behavior change): - decorators: split FieldOptions/ArrayOfMarker → interfaces.ts, RuleArg/FieldDecorator → types.ts, ARRAY_OF/FIELD_OPTION_KEYS → constants.ts (field.ts −79 lines); FIELD_OPTION_KEYS is drift-safe - metadata: extract shared DiscriminatorDef/DiscriminatorSubType (FieldOptions + TypeDef) - rules: RulePlanCache single-sourced in rules/types.ts; isNumber maxDecimal codegen → helper - seal: compile-cache fingerprint derived from Record so a new option is a compile error until covered typecheck/lint/knip clean, no circular deps, 2451 tests pass, coverage 99.8%. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...discriminator-array-and-validator-fixes.md | 29 ++++ src/decorators/constants.ts | 31 ++++ src/decorators/field.ts | 79 +--------- src/decorators/interfaces.ts | 55 +++++++ src/decorators/public.ts | 2 +- src/decorators/types.ts | 8 + src/metadata/index.ts | 2 +- src/metadata/interfaces.ts | 17 ++- src/rules/index.ts | 1 + src/rules/rule-plan.ts | 7 +- src/rules/string-format.spec.ts | 19 +-- src/rules/string-format.ts | 17 ++- src/rules/string-identifier.spec.ts | 25 +++ src/rules/string-identifier.ts | 24 ++- src/rules/typechecker.ts | 10 +- src/rules/types.ts | 8 + src/seal/compile-cache.ts | 28 ++-- src/seal/deserialize-builder.ts | 143 +++++++++++++++++- src/seal/interfaces.ts | 5 + src/seal/serialize-builder.ts | 9 +- test/e2e/discriminator-advanced.test.ts | 134 +++++++++++++++- test/e2e/field-message.test.ts | 16 ++ test/e2e/string-validators-full.test.ts | 10 ++ .../codegen-snapshot.test.ts.snap | 90 +++++++---- 24 files changed, 608 insertions(+), 161 deletions(-) create mode 100644 .changeset/discriminator-array-and-validator-fixes.md create mode 100644 src/decorators/constants.ts create mode 100644 src/decorators/interfaces.ts create mode 100644 src/decorators/types.ts diff --git a/.changeset/discriminator-array-and-validator-fixes.md b/.changeset/discriminator-array-and-validator-fixes.md new file mode 100644 index 0000000..fc74214 --- /dev/null +++ b/.changeset/discriminator-array-and-validator-fixes.md @@ -0,0 +1,29 @@ +--- +"@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`. diff --git a/src/decorators/constants.ts b/src/decorators/constants.ts new file mode 100644 index 0000000..fd5410d --- /dev/null +++ b/src/decorators/constants.ts @@ -0,0 +1,31 @@ +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. +export const ARRAY_OF = 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/field.ts b/src/decorators/field.ts index 84e50e2..a96971b 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,23 +1,18 @@ -import type { ClassCtor } from '../common'; import type { EmittableRule, InternalRule } from '../rules'; import type { RawPropertyMeta, RuleDef, ExposeDef, TypeDef } from '../metadata'; import type { Transformer } from '../transformers'; +import type { ArrayOfMarker, FieldOptions } from './interfaces'; +import type { FieldDecorator, RuleArg } from './types'; import { Direction, BakerError, isAsyncFunction, isPromiseLike } from '../common'; import { metaStore } from '../metadata'; import { ExcludeMode } from './enums'; +import { ARRAY_OF, FIELD_OPTION_KEYS } from './constants'; // ───────────────────────────────────────────────────────────────────────────── // 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. * @@ -35,74 +30,10 @@ 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; @@ -125,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; @@ -264,8 +193,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 */ diff --git a/src/decorators/interfaces.ts b/src/decorators/interfaces.ts new file mode 100644 index 0000000..0a1823a --- /dev/null +++ b/src/decorators/interfaces.ts @@ -0,0 +1,55 @@ +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'; + +// ───────────────────────────────────────────────────────────────────────────── +// arrayOf marker — produced by arrayOf(...), compiles to per-rule `each: true` +// ───────────────────────────────────────────────────────────────────────────── + +export interface ArrayOfMarker { + readonly [key: symbol]: 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 index aeff3bf..5d1b669 100644 --- a/src/decorators/public.ts +++ b/src/decorators/public.ts @@ -1,2 +1,2 @@ export { Field, arrayOf } from './field'; -export type { FieldOptions, ArrayOfMarker } from './field'; +export type { FieldOptions, ArrayOfMarker } from './interfaces'; 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/metadata/index.ts b/src/metadata/index.ts index a946429..ac65cfa 100644 --- a/src/metadata/index.ts +++ b/src/metadata/index.ts @@ -1,4 +1,4 @@ // Directory barrel — the RAW metadata IR layer consumed by decorators and seal. -export type { RawClassMeta, RawPropertyMeta, RuleDef, TransformDef, ExposeDef, TypeDef, MessageArgs } from './interfaces'; +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 index 36da803..7ea806d 100644 --- a/src/metadata/interfaces.ts +++ b/src/metadata/interfaces.ts @@ -46,12 +46,21 @@ export interface ExcludeDef { serializeOnly?: boolean; } +/** A polymorphic discriminator subtype mapping — a class constructor keyed by its wire name. */ +export interface DiscriminatorSubType { + value: Function; + 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?: { - property: string; - subTypes: { value: Function; name: string }[]; - }; + discriminator?: DiscriminatorDef; keepDiscriminatorProperty?: boolean; /** seal-time normalization result — true if fn() returns an array */ isArray?: boolean; diff --git a/src/rules/index.ts b/src/rules/index.ts index 9573b3f..1c56b9d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -9,3 +9,4 @@ export { createRule } from './create-rule'; 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/rule-plan.ts b/src/rules/rule-plan.ts index 6fe071d..d9dd193 100644 --- a/src/rules/rule-plan.ts +++ b/src/rules/rule-plan.ts @@ -1,15 +1,10 @@ import type { RequiredType } from './enums'; import type { EmitContext, InternalRule, RulePlan } from './interfaces'; -import type { RulePlanCheck, RulePlanExpr } from './types'; +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 = (): RulePlanExpr => ({ kind: RulePlanExprKind.Member, property: 'length' }); diff --git a/src/rules/string-format.spec.ts b/src/rules/string-format.spec.ts index 861f593..a5260dc 100644 --- a/src/rules/string-format.spec.ts +++ b/src/rules/string-format.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, mock } from 'bun:test'; import { RequiredType } from './enums'; +import { BakerError } from '../common'; import type { EmitContext } from './interfaces'; @@ -606,11 +607,8 @@ describe('isHash', () => { 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'); + it('should throw at construction for an unknown algorithm (fail-fast, like locale rules)', () => { + expect(() => isHash('unknownAlgo')).toThrow(BakerError); }); }); @@ -1028,10 +1026,6 @@ describe('isTaxId', () => { 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); }); @@ -1048,11 +1042,8 @@ describe('isTaxId', () => { 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 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', () => { diff --git a/src/rules/string-format.ts b/src/rules/string-format.ts index 4d3cb1c..222cf4a 100644 --- a/src/rules/string-format.ts +++ b/src/rules/string-format.ts @@ -3,6 +3,7 @@ import type { EmitContext, EmittableRule } from './interfaces'; import { RequiredType } from './enums'; import { makeRule } from './rule-plan'; import { makeStringRule } from './string-shared'; +import { BakerError } from '../common'; // Email — RFC 5322 simplified const EMAIL_RE = @@ -391,15 +392,15 @@ const HASH_REGEXES: Record = { 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 && re.test(value), + validate: value => typeof value === 'string' && 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')};`; }, @@ -609,15 +610,15 @@ const TAX_ID_REGEXES: Record = { 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 && re.test(value), + validate: value => typeof value === 'string' && 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')};`; }, diff --git a/src/rules/string-identifier.spec.ts b/src/rules/string-identifier.spec.ts index 0037100..341a522 100644 --- a/src/rules/string-identifier.spec.ts +++ b/src/rules/string-identifier.spec.ts @@ -60,6 +60,16 @@ describe('isISO8601', () => { 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); @@ -216,6 +226,21 @@ describe('isDateString', () => { 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); diff --git a/src/rules/string-identifier.ts b/src/rules/string-identifier.ts index 7c1bcdf..1661958 100644 --- a/src/rules/string-identifier.ts +++ b/src/rules/string-identifier.ts @@ -4,6 +4,22 @@ 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})?)?)?)?$/; @@ -26,7 +42,7 @@ function validateISO8601Strict(v: string): boolean { } if (m[3] !== undefined) { const day = Number(m[3]); - const maxDay = new Date(Number(m[1]), month, 0).getDate(); + const maxDay = lastDayOfMonth(Number(m[1]), month); if (day < 1 || day > maxDay) { return false; } @@ -61,7 +77,7 @@ function isISO8601(options?: IsISO8601Options): EmittableRule { `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]),md=new Date(Number(dm[1]),mo,0).getDate();` + + `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]);` + @@ -667,7 +683,7 @@ function isCalendarValidDate(v: string): boolean { 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(); + const maxDay = lastDayOfMonth(y, m); return d >= 1 && d <= maxDay; } @@ -677,7 +693,7 @@ function isDateString(): EmittableRule { 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')}; }` + `var md=${lastDayOfMonthExpr('y', 'm')}; if(d<1||d>md)${ctx.fail('isDateString')}; }` ); }); } diff --git a/src/rules/typechecker.ts b/src/rules/typechecker.ts index e91239d..3662134 100644 --- a/src/rules/typechecker.ts +++ b/src/rules/typechecker.ts @@ -3,6 +3,12 @@ import type { EmitContext, EmittableRule } from './interfaces'; 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 `{ let exp=${varName}.toExponential().split('e'); let mant=(exp[0].split('.')[1]||'').length; let exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; +} + // ───────────────────────────────────────────────────────────────────────────── // isString — typeof check (operator inline) // ───────────────────────────────────────────────────────────────────────────── @@ -77,7 +83,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { code += `if (${varName} === Infinity || ${varName} === -Infinity) ${ctx.fail('isNumber')};`; } if (maxDecimalPlaces !== undefined) { - code += `${code ? '\nelse ' : ''}{ let exp=${varName}.toExponential().split('e'); let mant=(exp[0].split('.')[1]||'').length; let exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; + code += `${code ? '\nelse ' : ''}${emitMaxDecimalCheck(varName, maxDecimalPlaces, ctx)}`; } return code; } @@ -89,7 +95,7 @@ export function isNumber(options?: IsNumberOptions): EmittableRule { code += `\nelse if (${varName} === Infinity || ${varName} === -Infinity) ${ctx.fail('isNumber')};`; } if (maxDecimalPlaces !== undefined) { - code += `\nelse { let exp=${varName}.toExponential().split('e'); let mant=(exp[0].split('.')[1]||'').length; let exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; + code += `\nelse ${emitMaxDecimalCheck(varName, maxDecimalPlaces, ctx)}`; } return code; }, diff --git a/src/rules/types.ts b/src/rules/types.ts index a57581c..4172d73 100644 --- a/src/rules/types.ts +++ b/src/rules/types.ts @@ -11,3 +11,11 @@ export type RulePlanExpr = 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/seal/compile-cache.ts b/src/seal/compile-cache.ts index 961b1f8..e9461bd 100644 --- a/src/seal/compile-cache.ts +++ b/src/seal/compile-cache.ts @@ -19,6 +19,18 @@ import type { SealOptions, SealedExecutors } from './interfaces'; * 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. */ +// Every seal-affecting option, in fixed fingerprint order. Typed as `Record` +// so adding (or removing) a SealOptions field is a COMPILE error here until the fingerprint covers it — +// without this, a new option would silently collide two configs onto the same key and share a wrong +// executor across bakers. The literal's key order is the fingerprint's bit order. +const FINGERPRINT_KEYS = Object.keys({ + enableImplicitConversion: true, + exposeDefaultValues: true, + stopAtFirstError: true, + whitelist: true, + debug: true, +} satisfies Record) as (keyof SealOptions)[]; + class CompileCache { #cache: WeakMap>>; @@ -27,17 +39,15 @@ class CompileCache { } /** - * 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. + * 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. */ static fingerprint(o: SealOptions): string { - return ( - (o.enableImplicitConversion ? '1' : '0') + - (o.exposeDefaultValues ? '1' : '0') + - (o.stopAtFirstError ? '1' : '0') + - (o.whitelist ? '1' : '0') + - (o.debug ? '1' : '0') - ); + let fp = ''; + for (const key of FINGERPRINT_KEYS) { + fp += o[key] ? '1' : '0'; + } + return fp; } get(cls: Function, fp: string): SealedExecutors | undefined { diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 1ff1ae6..3d86752 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -6,7 +6,7 @@ import type { RuntimeOptions, BakerIssue } from '../common'; import type { SealOptions, SealedExecutors, ChildScope, CategorizedRules, ResolvedTypeGate } from './interfaces'; import type { DeserializeExecutor, ValidateExecutor } from './types'; import type { RawClassMeta, RawPropertyMeta, RuleDef, MessageArgs } from '../metadata'; -import type { EmitContext } from '../rules'; +import type { EmitContext, RulePlanCache } from '../rules'; import { CacheKey, BakerError, Direction } from '../common'; import { CollectionType } from '../metadata'; @@ -557,7 +557,7 @@ class DeserializeBuilder { const gatedCtx = insideTypeGate ? { ...ruleEmitCtx, insideTypeGate: true } : ruleEmitCtx; let emitted: string; if (sg && rd.rule.plan && (lengthVar || timeVar)) { - const cache: { length?: string; time?: string } = {}; + const cache: RulePlanCache = {}; if (rd.rule.plan.cacheKey === CacheKey.Length && lengthVar) { cache.length = lengthVar; } @@ -781,6 +781,9 @@ class DeserializeBuilder { 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 @@ -790,7 +793,8 @@ class DeserializeBuilder { code += `if (${kindVar} === 0) ${emitCtx.fail('isArray')};\n`; for (const rd of eachRules) { - const extra = this.computeRuleExtras(rd, fieldKey, varName); + // `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 @@ -838,7 +842,8 @@ class DeserializeBuilder { let block = ''; block += ` ${col.counterDecl}`; block += ` ${col.loopHeader} {\n`; - block += ' ' + rd.rule.emit(col.elemExpr, colEmitCtx) + '\n'; + block += ` var ${elemVar} = ${col.elemExpr};\n`; + block += ' ' + rd.rule.emit(elemVar, colEmitCtx) + '\n'; if (col.counterInc) { block += ` ${col.counterInc}`; } @@ -1094,6 +1099,71 @@ class DeserializeBuilder { // ── 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 errs = `${GEN.errors}${sk}`; + const nIdx = `${GEN.nestedIdx}${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`; + code += ` if (isErr(${resVar})) {\n`; + code += ` var ${errs} = ${resVar}.data;\n`; + code += ` var __bk$pp${sk} = ${elemPathPrefix};\n`; + if (collectErrors) { + code += ` for (var ${nIdx}=0; ${nIdx}<${errs}.length; ${nIdx}++) {\n`; + code += ` ` + nestedErrPush(GEN.errList, `__bk$pp${sk}+${errs}[${nIdx}].path`, `${errs}[${nIdx}]`, `__ne${sk}`); + code += ` }\n`; + } else { + code += ` ` + nestedErrReturn(`__bk$pp${sk}+${errs}[0].path`, `${errs}[0]`, `__ne${sk}`); + } + code += ` } else {\n`; + if (keepDisc) { + code += ` ${resVar}[${discProp}] = ${discVar};\n`; + } + code += ` ${GEN.arr}${sk}.push(${resVar});\n`; + code += ` }\n`; + 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; + } + private generateNestedCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { const { collectErrors, execs } = this; @@ -1105,6 +1175,12 @@ class DeserializeBuilder { const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); 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); + } // discriminator const discProp = JSON.stringify(meta.type.discriminator.property); code += `var ${GEN.disc}${sk} = ${varName} && ${varName}[${discProp}];\n`; @@ -1228,6 +1304,60 @@ class DeserializeBuilder { return code; } + /** + * 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 nIdx = `${GEN.nestedIdx}${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 += ` if (${resVar} !== null) {\n`; + code += ` var __bk$pp${sk} = ${elemPathPrefix};\n`; + if (collectErrors) { + code += ` for (var ${nIdx}=0; ${nIdx}<${resVar}.length; ${nIdx}++) {\n`; + code += ` ` + nestedErrPush(GEN.errList, `__bk$pp${sk}+${resVar}[${nIdx}].path`, `${resVar}[${nIdx}]`, `__ne${sk}`); + code += ` }\n`; + } else { + code += ` ` + nestedErrReturn(`__bk$pp${sk}+${resVar}[0].path`, `${resVar}[0]`, `__ne${sk}`, true); + } + code += ` }\n`; + 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; + } + private generateNestedCodeValidateOnly(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { const { collectErrors, execs } = this; if (!meta.type) { @@ -1242,6 +1372,11 @@ class DeserializeBuilder { } 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`; diff --git a/src/seal/interfaces.ts b/src/seal/interfaces.ts index 4f01d5f..bb29bae 100644 --- a/src/seal/interfaces.ts +++ b/src/seal/interfaces.ts @@ -28,6 +28,11 @@ export interface SealOptions { // 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; diff --git a/src/seal/serialize-builder.ts b/src/seal/serialize-builder.ts index cadad53..d60243a 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -276,7 +276,14 @@ class SerializeBuilder { } code += ` ${GEN.outItem}${sk} = ${GEN.serResult}${sk};\n`; } - code += `} else { ${GEN.outItem}${sk} = ` + itemVar + '; }\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; }; diff --git a/test/e2e/discriminator-advanced.test.ts b/test/e2e/discriminator-advanced.test.ts index c47738c..e9b4642 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,6 +113,67 @@ 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 (default drops discriminator key)', async () => { const dog = Object.assign(new DogDto(), { breed: 'Shiba' }); @@ -142,6 +203,12 @@ describe('discriminator — serialize', () => { 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); + }); }); // ─── E-23: 2 discriminator fields in same DTO ────────────────────────────── @@ -342,4 +409,69 @@ describe('async serialize: discriminator + array (each)', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// 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/string-validators-full.test.ts b/test/e2e/string-validators-full.test.ts index dc1cf05..ec09cae 100644 --- a/test/e2e/string-validators-full.test.ts +++ b/test/e2e/string-validators-full.test.ts @@ -782,6 +782,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/integration/__snapshots__/codegen-snapshot.test.ts.snap b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap index 603f666..61bbae5 100644 --- a/test/integration/__snapshots__/codegen-snapshot.test.ts.snap +++ b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap @@ -149,18 +149,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -217,18 +220,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -570,18 +576,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -648,18 +657,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -948,18 +960,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') return err([{path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}]); + 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) { - if (typeof __bk$sv_tags !== 'string') return err([{path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}]); + 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()) { - if (typeof __bk$mv_tags !== 'string') return err([{path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}]); + 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++; } } @@ -1014,18 +1029,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') return [{path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}]; + 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) { - if (typeof __bk$sv_tags !== 'string') return [{path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}]; + 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()) { - if (typeof __bk$mv_tags !== 'string') return [{path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}]; + var __bk$eltags = __bk$mv_tags; + if (typeof __bk$eltags !== 'string') return [{path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}]; __bk$mi_tags++; } } @@ -1322,18 +1340,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -1391,18 +1412,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -1708,18 +1732,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } @@ -1777,18 +1804,21 @@ 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++) { - if (typeof __bk$f_tags[__bk$i_tags] !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$i_tags+']',code:"isString"}); + 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) { - if (typeof __bk$sv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$si_tags+']',code:"isString"}); + 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()) { - if (typeof __bk$mv_tags !== 'string') __bk$errors.push({path:__bk$ep_tags+__bk$mi_tags+']',code:"isString"}); + 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++; } } From 97b55338347bcec5b85019c1b806e781b9631199 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 00:54:34 +0900 Subject: [PATCH 26/31] refactor(seal): dedup type-fn classification and nested-each result codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure refactors, no behavior change — generated code is byte-identical for every shape covered by the codegen snapshot; full suite (2451) and snapshots unchanged. - type-resolver.ts: single `classifyTypeResult` reads the `@Type` thunk's Map/Set marker + array unwrap once, replacing three near-identical copies in seal normalization, circular analysis, and async analysis (the file comments flagged the drift risk) - deserialize-codegen: `generateNestedEachResultCode` / `generateValidateNestedEachResultCode` single-source the per-element `if (isErr/!=null) { re-path nested errors } else { success }` block that the Set / Map / array / discriminator loops each repeated (deserialize + validate sides, 8 sites) — the exact divergence class behind the each-message bug; nestedErrPush/nestedErrReturn are now fully encapsulated in those helpers typecheck/lint/knip clean, no circular deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/seal/async-analyzer.ts | 12 +-- src/seal/circular-analyzer.ts | 5 +- src/seal/deserialize-builder.ts | 177 ++++++-------------------------- src/seal/deserialize-codegen.ts | 60 +++++++++++ src/seal/seal.ts | 10 +- src/seal/type-resolver.ts | 27 +++++ 6 files changed, 132 insertions(+), 159 deletions(-) create mode 100644 src/seal/type-resolver.ts diff --git a/src/seal/async-analyzer.ts b/src/seal/async-analyzer.ts index e375cab..c534f9c 100644 --- a/src/seal/async-analyzer.ts +++ b/src/seal/async-analyzer.ts @@ -4,6 +4,7 @@ import type { InheritanceMerger } from './inheritance-merger'; 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 @@ -86,17 +87,14 @@ export class AsyncAnalyzer { } } if (out.length === 0 && t.fn) { - const result = t.fn(); - if (result === Map || result === Set) { + 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 { - const resolved = Array.isArray(result) ? (result as unknown[])[0] : result; - if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { - out.push(resolved as Function); - } + } else if (typeof resolved === 'function' && !PRIMITIVE_CTORS.has(resolved)) { + out.push(resolved as Function); } } return out; diff --git a/src/seal/circular-analyzer.ts b/src/seal/circular-analyzer.ts index 1579b08..cfaa44e 100644 --- a/src/seal/circular-analyzer.ts +++ b/src/seal/circular-analyzer.ts @@ -1,6 +1,7 @@ import type { InheritanceMerger } from './inheritance-merger'; import { BakerError } from '../common'; +import { classifyTypeResult } from './type-resolver'; /** * Static analysis for circular references. Traverses the @Type reference graph via DFS to detect @@ -46,8 +47,8 @@ export class CircularAnalyzer { } catch (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 (typeof nested === 'function' && walk(nested)) { + const { resolved } = classifyTypeResult(typeResult); + if (typeof resolved === 'function' && walk(resolved)) { return true; } } diff --git a/src/seal/deserialize-builder.ts b/src/seal/deserialize-builder.ts index 3d86752..4d1bd66 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -15,8 +15,6 @@ import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups import { DES_GEN as GEN, PRIMITIVE_TYPE_HINTS, ASSERTER_TO_GATE, GATE_ONLY_ASSERTERS } from './constants'; import type { TypeGateConfig } from './deserialize-codegen'; import { - nestedErrPush, - nestedErrReturn, toVarName, resolveGuardKey, GUARD_STRATEGIES, @@ -25,7 +23,9 @@ import { generateConversionCode, categorizeRules, generateNestedResultCode, + generateNestedEachResultCode, generateValidateNestedResult, + generateValidateNestedEachResultCode, } from './deserialize-codegen'; // ───────────────────────────────────────────────────────────────────────────── @@ -994,26 +994,14 @@ class DeserializeBuilder { 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`; - } 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 += 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 { @@ -1056,26 +1044,14 @@ class DeserializeBuilder { 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`; - } 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}`); - } - code += ` } else { ${GEN.arr}${sk}.set(${kVar}, ${GEN.result}${sk}); }\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 { @@ -1114,8 +1090,6 @@ class DeserializeBuilder { const itemVar = `__bk$di${sk}`; const discVar = `${GEN.disc}${sk}`; const resVar = `${GEN.result}${sk}`; - const errs = `${GEN.errors}${sk}`; - const nIdx = `${GEN.nestedIdx}${sk}`; const ppBase = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); const elemPathPrefix = `${ppBase}+'['+${iVar}+'].'`; const elemPath = `${ppBase}+'['+${iVar}+']'`; @@ -1136,22 +1110,8 @@ class DeserializeBuilder { execs.push(nestedSealed); code += ` case ${JSON.stringify(sub.name)}: {\n`; code += ` var ${resVar} = ${awaitKwD}execs[${execIdx}].deserialize(${itemVar}, opts);\n`; - code += ` if (isErr(${resVar})) {\n`; - code += ` var ${errs} = ${resVar}.data;\n`; - code += ` var __bk$pp${sk} = ${elemPathPrefix};\n`; - if (collectErrors) { - code += ` for (var ${nIdx}=0; ${nIdx}<${errs}.length; ${nIdx}++) {\n`; - code += ` ` + nestedErrPush(GEN.errList, `__bk$pp${sk}+${errs}[${nIdx}].path`, `${errs}[${nIdx}]`, `__ne${sk}`); - code += ` }\n`; - } else { - code += ` ` + nestedErrReturn(`__bk$pp${sk}+${errs}[0].path`, `${errs}[0]`, `__ne${sk}`); - } - code += ` } else {\n`; - if (keepDisc) { - code += ` ${resVar}[${discProp}] = ${discVar};\n`; - } - code += ` ${GEN.arr}${sk}.push(${resVar});\n`; - code += ` }\n`; + const successStmt = `${keepDisc ? `${resVar}[${discProp}] = ${discVar}; ` : ''}${GEN.arr}${sk}.push(${resVar});`; + code += generateNestedEachResultCode(resVar, elemPathPrefix, sk, collectErrors, successStmt, ' '); code += ` break;\n`; code += ` }\n`; } @@ -1234,26 +1194,14 @@ class DeserializeBuilder { 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`; - } 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}.push(${GEN.result}${sk}); }\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`; @@ -1318,7 +1266,6 @@ class DeserializeBuilder { const itemVar = `__bk$di${sk}`; const discVar = `${GEN.disc}${sk}`; const resVar = `${GEN.result}${sk}`; - const nIdx = `${GEN.nestedIdx}${sk}`; const ppBase = this.pathPrefix ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}` : JSON.stringify(fieldKey); const elemPathPrefix = `${ppBase}+'['+${iVar}+'].'`; const elemPath = `${ppBase}+'['+${iVar}+']'`; @@ -1337,16 +1284,7 @@ class DeserializeBuilder { execs.push(subSealed); code += ` case ${JSON.stringify(sub.name)}: {\n`; code += ` var ${resVar} = ${awaitKwD}execs[${execIdx}].validate(${itemVar}, opts);\n`; - code += ` if (${resVar} !== null) {\n`; - code += ` var __bk$pp${sk} = ${elemPathPrefix};\n`; - if (collectErrors) { - code += ` for (var ${nIdx}=0; ${nIdx}<${resVar}.length; ${nIdx}++) {\n`; - code += ` ` + nestedErrPush(GEN.errList, `__bk$pp${sk}+${resVar}[${nIdx}].path`, `${resVar}[${nIdx}]`, `__ne${sk}`); - code += ` }\n`; - } else { - code += ` ` + nestedErrReturn(`__bk$pp${sk}+${resVar}[0].path`, `${resVar}[0]`, `__ne${sk}`, true); - } - code += ` }\n`; + code += generateValidateNestedEachResultCode(resVar, elemPathPrefix, sk, collectErrors, ' '); code += ` break;\n`; code += ` }\n`; } @@ -1455,27 +1393,10 @@ class DeserializeBuilder { execs.push(nestedSealed); const awaitKw = this.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 = this.pathPrefix ? `${this.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`; + code += generateValidateNestedEachResultCode(`${GEN.result}${sk}`, ppInit, sk, collectErrors, ' '); } code += ` }\n`; @@ -1567,27 +1488,10 @@ class DeserializeBuilder { 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 = this.pathPrefix ? `${this.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`; + code += generateValidateNestedEachResultCode(`${GEN.result}${sk}`, ppInit, sk, collectErrors, ' '); } code += ` }\n`; @@ -1655,27 +1559,10 @@ class DeserializeBuilder { 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 = this.pathPrefix ? `${this.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`; - } else { - code += ` ` + nestedErrReturn(`${ppVar}+${GEN.result}${sk}[0].path`, `${GEN.result}${sk}[0]`, `__ne${sk}`, true); - } - code += ` }\n`; + code += generateValidateNestedEachResultCode(`${GEN.result}${sk}`, ppInit, sk, collectErrors, ' '); } code += ` }\n`; diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts index 1e8cca8..63dba75 100644 --- a/src/seal/deserialize-codegen.ts +++ b/src/seal/deserialize-codegen.ts @@ -269,6 +269,38 @@ export function generateNestedResultCode(fieldKey: string, resultVar: string, co ); } +/** + * 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); @@ -296,3 +328,31 @@ export function generateValidateNestedResult(fieldKey: string, resultVar: string ` }\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/seal.ts b/src/seal/seal.ts index 52a54d1..714472f 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -2,8 +2,9 @@ import type { SealOptions, SealedExecutors } from './interfaces'; import type { ClassCtor } from '../common'; import type { MetaStore } from '../metadata'; -import { CollectionType, metaStore } from '../metadata'; +import { metaStore } from '../metadata'; import { Direction, BakerError } from '../common'; +import { classifyTypeResult } from './type-resolver'; import { AsyncAnalyzer } from './async-analyzer'; import { CircularAnalyzer } from './circular-analyzer'; import { CircularPlaceholder } from './circular-placeholder'; @@ -140,9 +141,10 @@ class SealRun { }); } + const { collection, isArray, resolved } = classifyTypeResult(typeResult); + // Detect Map/Set collection - if (typeResult === Map || typeResult === Set) { - const collection = typeResult === Map ? CollectionType.Map : CollectionType.Set; + if (collection !== undefined) { const typeCopy = { ...meta.type, collection, isArray: false }; // collectionValue thunk → cache resolvedCollectionValue if (meta.type.collectionValue) { @@ -162,8 +164,6 @@ class SealRun { 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)}`, diff --git a/src/seal/type-resolver.ts b/src/seal/type-resolver.ts new file mode 100644 index 0000000..ccc3b41 --- /dev/null +++ b/src/seal/type-resolver.ts @@ -0,0 +1,27 @@ +import { CollectionType } from '../metadata'; + +/** + * 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; +} + +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 }; +} From ada9bb3fcab5310708e19a7f224d76ed3e20b8c5 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 00:55:36 +0900 Subject: [PATCH 27/31] chore: remove REFACTORING.md (refactor tracking doc no longer needed) Co-Authored-By: Claude Opus 4.8 (1M context) --- REFACTORING.md | 265 ------------------------------------------------- 1 file changed, 265 deletions(-) delete mode 100644 REFACTORING.md diff --git a/REFACTORING.md b/REFACTORING.md deleted file mode 100644 index 39f20f0..0000000 --- a/REFACTORING.md +++ /dev/null @@ -1,265 +0,0 @@ -# @zipbul/baker — Refactoring Plan (structure-first, domain-owned) - -Goal: a layered directory structure where **each domain owns its own types, enums, and implementation**, -and higher layers reference lower ones — never the reverse. Decide the skeleton top-down, then move -symbols into the domain that owns them, then split the oversized files. Behavior-preserving; `tsc` clean -and the full suite green at every step; generated `new Function` bodies **byte-identical** (the 5.1 -`(class,config)` cache shares one sealed form across same-config bakers, so codegen drift would be -silently cross-baker-visible). - -Conventions: per-directory `enums.ts`/`types.ts`/`constants.ts`/`interfaces.ts`; directory barrels -(`index.ts`); strict named exports (no `export *`); `import type`/`export type` for type-only -(`verbatimModuleSyntax`); move code verbatim (never "tidy" documented micro-opts during extraction). - ---- - -## Classification — layered pipeline, domain-owned (triple-reviewed) - -baker is a compiler: **author metadata → seal/compile → run**. Its axis of change is the pipeline -stage (add a rule → `rules/`; change codegen → `seal/`; change runtime → `runtime/`). A vertical/feature -slice is wrong — it would shatter the single generic `deserialize-builder` compiler and the single -generated runtime executor. So the cut is by pipeline layer. - -**Two kinds of home — DOMAIN vs COMMON (the distinction that was muddled before):** - -- **Pipeline DOMAIN** — a stage that owns a cohesive responsibility (`rules`, `transformers`, - `metadata`, `decorators`, `seal`, `config`, `runtime`). A domain owns its TYPES + ENUMS + - IMPLEMENTATION together (`transformers/` owns the `Transformer` type AND `trimTransformer`; `rules/` - owns `InternalRule`/`RequiredType` AND `isString`). -- **COMMON** — cross-cutting primitives with **no semantic owning stage**, used across the pipeline. - -**Membership test (objective): "Is there a single stage that *semantically owns* this symbol — the -place a developer would naturally look for it?"** If yes → that domain. If no (it's a pipeline-wide -primitive/concept) → `common/`. Note this is *semantic* ownership, not merely "fewest importers": -`CollectionType` (Map/Set of a field) is owned by `metadata` even though `seal` also reads it, because -`TypeDef` *defines* it; `Direction` (Deserialize/Serialize) is owned by **nobody** — it is the -pipeline's two directions — so it is common even though only decorators+seal use it. - -Applying the test to the genuinely-ownerless symbols (verified by usage): `errors` -(`BakerError` used by 6 areas), `utils` (`isAsyncFunction`/`isPromiseLike`), `Direction`, -`CacheKey` (codegen cache key, rules+seal, no single owner), `ClassCtor` (generic `new(...)=>T`) → all -**common**. `symbols` (the RAW metadata symbol) is common by nature but **pinned at root** (published -`./symbols` subpath + `Symbol.metadata` polyfill load-order). Everything with a real owner stays in its -domain — there is **no "core kernel" holding other domains' types** (that inverts the arrows; -`Transformer` is transformers', not common). - -**The RAW metadata IR is a layer, not a leaf.** `Raw*Meta` + the `*Def` family *aggregate* the author -domains' types (`RuleDef.rule: InternalRule`, `TransformDef.fn: TransformFunction`), so the IR sits -ABOVE `rules/`/`transformers/` and imports them downward — it is NOT a leaf below them. - ---- - -## Target skeleton (bottom → top; every import points downward) - -``` -src/ -├── symbols.ts # COMMON-by-nature but ROOT-PINNED — Symbol.metadata polyfill (load-order) + published ./symbols. -├── common/ # NO owning stage — cross-cutting primitives (the bottom leaf; imports nothing from a stage) -│ # errors/ : BakerError, BakerIssue(Set), guards, toBakerIssueSet, BAKER_ERROR -│ # utils : isAsyncFunction, isPromiseLike -│ # enums : Direction, CacheKey (no semantic owner) -│ # types : ClassCtor (generic new(...)=>T) -│ # interfaces: RuntimeOptions (seam: seal threads it, runtime consumes — neither owns) -│ -├── rules/ # DOMAIN (author primitive) → ./rules · OWNS its types+enums+impl -│ # types: InternalRule, EmittableRule, EmitContext, RulePlan* -│ # enums: RequiredType, RuleOp, RulePlanExprKind, RulePlanCheckKind -│ # impl: string…(split Phase E), number…, typechecker, combinators, -│ # create-rule, rule-plan, rule-metadata -├── transformers/ # DOMAIN (author primitive) → ./transformers · OWNS Transformer/TransformParams/TransformFunction + impls -│ -├── metadata/ # DOMAIN — IR layer (ABOVE author primitives): the schema decorators write & seal reads -│ # types: RawClassMeta, RawPropertyMeta, RuleDef, TransformDef, ExposeDef, -│ # ExcludeDef, TypeDef, PropertyFlags, MessageArgs -│ # enums: CollectionType (TypeDef defines it — metadata is its semantic owner) -│ # impl: collect, meta-access (read/write RAW on the class via symbols) -│ # imports ↓ rules (InternalRule), transformers (Transformer), common, symbols -├── decorators/ # DOMAIN → ./decorators — @Field etc. PRODUCE metadata -│ # enums: ExcludeMode (sole consumer = decorators) -│ # imports ↓ metadata, rules, transformers, common -├── seal/ # DOMAIN (compile) — owns its output + options -│ # types: SealedExecutors ; interfaces: SealOptions ; enums: GuardKey -│ # (RuntimeOptions is in common/ — seal only threads it through SealedExecutors' signature) -│ # impl: seal, deserialize-builder, serialize-builder, compile-cache, -│ # async-analysis, merge-inheritance, circular-analyzer, -│ # expose-validator, validate-meta, codegen-utils -│ # imports ↓ metadata, rules, decorators(schema), common -│ # (config is ABOVE seal — config imports SealOptions from seal, not vice versa) -├── config/ # DOMAIN — normalizeConfig (BakerConfig → SealOptions) ; imports ↓ common, seal(SealOptions type) -├── runtime/ # DOMAIN (run, rename of functions/) — deserialize/serialize/validate, check-call-options -│ # imports ↓ seal (SealedExecutors), common (RuntimeOptions, errors) -└── baker.ts # ROOT — composition root ; imports ↓ config, seal, runtime -``` - -### The one irreducible seam (document, don't fight) -`rules/` `EmittableRule.emit(ctx)` / `EmitContext.addExecutor(exec: SealedExecutors)` references -`SealedExecutors`, which `seal/` owns. That is the visitor pattern: rules define `emit`, seal supplies -the context and calls it. It is a single **type-only (erased) forward edge `rules → seal`**, kept as -`import type` so there is no runtime cycle (dpdm sees none). This is the ONLY upward edge; everything -else is strictly downward. (It exists today inside the monolithic `types.ts`; the split makes it an -explicit, commented `import type`.) - -### Placement decisions (by the semantic-owner test) -- **DOMAIN (has an owner):** - - `Transformer*` → **transformers/** (its owner); `TransformDef`(metadata) imports it downward. - - `RequiredType`/`RuleOp`/`RulePlan*` → **rules/**; metadata/seal import downward. - - `CollectionType` → **metadata/** (`TypeDef` defines it); seal imports downward. - - `MessageArgs` → **metadata/** (structural member of `RuleDef`/`RawPropertyMeta` — owned by the IR, not by whoever consumes it). - - `SealOptions`/`SealedExecutors` → **seal/** (seal produces/owns them); runtime/config/baker import downward. - - `ExcludeMode` → **decorators/** (sole consumer). -- **COMMON (no owner — fails the test):** - - `Direction` (Deserialize/Serialize — pipeline-wide), `CacheKey` (codegen cache key, rules produce / seal consume), - `ClassCtor` (generic constructor), `errors`, `utils` → **common/**. - - `RuntimeOptions` → **common/** (seam): seal only *threads* it through `SealedExecutors`' signature and - runtime *consumes* it — neither owns it (mirrors `CacheKey`). Putting it in `runtime/` would create a - `seal → runtime` upward edge via `SealedExecutors`; `common/` keeps the seam below both. It is published - (`index.ts`), so its public re-export repoints to `common/`. - - `symbols` → common-by-nature but **root-pinned** (published subpath + polyfill load-order). - ---- - -## Phase 0 / 1 (DONE) -P0 enum conversion. P1 (5.0/5.1): `Baker` class, per-baker runtime `app.deserialize/validate/serialize`, -global runtime + `Class[SEALED]` + `SEALED` symbol removed, executors in each Baker's `#executors` map, -`(class,config)` compile cache + cache-hit nested seeding, `Baker.#require` prototype-chain walk. - -## Phase A — compile-cache extraction (FIRST: self-contained, spec-backed, zero codegen risk) -Extract `seal/compile-cache.ts` (the WeakMap + `configFingerprint`/`getCached`/`setCached`/`clearCached`/ -`clearAllCached`). It already has a committed spec (`src/seal/compile-cache.spec.ts`, repoint its import) -and a consumer (`test/integration/helpers/unseal.ts` imports `clearAllCached`). Touches no `new Function` -body. Of seal.ts's 7 test-only exports it relocates the cache ones; the rest (`mergeInheritance`, -`circularPlaceholder`) move in Phase C. -Gate: suite green; codegen unchanged. - -## Phase B — establish the skeleton (moves only, no logic change) -1. `functions/` → **`runtime/`** (repoint imports; `functions` is not in `package.json` exports or - `index.ts`, so no published path changes). -2. Create **`common/`**, **`metadata/`**, **`config/`**; move `errors.ts`→`common/errors/`, - `utils.ts`→`common/`, `collect.ts`+`meta-access.ts`→`metadata/`, `configure.ts`→`config/` (owns - `normalizeConfig` + `BakerConfig` type + `BAKER_CONFIG_KEYS`). Leave `symbols.ts` and `baker.ts` at root. - (The cross-cutting enums `Direction`/`CacheKey` + `ClassCtor` land in `common/` during Phase C.) -3. `index.ts` re-export paths repointed. NOTE the PUBLIC re-exports that move (each is a public-barrel - edit, not just internal): `RequiredType`→rules/, `ExcludeMode`→decorators/, `EmittableRule`→rules/, - `Transformer`/`TransformParams`→transformers/ (Phase C); `BakerConfig`→config/ (Phase B); - `RuntimeOptions`→common/ (Phase C). Repoint each as its symbol moves. -Gate: `tsc` + suite green; `deps:check` no new cycles; public **type surface (names+shapes) unchanged** -(note: emitted `.d.ts` *internal re-export paths* necessarily change on a move — that is expected; the -invariant is the public names/shapes, not byte-identical `.d.ts`). - -## Phase C — dissolve `types.ts`/`enums.ts`/`interfaces.ts` into their owning domains -Apply the placement table above. Create `metadata/` IR types, `rules/` types+enums, `transformers/` -types, `decorators/` enums, `seal/` types+interfaces. Relocate `create-rule`/`rule-plan`/`rule-metadata` -into `rules/`. Mark the `rules → seal` `EmitContext`→`SealedExecutors` edge `import type` and comment it. -Also extract `seal/async-analysis.ts` (`analyzeAsync`+`nestedClassesOf`), `seal/merge-inheritance.ts`, -and `circularPlaceholder` out of `seal.ts` (each carries a test-only export) → `sealOne` becomes a clean -~160-line orchestrator. Do NOT fragment `sealOne`'s inline pipeline (typedef normalization stays inline). -Gate: `tsc` + suite green; `deps:check` clean — **verify zero edge from a lower layer up to a higher one -except the single documented `rules → seal` erased type edge**; codegen byte-identical. - -## Phase D — decompose the big builders -Split `deserialize-builder.ts` (1986) + `serialize-builder.ts` (446) into single-purpose codegen modules -(verbatim): error-codegen, conversion-codegen, expose-resolver, guard-strategies, rule-analysis, -issue-extras, emit-context, rule-emitter, nested-codegen, nested-codegen-validate, field-codegen, -transform-codegen, serialize-field-codegen, slim drivers. **Cycle break:** `field-codegen ↔ -nested-codegen-validate` via an `emitField` callback (dependency inversion) — this alters the codegen -call path, so do it as its own separately-gated commit (extract the leaf modules verbatim first). -Gate: byte-identical codegen — **mechanized** (see harness below), not eyeballed. - -## Phase E — `rules/string.ts` split (DONE) -2526 lines, flat, low-coupling. Split into `string-shared.ts` + six concern modules -(`basic`/`width`/`encoding`/`format`/`identifier`/`finance`) behind a pure re-export barrel so -`rules/index.ts` (the `./rules` subpath) stays byte-stable. Every regex/data constant/checksum helper -moved verbatim — declaration text byte-identical; `./rules` export set (83) unchanged; snapshot 15/0. - -## Phase F — barrels / exports / `.d.ts` close-out -Per-directory barrels; public barrels + root `/index.ts` + `./symbols` stable. `.d.ts` review, -`deps:check`, `knip`. Optional nit: de-dupe `runtime/` `run*` unwrap/guard helpers. - ---- - -## Prerequisite — codegen-snapshot harness (build BEFORE Phase C/D) -The "byte-identical codegen" invariant is currently only asserted. Add a `bun test` that, for a -representative DTO set, captures each generated executor's source (`sealed.deserialize/serialize/ -validate.toString()`, reachable via the test-only `getCached(Cls, configFingerprint(opts))`) into a -committed snapshot and diffs it. (Captures the generated **body text** only — injected closure data -like `refs`/`regexes`/`execs` is not part of `.toString()`; that is exactly the "codegen byte-identical" -invariant, which is about the body.) Land it as its own commit before any seal/ -builder code is moved (Phases C/D feed/own codegen), so drift is machine-checked every commit. Phases -A/B don't touch codegen but the harness should exist before C. - -## Execution order + STATUS (each step = one commit; `tsc` + suite green; codegen byte-identical) -1. ~~P0 enums~~, ~~P1 Baker/runtime/cache~~ — **DONE** (5.0/5.1). -2. ~~**A** — extract `compile-cache.ts`~~ — **DONE**. -3. ~~**snapshot harness** (machine-check codegen byte-identity)~~ — **DONE** (15 snapshots). -4. ~~**B** — skeleton: `functions/`→`runtime/`, create `common/`+`metadata/`+`config/`, move substrate~~ — **DONE**. -5. ~~**C** — C1 dissolve `types/enums/interfaces` into owning domains (incl. shims then delete); C2 extract - `async-analysis`/`merge-inheritance`/`circular-placeholder`/`constants` from seal.ts~~ — **DONE**. -6. ~~**D** — builders → `DeserializeBuilder`/`SerializeBuilder` CLASSES (state as fields, methods; no - ctx-threading / fragment re-return / cycle-break callback; inline-nested = child builder). Byte-identical~~ — **DONE**. - ~~Plus: relocate rule machinery (`create-rule`/`rule-plan`/`rule-metadata`) into `rules/`~~ — **DONE**. -7. ~~**F** — per-directory barrels (`common`/`metadata`/`config`/`seal`/`runtime` index.ts) + strict - exports; cross-dir imports routed through barrels (one documented deep edge: `rules/types → - seal/types` for `SealedExecutors`)~~ — **DONE**. -8. ~~**E** — `string.ts` split into `string-shared` + six concern modules behind a byte-stable - `./rules` barrel~~ — **DONE**. -9. ~~**Post-D cleanup** — remove the `createChild` `Object.create`+readonly-cast hack (real constructor - `scope` arg); extract pure codegen utilities out of `DeserializeBuilder` into `seal/deserialize-codegen.ts` - (2003→1624 lines); unify the four direction-mirror expose helpers into `resolveExposeName`/ - `resolveExposeGroups` (single source of truth in `seal/codegen-utils.ts`); move orphan - `error-system.spec.ts` into `common/`~~ — **DONE**. - -10. ~~**Audit cleanup** — convert the seal pipeline (`sealOne`/`sealRegistry`) to a `SealRun` class - (kill recursion state-threading); rename `transformers/*.transformer.ts` → kebab plain + per-file - transformer specs; split `string.spec.ts` into per-module specs mirroring the source; split each - published dir into `public.ts` (curated published surface) + `index.ts` (full internal barrel) so - every cross-domain import routes through `../` with no deep import~~ — **DONE**. - -11. ~~**DI class extraction (collaborator-owned state)** — lift the seal/metadata/config stage logic - from free functions into constructor-injected classes whose methods read `this`/private `#fields` - (the "uses-`this`" test; genuinely stateless helpers stay functions — `validateExposeStacks`, all - codegen emitters, the `runtime/` dispatchers): `MetaStore` (the single RAW-access boundary; - `metaStore` singleton, injected into `SealRun`/`Baker`), `InheritanceMerger(#meta)`, - `CircularAnalyzer(#merger)`, `AsyncAnalyzer(#resolve,#merger)`, `MetaValidator(#meta)`, - `ConfigNormalizer(#validKeys)`, and `CircularPlaceholder(#message)` (writable own/arrow executor - fields so `sealOne` can `Object.assign`-replace them in place, preserving reference identity). - `SealRun`'s constructor wires the collaborator graph by injection. Smear cleanup: builder/codegen - types → `seal/types.ts` + `seal/interfaces.ts` (`DeserializeExecutor`/`ValidateExecutor`/ - `ChildScope`/`CategorizedRules`/`ResolvedTypeGate`); codegen data consts → `seal/constants.ts` as - distinct `DES_GEN`/`SER_GEN` (alias-imported as `GEN` → byte-identical) + - `PRIMITIVE_TYPE_HINTS`/`ASSERTER_TO_GATE`/`GATE_ONLY_ASSERTERS`. The `EmitContext`-coupled codegen - types (`GuardParams`/`TypeGateConfig`) stay internal to `deserialize-codegen.ts` so the - barrel-exported `interfaces.ts` keeps no `rules → seal` edge (which would close a `rules↔seal` - cycle)~~ — **DONE**. - -Result: `src/` root holds only `baker.ts` (composition root) + `symbols.ts` (pinned). All other code -lives in its domain (`common/ metadata/ config/ rules/ transformers/ seal/ runtime/`). The builders and -the seal pipeline are classes; the seal/metadata/config stage logic is constructor-injected classes that -own their collaborators/state as private `#fields` (`MetaStore`, `InheritanceMerger`, `CircularAnalyzer`, -`AsyncAnalyzer`, `MetaValidator`, `ConfigNormalizer`, `CircularPlaceholder`), while genuinely stateless -helpers (codegen emitters, `runtime/` dispatchers, `validateExposeStacks`) stay functions. Pure codegen -utilities live in sibling `*-codegen`/`codegen-utils` modules. No `Object.create`/`as`-cast hacks, no `any`/`@ts-ignore`/`eslint-disable` in source. Junk-drawer -`types.ts`/`enums.ts`/`interfaces.ts` are gone. Acyclic. **Barrels:** every directory has an `index.ts`; -the three published dirs (`rules`/`transformers`/`decorators`) additionally have a `public.ts` — the -package.json subpath publishes `public.ts` (curated), while same-repo code imports the full `index.ts` -barrel, so internal symbols (`EmitContext`/`InternalRule`/`emitRulePlan`/…) reach consumers without -leaking publicly. The ONLY cross-dir deep import is `rules/types → seal/types` (type-only cycle-break). -Unit specs are co-located per source file. Each phase independently revertible; regressions isolate to one layer. - ---- - -## Invariants (every commit) -- `bunx tsc --noEmit` clean; `bun test` fully green (currently 2397 pass). -- Generated `new Function` bodies byte-identical (snapshot-checked from Phase C onward). -- Public surface unchanged: `/index.ts` names+shapes, subpath barrels (`./rules`, `./transformers`, - `./decorators`, `./symbols`), `package.json` exports. `./symbols` keeps pointing at root `symbols.ts`. -- **Strict downward layering**: `common/` (+ root `symbols`) ← rules·transformers ← metadata ← - decorators ← seal ← {config, runtime} ← baker. `common/` imports NOTHING from any stage (if it would - need to, the symbol has an owner and isn't common). The ONLY upward edge permitted is the documented - type-only `rules → seal` (`EmitContext.addExecutor: SealedExecutors`). `deps:check` clean; `knip` clean. -- `verbatimModuleSyntax` respected. - ---- - -## Forward-looking — OpenAPI 3.0 -`app.toOpenAPI()` walks the type graph from the roots a baker collected — per-app isolation falls out of -the `Baker` boundary; class identity stays the isolation boundary; single-app projects have one `Baker`. From ca71ed3720e32d98d37b4fec9c995f5a02f9deba Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 00:57:15 +0900 Subject: [PATCH 28/31] test(seal): cover async deserialize/validate of discriminator arrays The `await` branch of the discriminator-each codegen (added with the discriminator-array fix) was only exercised by sync DTOs; add an async-transform DTO so the awaited per-element dispatch and the async invalidDiscriminator path are covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/e2e/discriminator-advanced.test.ts | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/e2e/discriminator-advanced.test.ts b/test/e2e/discriminator-advanced.test.ts index e9b4642..98b58a8 100644 --- a/test/e2e/discriminator-advanced.test.ts +++ b/test/e2e/discriminator-advanced.test.ts @@ -409,6 +409,69 @@ 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) // ───────────────────────────────────────────────────────────────────────────── From bf50a42dbbe953c17a639283280baca2fe964926 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 01:08:55 +0900 Subject: [PATCH 29/31] fix(rules,runtime,decorators): isolate isURL constraints + tighten audit residuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground-truth verified each remaining deep-review item; fixed the real ones (RED test first where observable), pinned the intentional one, and confirmed two agent-flagged "bugs" were non-issues. Fixes: - rules: isURL with default protocols copies into `constraints` instead of sharing the frozen module-level array — one rule mutating its constraints can no longer corrupt every other isURL rule - runtime: SEAL_TIME_KEYS (per-call rejection) now derives from a new single-source SEAL_OPTION_KEYS (Record) shared with the compile-cache fingerprint, instead of a hand-maintained partial alias list that would silently fall through on a future renamed/added option - decorators: ARRAY_OF is a `unique symbol`, so ArrayOfMarker is keyed precisely (`[ARRAY_OF]: true`) instead of a catch-all `[key: symbol]: true` index signature Tests: - pin unixSecondsTransformer's whole-second floor as intentional (standard Unix-timestamp convention, not the round-trip "bug" an audit flagged): -500ms -> -1s, 1500ms -> 1s - cover async deserialize/validate of discriminator arrays (the `await` codegen branch) Verified NON-issues (no change): per-rule message/context copy is required to pass each rule's own constraints to a message fn (not redundant with the field-level copy); serialize's `out[key]=undefined` for a non-optional field is consistent with flat fields, not a nested-only asymmetry. typecheck/lint/knip clean, no circular deps, 2456 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../discriminator-array-and-validator-fixes.md | 4 ++++ src/decorators/constants.ts | 5 +++-- src/decorators/interfaces.ts | 3 ++- src/rules/string-format.spec.ts | 9 +++++++++ src/rules/string-format.ts | 6 ++++-- src/runtime/check-call-options.ts | 12 +++++------- src/seal/compile-cache.ts | 16 +++------------- src/seal/constants.ts | 15 +++++++++++++++ src/seal/index.ts | 1 + src/transformers/date.spec.ts | 8 ++++++++ 10 files changed, 54 insertions(+), 25 deletions(-) diff --git a/.changeset/discriminator-array-and-validator-fixes.md b/.changeset/discriminator-array-and-validator-fixes.md index fc74214..67547f4 100644 --- a/.changeset/discriminator-array-and-validator-fixes.md +++ b/.changeset/discriminator-array-and-validator-fixes.md @@ -27,3 +27,7 @@ model. Several change observable behavior — review before upgrading: - **`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/src/decorators/constants.ts b/src/decorators/constants.ts index fd5410d..7679570 100644 --- a/src/decorators/constants.ts +++ b/src/decorators/constants.ts @@ -1,8 +1,9 @@ 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. -export const ARRAY_OF = Symbol.for('baker:arrayOf'); +// 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 diff --git a/src/decorators/interfaces.ts b/src/decorators/interfaces.ts index 0a1823a..06b549e 100644 --- a/src/decorators/interfaces.ts +++ b/src/decorators/interfaces.ts @@ -3,13 +3,14 @@ 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 [key: symbol]: true; + readonly [ARRAY_OF]: true; readonly rules: EmittableRule[]; } diff --git a/src/rules/string-format.spec.ts b/src/rules/string-format.spec.ts index a5260dc..2da63b5 100644 --- a/src/rules/string-format.spec.ts +++ b/src/rules/string-format.spec.ts @@ -86,6 +86,15 @@ describe('isEmail', () => { }); 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); }); diff --git a/src/rules/string-format.ts b/src/rules/string-format.ts index 222cf4a..a8da926 100644 --- a/src/rules/string-format.ts +++ b/src/rules/string-format.ts @@ -27,7 +27,7 @@ interface IsURLOptions { protocols?: string[]; } -const URL_PROTOCOLS_DEFAULT = ['http', 'https', 'ftp']; +const URL_PROTOCOLS_DEFAULT = Object.freeze(['http', 'https', 'ftp']); function isURL(options?: IsURLOptions): EmittableRule { const protocols = options?.protocols ?? URL_PROTOCOLS_DEFAULT; @@ -38,7 +38,9 @@ function isURL(options?: IsURLOptions): EmittableRule { return makeRule({ name: 'isURL', requiresType: RequiredType.String, - constraints: { format: 'uri', protocols }, + // 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); diff --git a/src/runtime/check-call-options.ts b/src/runtime/check-call-options.ts index c6d3145..f2af5ac 100644 --- a/src/runtime/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -2,16 +2,14 @@ import type { RuntimeOptions } from '../common'; import { BakerError } from '../common'; import { BAKER_CONFIG_KEYS } from '../config'; +import { SEAL_OPTION_KEYS } from '../seal'; 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 aliases they normalize to. -const SEAL_TIME_KEYS = new Set([ - ...BAKER_CONFIG_KEYS, - 'enableImplicitConversion', - 'exposeDefaultValues', - 'whitelist', -]); +// 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. +const SEAL_TIME_KEYS = new Set([...BAKER_CONFIG_KEYS, ...SEAL_OPTION_KEYS]); /** * @internal — validate per-call options object at public-API entry. diff --git a/src/seal/compile-cache.ts b/src/seal/compile-cache.ts index e9461bd..383d911 100644 --- a/src/seal/compile-cache.ts +++ b/src/seal/compile-cache.ts @@ -1,5 +1,7 @@ import type { SealOptions, SealedExecutors } from './interfaces'; +import { SEAL_OPTION_KEYS } from './constants'; + // ───────────────────────────────────────────────────────────────────────────── // (class, config) executor cache — content-addressed sharing across bakers // ───────────────────────────────────────────────────────────────────────────── @@ -19,18 +21,6 @@ import type { SealOptions, SealedExecutors } from './interfaces'; * 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. */ -// Every seal-affecting option, in fixed fingerprint order. Typed as `Record` -// so adding (or removing) a SealOptions field is a COMPILE error here until the fingerprint covers it — -// without this, a new option would silently collide two configs onto the same key and share a wrong -// executor across bakers. The literal's key order is the fingerprint's bit order. -const FINGERPRINT_KEYS = Object.keys({ - enableImplicitConversion: true, - exposeDefaultValues: true, - stopAtFirstError: true, - whitelist: true, - debug: true, -} satisfies Record) as (keyof SealOptions)[]; - class CompileCache { #cache: WeakMap>>; @@ -44,7 +34,7 @@ class CompileCache { */ static fingerprint(o: SealOptions): string { let fp = ''; - for (const key of FINGERPRINT_KEYS) { + for (const key of SEAL_OPTION_KEYS) { fp += o[key] ? '1' : '0'; } return fp; diff --git a/src/seal/constants.ts b/src/seal/constants.ts index e151876..9b1d25f 100644 --- a/src/seal/constants.ts +++ b/src/seal/constants.ts @@ -1,6 +1,21 @@ +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). diff --git a/src/seal/index.ts b/src/seal/index.ts index 261a33e..6327c40 100644 --- a/src/seal/index.ts +++ b/src/seal/index.ts @@ -1,3 +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/transformers/date.spec.ts b/src/transformers/date.spec.ts index 453203f..0006b4f 100644 --- a/src/transformers/date.spec.ts +++ b/src/transformers/date.spec.ts @@ -24,6 +24,14 @@ describe('unixSecondsTransformer', () => { 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', () => { From e1273f2e60b5414352f64f3a95b580fdb62e86b1 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 04:11:13 +0900 Subject: [PATCH 30/31] test(bench): migrate benchmarks to the Baker instance API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark suite referenced a removed global API (Recipe/seal/deserialize/ serialize/validate/configure as named exports of the package root), so the whole suite failed to type-check and run. Rewire every bench to the current `new Baker()` instance API (configure({...}) → new Baker({...})). Also fix a collateral clobber where a blanket rename had rewritten ajv's local validate() call in cold.bench. Co-Authored-By: Claude Opus 4.8 (1M context) --- bench/array.bench.ts | 14 +-- bench/cold.bench.ts | 14 +-- bench/error.bench.ts | 15 ++-- bench/nested.bench.ts | 18 ++-- bench/proof-inline-emit.bench.ts | 90 ++++++++++---------- bench/proof-optimization-candidates.bench.ts | 34 ++++---- bench/proof-sync-overhead.bench.ts | 24 +++--- bench/simple.bench.ts | 12 +-- bench/validate-only.bench.ts | 32 +++---- 9 files changed, 134 insertions(+), 119 deletions(-) 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); }); }); From 26e13af5c1bf168282d11d9f44ce13e1f03d7937 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Mon, 22 Jun 2026 04:11:34 +0900 Subject: [PATCH 31/31] fix(seal): validate declared Set/Map collection elements, speed up collection validate Headline bug fix + a collection-validate perf win + an internal layering cleanup (full detail in the changeset). - Declared @Type(() => Set/Map) collections now validate their elements. The declared-collection codegen hand-rolled its per-element loop apart from the canonical path and (a) dropped every each-rule on a Map, (b) ignored the runtime groups filter, (c) passed the whole collection (not the failing element) to a function message. All four Set/Map x deserialize/validate sites now route through one shared emitter with canonical rule-major order, group filtering, per-element value binding, and field[i] paths. RED tests added first. - Collection validate is ~4.7x faster on large arrays: the inline-nested validate path no longer eagerly allocates a per-element error-path string on the happy path; it is built only at the cold error-push sites. deserialize and all error paths are byte-identical (codegen snapshot updated). - createRule is now also exported from the ./rules subpath. - luxon/moment peer-dep error narrowed to ERR_MODULE_NOT_FOUND so a peer that is installed but throws during evaluation surfaces its real error. Internal-only: extracted TypeDef normalization out of the sealOne god-function, split large static lookup tables and the string-format validators into cohesive modules, simplified stateless helpers (config/type/expose normalizers as plain functions), and ran an oxfmt pass over the source. Public surface unchanged except the createRule subpath export (verified by export-diff + three adversarial reviews). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...declared-collection-validation-and-perf.md | 32 + src/baker.ts | 24 +- src/common/error-system.spec.ts | 2 +- src/config/config-normalizer.ts | 45 +- src/config/index.ts | 2 +- src/decorators/field.ts | 9 +- src/decorators/interfaces.ts | 1 + src/decorators/transform.spec.ts | 4 +- src/metadata/index.ts | 11 +- src/metadata/interfaces.ts | 2 +- src/metadata/meta-store.ts | 2 +- src/rules/binary.ts | 3 +- src/rules/combinators.spec.ts | 2 +- src/rules/constants.ts | 1015 +++++++++++++++++ src/rules/index.ts | 2 +- src/rules/locales.spec.ts | 2 +- src/rules/locales.ts | 218 +--- src/rules/number.spec.ts | 2 +- src/rules/number.ts | 2 +- src/rules/public.ts | 1 + src/rules/string-basic.spec.ts | 2 +- src/rules/string-crypto.ts | 56 + src/rules/string-datetime.ts | 25 + src/rules/string-encoding.spec.ts | 2 +- src/rules/string-finance.spec.ts | 14 +- src/rules/string-finance.ts | 261 +---- src/rules/string-format.spec.ts | 5 +- src/rules/string-format.ts | 174 +-- src/rules/string-geo.ts | 58 + src/rules/string-identifier.ts | 507 +------- src/rules/string.ts | 24 +- src/rules/typechecker.spec.ts | 2 +- src/rules/typechecker.ts | 5 +- src/runtime/check-call-options.ts | 22 +- src/runtime/constants.ts | 11 + src/runtime/deserialize.spec.ts | 47 +- src/runtime/deserialize.ts | 3 +- src/runtime/serialize.spec.ts | 22 +- src/seal/async-analyzer.ts | 2 +- src/seal/circular-analyzer.spec.ts | 2 +- src/seal/compile-cache.spec.ts | 18 +- src/seal/compile-cache.ts | 3 +- src/seal/deserialize-builder.spec.ts | 160 ++- src/seal/deserialize-builder.ts | 241 ++-- src/seal/deserialize-codegen.ts | 14 +- src/seal/inheritance-merger.ts | 2 +- src/seal/interfaces.ts | 21 +- src/seal/meta-validator.spec.ts | 29 +- src/seal/meta-validator.ts | 6 +- src/seal/seal.ts | 81 +- src/seal/serialize-builder.spec.ts | 10 +- src/seal/serialize-builder.ts | 15 +- src/seal/type-normalizer.ts | 73 ++ src/seal/type-resolver.ts | 17 +- src/transformers/constants.ts | 3 + src/transformers/interfaces.ts | 21 + src/transformers/luxon.ts | 20 +- src/transformers/moment.ts | 19 +- src/transformers/public.ts | 3 +- test/e2e/async-transform.test.ts | 8 +- test/e2e/baker-scoped-isolation.test.ts | 8 +- test/e2e/boundary-values.test.ts | 2 +- test/e2e/date-constraints.test.ts | 8 +- test/e2e/implicit-conversion.test.ts | 4 +- test/e2e/multi-app-isolation.test.ts | 2 +- test/e2e/real-world-dto.test.ts | 4 +- test/e2e/serialize-pipeline.test.ts | 4 +- test/e2e/string-validators-full.test.ts | 4 +- test/e2e/string-validators.test.ts | 2 +- test/e2e/validate-inline-parity.test.ts | 2 - test/e2e/validators-missing-e2e.test.ts | 4 +- .../codegen-snapshot.test.ts.snap | 53 +- test/integration/codegen-snapshot.test.ts | 15 +- test/integration/codegen.test.ts | 4 +- test/integration/deserialize.test.ts | 6 +- test/integration/inheritance.test.ts | 6 +- 76 files changed, 1927 insertions(+), 1590 deletions(-) create mode 100644 .changeset/declared-collection-validation-and-perf.md create mode 100644 src/rules/constants.ts create mode 100644 src/rules/string-crypto.ts create mode 100644 src/rules/string-datetime.ts create mode 100644 src/rules/string-geo.ts create mode 100644 src/runtime/constants.ts create mode 100644 src/seal/type-normalizer.ts create mode 100644 src/transformers/constants.ts 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/src/baker.ts b/src/baker.ts index 4279d59..d27bbd8 100644 --- a/src/baker.ts +++ b/src/baker.ts @@ -1,10 +1,9 @@ -import type { BakerConfig } from './config'; import type { BakerIssueSet, ClassCtor, RuntimeOptions } from './common'; +import type { BakerConfig } from './config'; import type { SealOptions, SealedExecutors } from './seal'; -import { configNormalizer } from './config'; import { BakerError } from './common'; -import { sealRegistry } from './seal'; +import { normalizeConfig } from './config'; import { runDeserialize, runDeserializeSync, @@ -17,6 +16,7 @@ import { runValidateSync, runValidateAsync, } from './runtime'; +import { sealRegistry } from './seal'; /** * A baker — an isolated registration + seal + runtime boundary. Each `new Baker()` owns its own @@ -47,7 +47,9 @@ export class Baker { #sealed = false; constructor(config?: BakerConfig) { - this.#options = config === undefined ? Object.freeze({}) : configNormalizer.normalize(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`. */ @@ -96,11 +98,8 @@ export class Baker { deserializeSync = (Class: ClassCtor, input: unknown, options?: RuntimeOptions): T | BakerIssueSet => runDeserializeSync(this.#require(Class), Class.name, input, options); - deserializeAsync = ( - Class: ClassCtor, - 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: ClassCtor, @@ -111,11 +110,8 @@ export class Baker { validateSync = (Class: ClassCtor, input: unknown, options?: RuntimeOptions): true | BakerIssueSet => runValidateSync(this.#require(Class), Class.name, input, options); - validateAsync = ( - Class: ClassCtor, - 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/common/error-system.spec.ts b/src/common/error-system.spec.ts index 0425fb7..5caf9f4 100644 --- a/src/common/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 '../rules/create-rule'; -import { BakerError } from './errors'; import { isPassportNumber } from '../rules/locales'; import { isDivisibleBy, max, min } from '../rules/number'; +import { BakerError } from './errors'; // 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/config/config-normalizer.ts b/src/config/config-normalizer.ts index 9b4e56b..57d0bb0 100644 --- a/src/config/config-normalizer.ts +++ b/src/config/config-normalizer.ts @@ -5,36 +5,25 @@ import { BakerError } from '../common'; import { BAKER_CONFIG_KEYS } from './constants'; /** - * Validates a {@link BakerConfig} and maps it to the internal {@link SealOptions}. Holds the set of - * valid config keys as an injected collaborator (default: {@link BAKER_CONFIG_KEYS}), so the unknown-key - * rejection reads from instance state. Used by `new Baker(config)` via the `configNormalizer` singleton. + * 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 class ConfigNormalizer { - readonly #validKeys: ReadonlySet; - - constructor(validKeys: ReadonlySet = BAKER_CONFIG_KEYS) { - this.#validKeys = validKeys; +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}.`, + ); } - - normalize(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(', ')}.`); } - for (const key of Object.keys(config)) { - if (!this.#validKeys.has(key as keyof BakerConfig)) { - throw new BakerError(`[baker] unknown key '${key}'. ` + `Valid keys: ${[...this.#validKeys].join(', ')}.`); - } - } - return Object.freeze({ - enableImplicitConversion: config.autoConvert ?? false, - exposeDefaultValues: config.allowClassDefaults ?? false, - stopAtFirstError: config.stopAtFirstError ?? false, - whitelist: config.forbidUnknown ?? false, - debug: config.debug ?? false, - }); } + return Object.freeze({ + enableImplicitConversion: config.autoConvert ?? false, + exposeDefaultValues: config.allowClassDefaults ?? false, + stopAtFirstError: config.stopAtFirstError ?? false, + whitelist: config.forbidUnknown ?? false, + debug: config.debug ?? false, + }); } - -export const configNormalizer = new ConfigNormalizer(); diff --git a/src/config/index.ts b/src/config/index.ts index 04c1c81..6512adf 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,4 @@ // Directory barrel — config normalization (BakerConfig → SealOptions). -export { ConfigNormalizer, configNormalizer } from './config-normalizer'; +export { normalizeConfig } from './config-normalizer'; export { BAKER_CONFIG_KEYS } from './constants'; export type { BakerConfig } from './interfaces'; diff --git a/src/decorators/field.ts b/src/decorators/field.ts index a96971b..7aaf0b3 100644 --- a/src/decorators/field.ts +++ b/src/decorators/field.ts @@ -1,13 +1,13 @@ -import type { EmittableRule, InternalRule } from '../rules'; 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 { Direction, BakerError, isAsyncFunction, isPromiseLike } from '../common'; import { metaStore } from '../metadata'; -import { ExcludeMode } from './enums'; import { ARRAY_OF, FIELD_OPTION_KEYS } from './constants'; +import { ExcludeMode } from './enums'; // ───────────────────────────────────────────────────────────────────────────── // arrayOf — Array element validation marker (compiles to per-rule `each: true`) @@ -101,7 +101,10 @@ 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). */ +// 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; diff --git a/src/decorators/interfaces.ts b/src/decorators/interfaces.ts index 06b549e..6a0b780 100644 --- a/src/decorators/interfaces.ts +++ b/src/decorators/interfaces.ts @@ -3,6 +3,7 @@ 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'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/decorators/transform.spec.ts b/src/decorators/transform.spec.ts index 185555c..8e17036 100644 --- a/src/decorators/transform.spec.ts +++ b/src/decorators/transform.spec.ts @@ -1,13 +1,13 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import type { EmittableRule } from '../rules/interfaces'; 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 { metaStore } from '../metadata'; +import { ExcludeMode } from './enums'; import { Field } from './field'; const createdCtors: Function[] = []; diff --git a/src/metadata/index.ts b/src/metadata/index.ts index ac65cfa..2213ef7 100644 --- a/src/metadata/index.ts +++ b/src/metadata/index.ts @@ -1,4 +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 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 index 7ea806d..e6b8fad 100644 --- a/src/metadata/interfaces.ts +++ b/src/metadata/interfaces.ts @@ -48,7 +48,7 @@ export interface ExcludeDef { /** A polymorphic discriminator subtype mapping — a class constructor keyed by its wire name. */ export interface DiscriminatorSubType { - value: Function; + value: ClassCtor; name: string; } diff --git a/src/metadata/meta-store.ts b/src/metadata/meta-store.ts index 0268360..cf9f9fc 100644 --- a/src/metadata/meta-store.ts +++ b/src/metadata/meta-store.ts @@ -1,5 +1,5 @@ -import type { MetaObject, MetaCarrier } from './types'; import type { RawClassMeta, RawPropertyMeta } from './interfaces'; +import type { MetaObject, MetaCarrier } from './types'; import { BakerError } from '../common'; import { RAW } from '../symbols'; diff --git a/src/rules/binary.ts b/src/rules/binary.ts index e415fb5..8c47734 100644 --- a/src/rules/binary.ts +++ b/src/rules/binary.ts @@ -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')};`, }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/combinators.spec.ts b/src/rules/combinators.spec.ts index 379e9a0..e0af000 100644 --- a/src/rules/combinators.spec.ts +++ b/src/rules/combinators.spec.ts @@ -2,8 +2,8 @@ import { describe, it, expect, mock } from 'bun:test'; 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]. 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/rules/index.ts b/src/rules/index.ts index 1c56b9d..9dd026d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -5,7 +5,7 @@ export * from './public'; // Internal surface — consumed cross-domain but NOT part of the published `./rules`. -export { createRule } from './create-rule'; +// (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'; diff --git a/src/rules/locales.spec.ts b/src/rules/locales.spec.ts index e854a4b..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 './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 eb8a75d..474f873 100644 --- a/src/rules/locales.ts +++ b/src/rules/locales.ts @@ -1,7 +1,8 @@ import type { EmitContext, EmittableRule } from './interfaces'; -import { RequiredType } from './enums'; 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'; // ───────────────────────────────────────────────────────────────────────────── @@ -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 a4304ea..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 './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 059405e..42ea2bd 100644 --- a/src/rules/number.ts +++ b/src/rules/number.ts @@ -1,7 +1,7 @@ import type { EmitContext, EmittableRule } from './interfaces'; -import { RequiredType, RuleOp } from './enums'; import { BakerError } from '../common'; +import { RequiredType, RuleOp } from './enums'; import { makePlannedRule, makeRule, planCompare, planLiteral, planOr, planValue } from './rule-plan'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/rules/public.ts b/src/rules/public.ts index a14adde..89778f7 100644 --- a/src/rules/public.ts +++ b/src/rules/public.ts @@ -1,3 +1,4 @@ +export { createRule } from './create-rule'; export { isString, isNumber, diff --git a/src/rules/string-basic.spec.ts b/src/rules/string-basic.spec.ts index 8f400fd..c0cd0aa 100644 --- a/src/rules/string-basic.spec.ts +++ b/src/rules/string-basic.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from './enums'; import type { EmitContext } from './interfaces'; +import { RequiredType } from './enums'; import { minLength, maxLength, 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 index 566f236..72b08fd 100644 --- a/src/rules/string-encoding.spec.ts +++ b/src/rules/string-encoding.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from './enums'; 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) { diff --git a/src/rules/string-finance.spec.ts b/src/rules/string-finance.spec.ts index a7cd48f..50b7079 100644 --- a/src/rules/string-finance.spec.ts +++ b/src/rules/string-finance.spec.ts @@ -1,19 +1,9 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from './enums'; import type { EmitContext } from './interfaces'; -import { - isISBN, - isISIN, - isISSN, - isEAN, - isBIC, - isCreditCard, - isIBAN, - isCurrency, - isISO4217CurrencyCode, -} from './string'; +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); diff --git a/src/rules/string-finance.ts b/src/rules/string-finance.ts index 4e552a6..2f09dce 100644 --- a/src/rules/string-finance.ts +++ b/src/rules/string-finance.ts @@ -1,5 +1,6 @@ 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'; @@ -293,83 +294,6 @@ 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(); @@ -428,189 +352,6 @@ function isIBAN(options?: IsIBANOptions): EmittableRule { // isISO4217CurrencyCode — ISO 4217 currency code set (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), diff --git a/src/rules/string-format.spec.ts b/src/rules/string-format.spec.ts index 2da63b5..8607c1f 100644 --- a/src/rules/string-format.spec.ts +++ b/src/rules/string-format.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect, mock } from 'bun:test'; -import { RequiredType } from './enums'; -import { BakerError } from '../common'; import type { EmitContext } from './interfaces'; +import { BakerError } from '../common'; +import { RequiredType } from './enums'; import { isEmail, isURL, @@ -1061,4 +1061,3 @@ describe('isTaxId', () => { expect(r1).not.toBe(r2); }); }); - diff --git a/src/rules/string-format.ts b/src/rules/string-format.ts index a8da926..8f4acbb 100644 --- a/src/rules/string-format.ts +++ b/src/rules/string-format.ts @@ -1,9 +1,10 @@ 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'; -import { BakerError } from '../common'; // Email — RFC 5322 simplified const EMAIL_RE = @@ -159,20 +160,6 @@ const isJWT = makeStringRule( }, ); -// 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. 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}))*$/; @@ -370,142 +357,6 @@ function isByteLength(min: number, max?: number): EmittableRule { }); } -// isHash — per-algorithm hex regex (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]; - 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')};`; - }, - }); -} - -// isRFC3339 — RFC 3339 datetime - -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 - -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 / isLongitude — a number, or a strictly-numeric string, within [lo, hi] (requiresType none) - -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)};}` - ); - }, - }); -} - -const isLatitude = rangeNumberOrString('isLatitude', -90, 90); -const isLongitude = rangeNumberOrString('isLongitude', -180, 180); - -// isEthereumAddress — 0x + 40 hex chars - -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.../tb1...) - -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')};`; - }, -); - // isPhoneNumber — E.164 international phone number const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/; @@ -597,19 +448,6 @@ function isStrongPassword(options?: IsStrongPasswordOptions): EmittableRule { // isTaxId — locale-specific tax identifier (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]; if (!re) { @@ -634,7 +472,6 @@ export { isIP, isMACAddress, isJWT, - isLatLong, isLocale, isDataURI, isFQDN, @@ -643,13 +480,6 @@ export { isMimeType, isMagnetURI, isByteLength, - isHash, - isRFC3339, - isMilitaryTime, - isLatitude, - isLongitude, - isEthereumAddress, - isBtcAddress, isPhoneNumber, isStrongPassword, isTaxId, 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.ts b/src/rules/string-identifier.ts index 1661958..f751610 100644 --- a/src/rules/string-identifier.ts +++ b/src/rules/string-identifier.ts @@ -1,5 +1,6 @@ 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'; @@ -111,259 +112,6 @@ const isISRC = makeStringRule( }, ); -// ISO 3166-1 Alpha-2 -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', -]); - const isISO31661Alpha2 = makeRule({ name: 'isISO31661Alpha2', requiresType: RequiredType.String, @@ -375,259 +123,6 @@ const isISO31661Alpha2 = makeRule({ }, }); -// ISO 3166-1 Alpha-3 -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', -]); - const isISO31661Alpha3 = makeRule({ name: 'isISO31661Alpha3', requiresType: RequiredType.String, diff --git a/src/rules/string.ts b/src/rules/string.ts index 991603e..441ab17 100644 --- a/src/rules/string.ts +++ b/src/rules/string.ts @@ -34,7 +34,6 @@ export { isIP, isMACAddress, isJWT, - isLatLong, isLocale, isDataURI, isFQDN, @@ -43,19 +42,16 @@ export { isMimeType, isMagnetURI, isByteLength, - isHash, - isRFC3339, - isMilitaryTime, - isLatitude, - isLongitude, - isEthereumAddress, - isBtcAddress, 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, @@ -70,15 +66,5 @@ export { } from './string-identifier'; export type { IsISO8601Options } from './string-identifier'; -export { - isISBN, - isISIN, - isISSN, - isEAN, - isBIC, - isCreditCard, - isIBAN, - isCurrency, - isISO4217CurrencyCode, -} from './string-finance'; +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 27f4e0c..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 './interfaces'; +import { RequiredType } from './enums'; import { isString, isNumber, diff --git a/src/rules/typechecker.ts b/src/rules/typechecker.ts index 3662134..91b5aa2 100644 --- a/src/rules/typechecker.ts +++ b/src/rules/typechecker.ts @@ -6,7 +6,7 @@ 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 `{ let exp=${varName}.toExponential().split('e'); let mant=(exp[0].split('.')[1]||'').length; let exp2=parseInt(exp[1],10); if(Math.max(0,mant-exp2)>${maxDecimalPlaces}) ${ctx.fail('isNumber')}; }`; + 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')}; }`; } // ───────────────────────────────────────────────────────────────────────────── @@ -216,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/runtime/check-call-options.ts b/src/runtime/check-call-options.ts index f2af5ac..96e33ad 100644 --- a/src/runtime/check-call-options.ts +++ b/src/runtime/check-call-options.ts @@ -1,15 +1,7 @@ import type { RuntimeOptions } from '../common'; import { BakerError } from '../common'; -import { BAKER_CONFIG_KEYS } from '../config'; -import { SEAL_OPTION_KEYS } from '../seal'; - -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. -const SEAL_TIME_KEYS = new Set([...BAKER_CONFIG_KEYS, ...SEAL_OPTION_KEYS]); +import { CALL_OPTION_KEYS, SEAL_TIME_KEYS } from './constants'; /** * @internal — validate per-call options object at public-API entry. @@ -35,13 +27,11 @@ 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}.`); - } + 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; } 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/runtime/deserialize.spec.ts b/src/runtime/deserialize.spec.ts index 40cca24..6a11b2a 100644 --- a/src/runtime/deserialize.spec.ts +++ b/src/runtime/deserialize.spec.ts @@ -6,8 +6,8 @@ import type { SealedExecutors } from '../seal/interfaces'; import { assertBakerIssueSet } from '../../test/integration/helpers/assert'; import { Baker } from '../baker'; -import { Field } from '../decorators/field'; import { isBakerIssueSet, BakerError } from '../common/errors'; +import { Field } from '../decorators/field'; 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/runtime/deserialize.ts b/src/runtime/deserialize.ts index 05d25af..f4d9e6e 100644 --- a/src/runtime/deserialize.ts +++ b/src/runtime/deserialize.ts @@ -1,6 +1,7 @@ +import type { Result } from '@zipbul/result'; + import { isErr } from '@zipbul/result'; -import type { Result } from '@zipbul/result'; import type { RuntimeOptions, BakerIssue, BakerIssueSet } from '../common'; import type { SealedExecutors } from '../seal'; diff --git a/src/runtime/serialize.spec.ts b/src/runtime/serialize.spec.ts index 6d4d6ca..298efed 100644 --- a/src/runtime/serialize.spec.ts +++ b/src/runtime/serialize.spec.ts @@ -4,8 +4,8 @@ import type { RuntimeOptions } from '../common/interfaces'; import type { SealedExecutors } from '../seal/interfaces'; import { Baker } from '../baker'; -import { Field } from '../decorators/field'; import { BakerError } from '../common/errors'; +import { Field } from '../decorators/field'; 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/seal/async-analyzer.ts b/src/seal/async-analyzer.ts index c534f9c..d0df863 100644 --- a/src/seal/async-analyzer.ts +++ b/src/seal/async-analyzer.ts @@ -1,6 +1,6 @@ import type { RawClassMeta, RawPropertyMeta } from '../metadata'; -import type { SealedExecutors } from './interfaces'; import type { InheritanceMerger } from './inheritance-merger'; +import type { SealedExecutors } from './interfaces'; import { Direction, isAsyncFunction } from '../common'; import { PRIMITIVE_CTORS } from './constants'; diff --git a/src/seal/circular-analyzer.spec.ts b/src/seal/circular-analyzer.spec.ts index 1906c15..fa5620f 100644 --- a/src/seal/circular-analyzer.spec.ts +++ b/src/seal/circular-analyzer.spec.ts @@ -26,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: [], diff --git a/src/seal/compile-cache.spec.ts b/src/seal/compile-cache.spec.ts index 0e8cd41..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 { CompileCache, compileCache } from './compile-cache'; +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 = CompileCache.fingerprint({ stopAtFirstError: true }); + const fp = compileCache.fingerprint({ stopAtFirstError: true }); const a = new Baker({ stopAtFirstError: true }); @a.Recipe @@ -42,8 +42,8 @@ describe('(class, config) executor cache', () => { b.seal(); // SealOptions key (BakerConfig's `autoConvert` normalizes to `enableImplicitConversion`) - const fpStrict = CompileCache.fingerprint({ enableImplicitConversion: false }); - const fpLoose = CompileCache.fingerprint({ enableImplicitConversion: true }); + const fpStrict = compileCache.fingerprint({ enableImplicitConversion: false }); + const fpLoose = compileCache.fingerprint({ enableImplicitConversion: true }); expect(fpStrict).not.toBe(fpLoose); expect(compileCache.get(D, fpStrict)).toBeDefined(); @@ -52,9 +52,9 @@ describe('(class, config) executor cache', () => { }); it('new Baker() and new Baker({}) share a fingerprint (all defaults → "00000")', () => { - expect(CompileCache.fingerprint({})).toBe('00000'); + expect(compileCache.fingerprint({})).toBe('00000'); expect( - CompileCache.fingerprint({ + 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 = CompileCache.fingerprint({}); + const fp = compileCache.fingerprint({}); const a = new Baker(); class Inner { @Field(isNumber()) k!: number; @@ -91,7 +91,7 @@ describe('(class, config) executor cache', () => { }); it('circular graph caches fully back-patched executors (not throwing placeholders)', () => { - const fp = CompileCache.fingerprint({}); + const fp = compileCache.fingerprint({}); const a = new Baker(); @a.Recipe class Node { @@ -106,7 +106,7 @@ describe('(class, config) executor cache', () => { }); it('a failed seal does not pollute the cache (commit is post-success)', () => { - const fp = CompileCache.fingerprint({}); + const fp = compileCache.fingerprint({}); const x = new Baker(); @x.Recipe class GoodOne { diff --git a/src/seal/compile-cache.ts b/src/seal/compile-cache.ts index 383d911..7a79df4 100644 --- a/src/seal/compile-cache.ts +++ b/src/seal/compile-cache.ts @@ -32,7 +32,7 @@ class CompileCache { * 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. */ - static fingerprint(o: SealOptions): string { + fingerprint(o: SealOptions): string { let fp = ''; for (const key of SEAL_OPTION_KEYS) { fp += o[key] ? '1' : '0'; @@ -69,5 +69,4 @@ class CompileCache { } } -export { CompileCache }; export const compileCache = new CompileCache(); diff --git a/src/seal/deserialize-builder.spec.ts b/src/seal/deserialize-builder.spec.ts index 91c6b84..3b09b7e 100644 --- a/src/seal/deserialize-builder.spec.ts +++ b/src/seal/deserialize-builder.spec.ts @@ -2,14 +2,16 @@ import { isErr, err } from '@zipbul/result'; import { describe, it, expect } from 'bun:test'; import type { BakerIssue } from '../common/errors'; -import type { SealOptions, SealedExecutors } from './interfaces'; 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'; @@ -1072,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 4d1bd66..00a9c3b 100644 --- a/src/seal/deserialize-builder.ts +++ b/src/seal/deserialize-builder.ts @@ -3,17 +3,17 @@ import type { Result, ResultAsync } from '@zipbul/result'; import { err as resultErr, isErr as resultIsErr } from '@zipbul/result'; import type { RuntimeOptions, BakerIssue } from '../common'; -import type { SealOptions, SealedExecutors, ChildScope, CategorizedRules, ResolvedTypeGate } from './interfaces'; -import type { DeserializeExecutor, ValidateExecutor } from './types'; 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 type { TypeGateConfig } from './deserialize-codegen'; import { toVarName, resolveGuardKey, @@ -128,16 +128,25 @@ class DeserializeBuilder { * 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, - }); + 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, + }, + ); } // ── Entry point ──────────────────────────────────────────────────────────── @@ -418,8 +427,8 @@ class DeserializeBuilder { // Collection (Map/Set) auto conversion if (meta.type?.collection) { code += this.validateOnly - ? this.generateCollectionCodeValidateOnly(fieldKey, varName, meta, emitCtx) - : this.generateCollectionCode(fieldKey, varName, meta, emitCtx); + ? this.generateCollectionCodeValidateOnly(fieldKey, varName, meta, emitCtx, fieldGroups) + : this.generateCollectionCode(fieldKey, varName, meta, emitCtx, fieldGroups); return code; } @@ -648,8 +657,16 @@ class DeserializeBuilder { 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; + const { + effectiveGateType, + gateCondition, + gateErrorCode, + gateEmitCtx, + otherGeneral, + gateDeps, + typeAsserter, + enableConversion, + } = config; // Helper: emit inner validation rules const emitInnerRules = (indent: string): string => { @@ -919,7 +936,9 @@ class DeserializeBuilder { } // Type gate fail — reflect message/context if typeAsserter rd exists - const gateEmitCtx = resolved.typeAsserter ? this.makeRuleEmitCtx(emitCtx, fieldKey, varName, resolved.typeAsserter) : emitCtx; + const gateEmitCtx = resolved.typeAsserter + ? this.makeRuleEmitCtx(emitCtx, fieldKey, varName, resolved.typeAsserter) + : emitCtx; code += this.emitTypedRules( fieldKey, @@ -961,9 +980,66 @@ class DeserializeBuilder { return sealed; } + /** + * 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 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; + } + return code; + } + // ── generateCollectionCode — Map/Set auto conversion ── - private generateCollectionCode(fieldKey: string, varName: string, meta: RawPropertyMeta, emitCtx: EmitContext): string { + 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!; @@ -1009,25 +1085,17 @@ class DeserializeBuilder { code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = new Set(${varName});\n`; } - // each validation rules (per element) + // each validation rules (per element) — iterate the materialized Set 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 = this.computeRuleExtras(rd, fieldKey, varName); - 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`; - } - code += ` ${siVar}++;\n`; - code += ` }\n`; - } + code += this.emitDeclaredEachRules( + fieldKey, + eachRules, + `${GEN.out}[${JSON.stringify(fieldKey)}]`, + sk, + emitCtx, + fieldGroups, + ' ', + ); code += `} else { ${emitCtx.fail('isArray')}; }\n`; } else { @@ -1067,6 +1135,18 @@ class DeserializeBuilder { code += ` ${GEN.out}[${JSON.stringify(fieldKey)}] = ${GEN.arr}${sk};\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, + ' ', + ); + code += `} else { ${emitCtx.fail('isObject')}; }\n`; } @@ -1080,7 +1160,13 @@ class DeserializeBuilder { * 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 { + 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; @@ -1257,7 +1343,13 @@ class DeserializeBuilder { * 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 { + 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); @@ -1325,7 +1417,9 @@ class DeserializeBuilder { 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 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 { @@ -1365,24 +1459,21 @@ class DeserializeBuilder { 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. + // 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 ppVar = `__bk$pp${sk}`; - const ppExpr = ppVar; - const ppInit = this.pathPrefix + 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 += ` 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`; + code += `${GEN.errList}.push({path:${ppExpr},code:'invalidInput'});\n`; } else { - code += `return [{path:${ppVar},code:'invalidInput'}];\n`; + code += `return [{path:${ppExpr},code:'invalidInput'}];\n`; } code += ` else {\n`; code += this.emitInlineNestedBlock(nestedMerged!, nestedCls, itemVar, ppExpr, vpPrefix); @@ -1407,7 +1498,9 @@ class DeserializeBuilder { 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 ppExpr = this.pathPrefix + ? `${this.pathPrefix}+${JSON.stringify(fieldKey + '.')}` + : JSON.stringify(fieldKey + '.'); const vpPrefix = `${sk}_`; code += this.emitInlineNestedBlock(nestedMerged!, nestedCls, varName, ppExpr, vpPrefix); } else { @@ -1431,6 +1524,7 @@ class DeserializeBuilder { varName: string, meta: RawPropertyMeta, emitCtx: EmitContext, + fieldGroups: string[] | undefined, ): string { const { collectErrors, execs } = this; const sk = (this.varPrefix || '') + sanitizeKey(fieldKey); @@ -1465,24 +1559,22 @@ class DeserializeBuilder { 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). + // 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 ppVar = `__bk$pp${sk}`; - const ppInit = this.pathPrefix + 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 += ` 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`; + code += `${GEN.errList}.push({path:${ppExpr},code:'invalidInput'});\n`; } else { - code += `return [{path:${ppVar},code:'invalidInput'}];\n`; + code += `return [{path:${ppExpr},code:'invalidInput'}];\n`; } code += ` else {\n`; - code += this.emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppVar, vpPrefix); + code += this.emitInlineNestedBlock(nestedMerged!, nestedCls!, itemVar, ppExpr, vpPrefix); code += ` }\n`; } else { const execIdx = execs.length; @@ -1497,33 +1589,9 @@ class DeserializeBuilder { code += ` }\n`; } - // each validation — iterate input array directly + // each validation — iterate the input array directly const eachRules = meta.validation.filter(rd => rd.each); - if (eachRules.length > 0) { - const eiVar = `${GEN.index}${sk}e`; - const prefixVar = `__bk$ep_${sk}`; - code += ` for (var ${eiVar}=0; ${eiVar}<${varName}.length; ${eiVar}++) {\n`; - // Declare the shared path-prefix var on the first each-rule only (a local flag, not a scan of - // the generated text — `var` hoists, so one declaration serves every rule in this loop). - let prefixDeclared = false; - for (const rd of eachRules) { - const extra = this.computeRuleExtras(rd, fieldKey, varName); - 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 (!prefixDeclared) { - prefixDeclared = true; - const prefixInit = this.pathPrefix - ? `${this.pathPrefix}+${JSON.stringify(fieldKey)}+'['` - : `${JSON.stringify(fieldKey)}+'['`; - code += ` var ${prefixVar} = ${prefixInit};\n`; - } - code += ` ${rd.rule.emit(`${varName}[${eiVar}]`, colEmitCtx)}\n`; - } - code += ` }\n`; - } + code += this.emitDeclaredEachRules(fieldKey, eachRules, varName, sk, emitCtx, fieldGroups, ' '); code += `} else { ${emitCtx.fail('isArray')}; }\n`; } else { @@ -1568,6 +1636,10 @@ class DeserializeBuilder { 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`; } @@ -1606,7 +1678,6 @@ class DeserializeBuilder { } } - // ───────────────────────────────────────────────────────────────────────────── // Exported entry functions — thin wrappers over DeserializeBuilder (signatures unchanged) // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/deserialize-codegen.ts b/src/seal/deserialize-codegen.ts index 63dba75..90ef4ec 100644 --- a/src/seal/deserialize-codegen.ts +++ b/src/seal/deserialize-codegen.ts @@ -240,7 +240,12 @@ export interface TypeGateConfig { } /** Generate nested-result handling for deserialize mode (pure) */ -export function generateNestedResultCode(fieldKey: string, resultVar: string, collectErrors: boolean, pathPrefix?: string): string { +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.`. @@ -302,7 +307,12 @@ export function generateNestedEachResultCode( } /** Generate validate-mode nested result handling (null check instead of isErr) (pure) */ -export function generateValidateNestedResult(fieldKey: string, resultVar: string, collectErrors: boolean, pathPrefix?: string): string { +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). diff --git a/src/seal/inheritance-merger.ts b/src/seal/inheritance-merger.ts index 5d33b3d..baa968e 100644 --- a/src/seal/inheritance-merger.ts +++ b/src/seal/inheritance-merger.ts @@ -5,7 +5,7 @@ import type { RawClassMeta, MetaStore } from '../metadata'; * reads RAW through as an injected collaborator. * * Merge rules: - * - validation: union merge (both parent and child apply, duplicate rules removed) + * - 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 diff --git a/src/seal/interfaces.ts b/src/seal/interfaces.ts index bb29bae..69b6e94 100644 --- a/src/seal/interfaces.ts +++ b/src/seal/interfaces.ts @@ -1,7 +1,7 @@ import type { Result, ResultAsync } from '@zipbul/result'; import type { BakerIssue, RuntimeOptions } from '../common'; -import type { RawClassMeta, RuleDef } from '../metadata'; +import type { CollectionType, RawClassMeta, RuleDef } from '../metadata'; // ───────────────────────────────────────────────────────────────────────────── // SealOptions — seal-time options resolved from a Baker's config @@ -48,6 +48,25 @@ export interface SealedExecutors { 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 // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/seal/meta-validator.spec.ts b/src/seal/meta-validator.spec.ts index 88fade0..f864e5c 100644 --- a/src/seal/meta-validator.spec.ts +++ b/src/seal/meta-validator.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'bun:test'; +import type { ClassCtor } from '../common'; import type { RawClassMeta, RawPropertyMeta } from '../metadata'; import { BakerError } from '../common'; @@ -16,7 +17,7 @@ function prop(over: Partial = {}): RawPropertyMeta { class Sub {} metaStore.set(Sub, { x: prop() }); -function disc(d: { property: string; subTypes: { value: Function; name: string }[] }): RawClassMeta { +function disc(d: { property: string; subTypes: { value: ClassCtor; name: string }[] }): RawClassMeta { return { f: prop({ type: { fn: () => Sub, discriminator: d } }) }; } @@ -24,15 +25,21 @@ describe('MetaValidator.validateShape', () => { class Host {} it('passes a valid discriminator', () => { - expect(() => validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: Sub, name: 'sub' }] }))).not.toThrow(); + 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); + 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/); + expect(() => validator.validateShape(Host, disc({ property: '__proto__', subTypes: [{ value: Sub, name: 's' }] }))).toThrow( + /reserved/, + ); }); it('rejects empty subTypes', () => { @@ -40,13 +47,15 @@ describe('MetaValidator.validateShape', () => { }); it('rejects a subType with a non-string name', () => { - expect(() => validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: Sub, name: '' }] }))).toThrow(/name must be/); + 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/, - ); + expect(() => + validator.validateShape(Host, disc({ property: 'type', subTypes: [{ value: 123 as never, name: 'x' }] })), + ).toThrow(/class constructor/); }); it('rejects duplicate subType names', () => { @@ -59,7 +68,9 @@ describe('MetaValidator.validateShape', () => { 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/); + 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', () => { diff --git a/src/seal/meta-validator.ts b/src/seal/meta-validator.ts index 428bf2d..0543ff4 100644 --- a/src/seal/meta-validator.ts +++ b/src/seal/meta-validator.ts @@ -1,7 +1,7 @@ import type { RawClassMeta, MetaStore } from '../metadata'; -import { CollectionType } from '../metadata'; import { BakerError } from '../common'; +import { CollectionType } from '../metadata'; import { RESERVED_PROPERTY_NAMES } from './constants'; /** @@ -39,7 +39,9 @@ export class MetaValidator { ); } 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.`); + 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++) { diff --git a/src/seal/seal.ts b/src/seal/seal.ts index 714472f..0f6a528 100644 --- a/src/seal/seal.ts +++ b/src/seal/seal.ts @@ -1,20 +1,19 @@ -import type { SealOptions, SealedExecutors } from './interfaces'; -import type { ClassCtor } from '../common'; import type { MetaStore } from '../metadata'; +import type { SealOptions, SealedExecutors } from './interfaces'; -import { metaStore } from '../metadata'; import { Direction, BakerError } from '../common'; -import { classifyTypeResult } from './type-resolver'; +import { metaStore } from '../metadata'; import { AsyncAnalyzer } from './async-analyzer'; import { CircularAnalyzer } from './circular-analyzer'; import { CircularPlaceholder } from './circular-placeholder'; -import { CompileCache, compileCache } from './compile-cache'; -import { PRIMITIVE_CTORS, RESERVED_PROPERTY_NAMES } from './constants'; +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 { normalizeTypeDefs } from './type-normalizer'; /** * One seal operation. Holds the per-operation state — the calling Baker's executor map, the resolved @@ -43,7 +42,7 @@ class SealRun { private readonly options: SealOptions, meta: MetaStore = metaStore, ) { - this.fp = CompileCache.fingerprint(options); + 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. @@ -125,65 +124,9 @@ class SealRun { } } - // 1b. TypeDef normalization — resolve @Type/@Field type fn(), detect arrays, auto-infer nested DTOs - // Prevent original RAW mutation: copy the shared RAW `type` before mutating (C-16 root fix). - // `flags` is already cloned per-seal by mergeInheritance, so it is mutated in place below. - 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 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(`${Class.name}.${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( - `${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. `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 }; - } + // 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); @@ -243,11 +186,7 @@ class SealRun { * 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 sealRegistry( - registry: Set, - options: SealOptions, - executors: Map>, -): void { +function sealRegistry(registry: Set, options: SealOptions, executors: Map>): void { new SealRun(executors, options).run(registry); } diff --git a/src/seal/serialize-builder.spec.ts b/src/seal/serialize-builder.spec.ts index 4802246..88ad58d 100644 --- a/src/seal/serialize-builder.spec.ts +++ b/src/seal/serialize-builder.spec.ts @@ -319,7 +319,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => AddressDto }, + type: { fn: () => AddressDto, resolvedClass: AddressDto }, flags: { validateNested: true }, }, }; @@ -353,7 +353,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => ItemDto }, + type: { fn: () => ItemDto, resolvedClass: ItemDto }, flags: { validateNested: true }, }, }; @@ -386,7 +386,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => ProfileDto }, + type: { fn: () => ProfileDto, resolvedClass: ProfileDto }, flags: { validateNested: true, isOptional: true }, }, }; @@ -443,7 +443,7 @@ describe('buildSerializeCode', () => { transform: [], expose: [], exclude: null, - type: { fn: () => AsyncItemDto }, + type: { fn: () => AsyncItemDto, resolvedClass: AsyncItemDto }, flags: { validateNested: true }, }, }; @@ -477,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 d60243a..8bcf722 100644 --- a/src/seal/serialize-builder.ts +++ b/src/seal/serialize-builder.ts @@ -1,9 +1,9 @@ import type { RuntimeOptions } from '../common'; -import type { SealOptions, SealedExecutors } from './interfaces'; import type { RawClassMeta, RawPropertyMeta, TransformDef } from '../metadata'; +import type { SealOptions, SealedExecutors } from './interfaces'; -import { CollectionType } from '../metadata'; import { BakerError, Direction } from '../common'; +import { CollectionType } from '../metadata'; import { sanitizeKey, buildGroupsHasExpr, resolveExposeName, resolveExposeGroups } from './codegen-utils'; import { SER_GEN as GEN } from './constants'; @@ -238,7 +238,7 @@ class SerializeBuilder { // ③b nested @Type handling (H4) — supports type + transform combination (nested serialize → transform) const type = meta.type; - if (type && (type.resolvedClass || type.discriminator || (type.fn && meta.flags.validateNested))) { + 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)}]`; @@ -280,7 +280,9 @@ class SerializeBuilder { // 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=`); + 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`; @@ -314,8 +316,9 @@ class SerializeBuilder { nestedCode += `${outputTarget} = ${GEN.outItem}${sk};`; } } else { - // Existing simple nested logic - const nestedCls = type.resolvedClass ?? (type.fn() as Function); + // 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); 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 index ccc3b41..3bf1fbf 100644 --- a/src/seal/type-resolver.ts +++ b/src/seal/type-resolver.ts @@ -1,19 +1,6 @@ -import { CollectionType } from '../metadata'; +import type { ClassifiedType } from './interfaces'; -/** - * 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; -} +import { CollectionType } from '../metadata'; export function classifyTypeResult(result: unknown): ClassifiedType { if (result === Map) { 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/interfaces.ts b/src/transformers/interfaces.ts index fd6bf3d..09c5431 100644 --- a/src/transformers/interfaces.ts +++ b/src/transformers/interfaces.ts @@ -11,3 +11,24 @@ 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.ts b/src/transformers/luxon.ts index 0d301a4..cfeeb54 100644 --- a/src/transformers/luxon.ts +++ b/src/transformers/luxon.ts @@ -1,25 +1,16 @@ -import type { Transformer } from './interfaces'; +import type { LuxonLike, LuxonTransformerOptions, Transformer } from './interfaces'; import { BakerError } from '../common'; - -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"; +import { LUXON_MISSING } from './constants'; 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 }); + // 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'; @@ -57,5 +48,4 @@ async function luxonTransformer(opts?: LuxonTransformerOptions): Promise { let moment: typeof import('moment'); try { moment = (await import('moment')).default; } catch (e) { - throw new BakerError(MOMENT_MISSING, { cause: 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; @@ -50,5 +42,4 @@ async function momentTransformer(opts?: MomentTransformerOptions): Promise { } 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/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/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/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/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/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/string-validators-full.test.ts b/test/e2e/string-validators-full.test.ts index ec09cae..1a64d34 100644 --- a/test/e2e/string-validators-full.test.ts +++ b/test/e2e/string-validators-full.test.ts @@ -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); diff --git a/test/e2e/string-validators.test.ts b/test/e2e/string-validators.test.ts index 3c73c4a..69fb2f4 100644 --- a/test/e2e/string-validators.test.ts +++ b/test/e2e/string-validators.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { Baker, isBakerIssueSet, Field } from '../../index'; -import { assertBakerIssueSet } from '../integration/helpers/assert'; import { isString, isEmail, @@ -15,6 +14,7 @@ import { contains, length, } from '../../src/rules/index'; +import { assertBakerIssueSet } from '../integration/helpers/assert'; const baker = new Baker(); 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 index 61bbae5..60c8bb6 100644 --- a/test/integration/__snapshots__/codegen-snapshot.test.ts.snap +++ b/test/integration/__snapshots__/codegen-snapshot.test.ts.snap @@ -345,15 +345,14 @@ 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]; - var __bk$ppset = "set"+'['+__bk$i_set+'].'; - if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + 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:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isNumber"}); -else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +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"}); } } } @@ -782,19 +781,18 @@ 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]; - var __bk$ppset = "set"+'['+__bk$i_set+'].'; - if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + 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:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"conversionFailed"}); __bk$skip_setc_0_k = true; } + 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:__bk$ppset+"k",code:"isNumber"}); + if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:"set"+'['+__bk$i_set+'].'+"k",code:"isNumber"}); } } } @@ -1146,15 +1144,14 @@ 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]; - var __bk$ppset = "set"+'['+__bk$i_set+'].'; - if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) return [{path:__bk$ppset,code:'invalidInput'}]; + 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:__bk$ppset+"k",code:"isDefined"}]; +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:__bk$ppset+"k",code:"isNumber"}]; -else if (isNaN(__bk$f_setc_0_k)) return [{path:__bk$ppset+"k",code:"isNumber"}]; -else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) return [{path:__bk$ppset+"k",code:"isNumber"}]; +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"}]; } } } @@ -1539,15 +1536,14 @@ 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]; - var __bk$ppset = "set"+'['+__bk$i_set+'].'; - if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + 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:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isNumber"}); -else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +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"}); } } } @@ -1930,15 +1926,14 @@ 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]; - var __bk$ppset = "set"+'['+__bk$i_set+'].'; - if (__il$setci == null || typeof __il$setci !== 'object' || Array.isArray(__il$setci)) __bk$errors.push({path:__bk$ppset,code:'invalidInput'}); + 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:__bk$ppset+"k",code:"isDefined"}); +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:__bk$ppset+"k",code:"isNumber"}); -else if (isNaN(__bk$f_setc_0_k)) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); -else if (__bk$f_setc_0_k === Infinity || __bk$f_setc_0_k === -Infinity) __bk$errors.push({path:__bk$ppset+"k",code:"isNumber"}); +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"}); } } } diff --git a/test/integration/codegen-snapshot.test.ts b/test/integration/codegen-snapshot.test.ts index 1a874fa..633b83a 100644 --- a/test/integration/codegen-snapshot.test.ts +++ b/test/integration/codegen-snapshot.test.ts @@ -9,18 +9,11 @@ import { describe, expect, it } from 'bun:test'; import type { BakerConfig } from '../../src/config'; import { Baker, Field, arrayOf } from '../../index'; -import { configNormalizer } from '../../src/config'; -import { CompileCache, compileCache } from '../../src/seal/compile-cache'; -import { - isBoolean, - isEmail, - isNumber, - isString, - min, - minLength, -} from '../../src/rules/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 ? configNormalizer.normalize(cfg) : {}); +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 } { 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/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);