Skip to content

refactor: monorepo split + honest compat layer + honest benchmarks + honest docs [Phase 0-4] - #37

Open
Isqanderm wants to merge 91 commits into
mainfrom
worktree-monorepo-v5
Open

refactor: monorepo split + honest compat layer + honest benchmarks + honest docs [Phase 0-4]#37
Isqanderm wants to merge 91 commits into
mainfrom
worktree-monorepo-v5

Conversation

@Isqanderm

@Isqanderm Isqanderm commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

Phases 0-4 of the om-data-mapper v5 revival: monorepo split (Phase 0-1), the compat-honesty pass (Phase 2), honest benchmarks (Phase 3), and the documentation rewrite (Phase 4).

Phase 0-1 — monorepo split

  • Split into @tech-pioneer/data-mapper-core (1.0.0), @tech-pioneer/data-mapper-class-transformer (1.0.0), @tech-pioneer/data-mapper-class-validator (1.0.0), and a meta-package om-data-mapper (5.0.0) that re-exports the v4 surface (including the class-transformer-compat / class-validator-compat subpaths).
  • 518-test invariant held across the split: 34 files / 518 tests passing at root, unchanged from the pre-split baseline; coverage thresholds (70/80/70/70) pass.
  • Replaced semantic-release with Changesets for versioning/release.
  • Rewrote CI pipelines: 764-line ci.yml → 36 lines with a Node 20/22/24 matrix; changesets release workflow; benchmark workflow stubbed pending Phase 3.
  • Deleted ~7.5k lines of stale root cruft (release announcements, AI-readiness meta-docs, committed benchmark build artifacts, tracked IDE dirs).

Phase 0-1 post-review fixes (whole-branch review)

  • fix: add @types/node devDependency — pnpm's isolated linker exposed that @types/node was never a direct dependency (npm hoisting masked it); CI caught it, local runs did not.
  • style: format repository with prettier — the new format:check CI gate required a one-time repo-wide format (style-only, verified with a whitespace-ignoring diff).
  • fix: disarm release publish path until Phase 5 — with zero changeset files, changesets/action + publish: input would have published all 4 unpublished versions to npm on merge to main. The publish: input and NPM_TOKEN env are removed until Phase 5; merging this PR cannot trigger a release.

Phase 2 — compat honesty (13 commits, TDD, per-task subagent review)

Every declared compat option now either works or is removed, with honest compat tables.

class-validator (packages/class-validator):

  • The 7 previously-dead ValidatorOptions are implemented in the JIT compiler as runtime checks (sync + async codegen): whitelist (now actually strips undecorated properties — recorded in the changeset as a behavior change under the 5.0.0 major), forbidNonWhitelisted, skipMissingProperties / skipNullProperties / skipUndefinedProperties (with the upstream @IsDefined exemption), stopAtFirstError (per-property; async path enforces via post-trim in constraint declaration order), forbidUnknownValues (default false — documented divergence from upstream ≥0.14).
  • Function-form message — called at runtime with ValidationArguments; explicit message wins over a validator's defaultMessage; composite constraint envelopes are unwrapped so args.constraints carries the original decorator args.
  • validationError: { target, value } stripping (recursive, all return paths); fixed the wrong doc comment on ValidationError.target.
  • registerDecorator (TC39 addInitializer migration pattern, documented with a working example) + getMetadataStorage (minimal facade: getTargetValidationMetadatas).
  • Codegen hardening: ValidateBy names are sanitized before being interpolated as identifiers.

class-transformer (packages/class-transformer):

  • enableImplicitConversion implemented: @Type(() => Number|String|Boolean|Date)-gated primitive coercion (honest limitation documented: no reflect-metadata under TC39 decorators, so @Type is required).
  • Removed dead ClassTransformOptions fields: enableCircularCheck, exposeUnsetFields, targetMaps, enableValidation.

Docs & release:

  • docs/compat-class-validator.md and docs/compat-class-transformer.md — per-option/per-decorator tables, every row verified against the shipped code (including the honest ❌ rows: strictGroups, dismissDefaultMessages, $property-templating, ValidationError.contexts, @Type discriminator).
  • Two changesets (minor for both compat packages; meta bumps via updateInternalDependencies).

Process: 9 plan tasks executed by fresh implementer subagents with per-task spec+quality review, two fix rounds, and a final whole-branch review (verdict "with fixes" — both findings fixed and re-review-verified). Known parked residual: @Matches function messages still receive the internal {pattern, modifiers} shape in args.constraints (follow-up for Phase 4).

Phase 3 — honest benchmarks (7 commits, per-task subagent review)

The v4 benchmark suite is deleted and rebuilt from scratch as a private benchmarks workspace package:

  • Deleted the fraud: fabricated RESULTS.md ("20,000–60,000% faster" rows marked "(estimated)"), the no-op validation comparison (fed class-validator metadata to om's engine, which returned [] — the timed no-op produced the headline numbers), the dead prebuilt-JS pipeline, and 4 dead devDeps (@types/benchmark, chalk, ts-node, tinybench).
  • Honesty guards (the core mechanism): every comparison file asserts at import time — before anything is measured — that both engines do real work on the exact scenario fixtures (validation: both engines error on invalid / pass valid; transformation: outputs deep-equal each other and a hand-written literal, password never leaks). A guard failure throws and fails the run; the v4 no-op failure mode is structurally impossible.
  • Reproducible entry points: pnpm bench / bench:core / bench:compat (build packages first, then vitest bench). Core benches measure om's decorator API vs hand-written vanilla baselines (vanilla honestly wins 3/4). Compat benches measure om vs the real class-validator / class-transformer (upstream legacy decorators applied programmatically; metadata pre-warmed on both sides).
  • Un-masked CI: benchmark.yml rebuilt (setup mirrors ci.yml, frozen lockfile, 30-min timeout, no || echo, no continue-on-error) — a red bench run fails red.
  • Honest docs: benchmarks/README.md contains zero performance numbers, names the v4 failure explicitly, and documents the fairness rules.

Known carry-forward to Phase 4 (recorded in the final review): fabricated v4 numbers still live in the root README.md, CHANGELOG.md, SECURITY.md and docs//docs-ru/ — the Phase 4 docs rewrite must purge them all before Phase 5 publishes anything.

Phase 4 — documentation (13 commits, per-task subagent review)

The docs now tell one honest, consistent story across English, Russian, and npm surfaces:

  • Root README.md: 1,474 → 178 lines. All fabricated numbers gone (17.28x, 42.7x, the self-contradicting "vs Vanilla" table, "98% coverage"), the class-validator adapter finally has a section (v4's README never mentioned the largest module), and the only numbers left are repo-regenerable (549 tests / 38 files, zero runtime deps, ~90 validator decorators). Performance section states outright: this project publishes no benchmark numbers it cannot regenerate in CI.
  • Per-package READMEs for all 4 packages (npm landing pages, absolute GitHub links, files already ships them at publish).
  • docs/troubleshooting.md extracted from the README's 590-line troubleshooting section, curated to the real v5 API (one factually wrong claim about @Default ordering caught and fixed against the test suite).
  • docs/migration-v4-to-v5.md — package layout, import mapping (v4 subpaths still work), and every behavior change from the Phase-2 changesets, with the whitelist-now-actually-strips breaking change called out redundantly (NestJS ValidationPipe note included).
  • docs/ honesty pass — complete index (compat tables and migration guides were unlisted), stale src/… paths → real packages/… paths, non-existent @MapNested/@When examples rewritten to the real API (verified against core's tests and codegen), every fabricated "10x" table deleted, both "100% API compatible" claims qualified.
  • examples/ fixed — every example imported a deleted pre-monorepo src/; now a private workspace package (benchmarks pattern) importing published names, type-checked AND executed under node. Bonus finding: the legacy Mapper.create() class API is unreachable from the published packages (export { Mapper } from './decorators' shadows export * from './core/Mapper') — examples rewritten to the decorator API, docs don't advertise the legacy class; Phase 5 must decide: fix the export or drop the legacy API.
  • docs-ru/ synced — 5 missing files translated (both compat tables, both migration guides, troubleshooting), honesty fixes mirrored into the 4 existing RU guides, index rebuilt; heading-parity and byte-identical code blocks verified for all 10 file pairs.
  • Final-review catch: the phase itself had described the class-transformer adapter as "JIT-compiled / built on core" — it is neither (no codegen, no core dependency; it walks TC39 decorator metadata at call time). Corrected across ~14 EN/RU locations including the npm README and package.json description.
  • SECURITY.md's live fabricated figure ("112-474% faster") purged; CHANGELOG.md got an [Unreleased] pointer to the migration guide (historical 4.x entries with their old numbers left untouched as historical record).

Post-phase docs-vs-code re-audit (4 parallel verifiers: READMEs, docs/, benchmarks, docs-ru): READMEs, benchmarks (incl. a live pnpm bench:core run — all honesty guards pass, vanilla honestly wins 2/4 core scenarios), and docs-ru came back clean; docs/ had 7 defects, all fixed and re-verified against source: the dead always ValidatorOption honestly marked ❌ (stored in metadata, never read by the compiler) and validationError added to the options listing; validation-jit-internals.md codegen examples corrected to the real generated code (bracket access not optional chaining, target field present, custom-validator key is custom, Debugging section rewritten to the public API only); transformer-jit-internals.md metadata-storage claim fixed (both APIs use WeakMap, not Symbols) and the "Both APIs compile a specialized transform function once" paragraph split into the honest per-API story (all EN + RU mirrors).

Known follow-ups: CHANGELOG historical entries link three deleted docs; two RU guides retain pre-existing partially-translated prose (full re-translation is a future pass); the always option is a candidate for actual implementation in a future code change.

Out of scope (tracked in the design spec)

Phase 5 (publish, dependabot PRs, PR #21 decision). See docs/superpowers/specs/2026-08-24-monorepo-v5-design.md.

No manual prerequisite remains for Phase 5's package names — see the namespace section at the end of this description. What is still manual: npm login, and an NPM_TOKEN with publish rights for @tech-pioneer.

Test plan

  • Clean-room reinstall (rm -rf node_modules + package build dirs, pnpm install --frozen-lockfile)
  • pnpm run build — all 4 packages build clean
  • pnpm lint — clean
  • pnpm exec prettier --check . — clean
  • pnpm test — 38 files / 549 tests passing (518 baseline + 31 new Phase 2 tests)
  • pnpm bench — full suite runs end-to-end, all honesty guards pass
  • pnpm run test:esm — 12/12 ESM smoke scenarios passing
  • pnpm --filter examples run typecheck — all 9 examples type-check against the built packages (Phase 4)
  • Meta-package surface check (plainToInstance, Expose, validateSync, registerDecorator, getMetadataStorage all resolve via the export * re-export)

🤖 Generated with Claude Code

https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe


Phase 4.5 — post-review fixes (27 commits)

The whole-branch adversarial review of this PR confirmed 20 code defects that the Phase 0-4 honesty pass had missed — they were in the code, not the prose. All 20 are fixed here, plan-driven (docs/superpowers/plans/2026-08-25-review-fixes.md), one task per finding, each task independently reviewed.

Two further defects surfaced afterwards, in the test suite itself: both the property tests and the memory-leak tests were non-deterministic, and the memory tests turned out to be incapable of detecting the leaks they claimed to guard against. Both are fixed below.

Finally, with the measurement infrastructure trustworthy, the README publishes performance numbers again — this time bound by rules (see the last section).

Silent-failure bugs (class-validator)

  • stopAtFirstError could drop every error. The generated async trim resolved the first failing constraint with k in propertyErrorsin walks Object.prototype, so a validator named toString/constructor matched an inherited key, deleted every real error, and the invalid payload passed validate(). Now Object.hasOwn.
  • __proto__ as an error key silently passed invalid data. propertyErrors.__proto__ = msg hits the prototype setter, creates no own property, and the failing constraint looked like a passing one. sanitizeValidatorName now rejects it.
  • registerDecorator silently dropped registrations. A second unnamed inline validator, a constraint class re-registered with different constraints, and — worst — two registrations differing only by options.groups all collapsed into one, so validating with the dropped group ran no check at all. The dedup predicate now compares constraints, message, always and groups, and prefers validator reference identity.
  • Unbounded metadata growth. @Matches/@Validate/@ValidateBy built a fresh value object inside addInitializer, which runs on every new Dto(), so the identity-based dedup never matched and the constraints array grew without bound. One constraint object per decorator application now.
  • Null-prototype inputs threw instead of returning the upstream unknownValue result.
  • Metadata keyed by a module-local Symbol — two installed copies of the package validated nothing. Now Symbol.for.

Upstream-fidelity fix (class-validator)

Class-based custom validators reported errors under a hard-coded custom key, so consumers migrating from upstream read errors[0].constraints.isLongerThan and got undefined. They now report under the registered name, with upstream's precedence (@ValidatorConstraint({name}) wins over an explicit name argument — verified against class-validator 0.14.4's ValidationExecutor.getConstraintType). Behavior change, recorded in a changeset.

class-transformer

  • enableImplicitConversion returned NaN for arrays. @Type(() => Number) scores!: number[] with ['1','2'] produced Number(['1','2']) = NaN; upstream yields [1, 2]. Array elements are now coerced individually.
  • Three decorators were broken in ESM. @TransformClassToPlain/@TransformClassToClass/@TransformPlainToClass used a literal require('./functions') that survived into build/esm/ ("type": "module") — ReferenceError: require is not defined in ES module scope for every ESM consumer. No test invoked them, so CI stayed green. Static imports now (verified acyclic), plus the package's first ESM smoke test, which executes the built output in a real Node process.

core (JIT codegen)

Field names and @Map() paths were interpolated verbatim into new Function source. @Map('content-type') emitted source?.content-type — a SyntaxError that killed the whole mapper class at first instantiation, so kebab-case API keys were simply unusable. A quote-bearing key escaped the cache['...'] literal and executed arbitrary code inside the compiled function; the regression test proves the injection ran against the old code. Every emission site now uses JSON.stringify-escaped bracket access — including one inside _wrapInTryCatch that the original audit missed. Side effect: numeric path segments (@Map('items.0.name')) work now instead of throwing.

Packaging and honesty

  • All four packages would have published without a LICENSE — every manifest declared "files": [..., "LICENSE"] and no such file existed. npm ignores missing entries silently.
  • The legacy Mapper.create() API was unreachable (the decorator Mapper shadows it under ES module semantics) yet the ESM "post-install simulation" reached it through a raw node_modules path that bypasses the very exports map it claimed to validate, then printed "Package is ready for npm publication". The dead re-export is removed, the simulation now resolves everything by package name, and its summary claims only what it tests. SECURITY.md no longer presents that API under "✅ Safe Usage", and CONTRIBUTING.md's test template works again.
  • Tests imported @tech-pioneer/data-mapper-class-validator/decorators, which is not in the exports map and resolved only through a vitest alias — real consumers get ERR_PACKAGE_PATH_NOT_EXPORTED.
  • Six more fabricated performance figures were still living in JSDoc (42.7x, 17.28x, 112-474%, 70% smaller bundle). Purged; the completeness grep returns nothing. (Numbers do return to the README at the end of this PR — measured ones, under rules. See the last section.)
  • Codegen samples in docs/transformer-jit-internals.md (both mirrors) were stale after the escaping change and are now byte-accurate, with prettier-ignore so the formatter cannot re-break them.

CI holes that let all of this ship green

  • examples/ and benchmarks/ are now typechecked in CI — the exact rot class that let v4's examples decay unnoticed. benchmarks/tsconfig.json had been failing tsc --noEmit with 4 errors that nothing ran.
  • packages/class-transformer gained a test:esm script; the recursive root script picks it up automatically.
  • Property tests are seeded. All 25 fast-check properties drew fresh inputs every run, so a failure landed on inputs nobody could reproduce — one URL property failed once and never again in ~65 subsequent runs, including 20 under full CPU saturation. Pinned to a shared seed, with a guard test that fails if the seed is dropped. Investigated and ruled out first: the wall-clock assertions in regression.test.ts (250x headroom even with every core busy), CPU contention, concurrent vitest runs, and a gap in @IsURL itself (20,000 draws, zero rejections).

Known follow-ups from Phase 3, now resolved

The fabricated figures Phase 3 flagged as "still living in the root README, CHANGELOG, SECURITY.md and docs" are gone, and the Phase 4 question "fix the export or drop the legacy API" is answered: dropped, with a warning comment left on the class recording that its own codegen is unescaped and must be fixed before anyone re-exports it.

  • The memory-leak tests measured nothing. forceGC() was if (global.gc) global.gc() and global.gc is undefined under vitest, so every "Force GC before measurement" comment described an action that never happened. Without a collection the heapUsed delta conflates retained memory with garbage V8 had not freed yet: the same operation swung from −1.48MB to +4.09MB across runs. Consequence — the suite could not detect a leak at all, and a genuine 9MB retention passed the 10MB threshold silently. --expose-gc is now supplied via the root vitest config (pool/poolOptions are root-only in Vitest and are silently ignored in a project config), forceGC() throws instead of no-opping so a missing flag fails loudly, and the thresholds drop from 10/15MB to 1MB — sized from the measured 0.09MB worst case with ~10x headroom, not guessed. Verified the guard can still fail: a deliberate retention reads as 3.99MB, which trips 1MB and would not have tripped 10MB. Also removed Math.abs from the assertions, which contradicted the comment beside it ("negative growth is good — it means GC is working") by failing the test when the heap ended smaller.

Deliberately not fixed

  • The legacy BaseMapper's codegen carries the same unescaped-interpolation defect. It is unreachable from the published surface, the decorator API uses it only in type positions, and it is now marked with a warning — escaping dead code would be busywork.

Performance numbers return to the README — under rules

With the benchmark suite trustworthy (honesty guards that abort if either engine is not doing the same real work) and the test suite deterministic, the README can finally answer "is this faster?" without lying. A README that says nothing about performance is not useful to someone deciding whether to adopt the library; the v4 failure was not publishing numbers, it was publishing numbers nobody could reproduce.

The new section is bound by three rules, and benchmarks/README.md now records them for whoever publishes the next figure:

  1. It names the command that regenerates itpnpm bench:compat / pnpm bench:core.
  2. It stamps the environment — CPU, OS, Node version, upstream package versions, date — and discloses that some samples carry a relative margin of error up to ±66%.
  3. It publishes the losses too. Against the upstream packages om wins by 2.0x-56.2x (the margin widening on invalid data, where upstream builds error objects the generated validator does not). Against hand-written JavaScript, om loses three of four core scenarios by up to 2.3x — and the README says so, with the honest conclusion: JIT pays off against interpreting decorator metadata on every call, not against direct property access.

benchmarks/README.md had argued that numbers in documentation are always stale and were what caused the v4 failure, which the new section would have flatly contradicted. Rather than leave two documents arguing with each other, it now states the split explicitly — no figures in the benchmark package, one dated snapshot in the root README — and carries the rules above.

Test plan (Phase 4.5)

  • pnpm run build — 4 packages clean
  • pnpm lint and pnpm exec prettier --check . — clean
  • pnpm test — 44 files / 584 tests, 10 consecutive full-suite runs with zero failures after each of the two determinism fixes
  • pnpm run test:esm — all 3 packages, including class-transformer's new smoke test
  • pnpm --filter examples run typecheck and pnpm --filter benchmarks run typecheck — clean
  • pnpm bench — honesty guards pass (verified they can still fail: the TypeScript fix touched only the diagnostic string inside each throw)

Phase 4.6 — npm namespace (3 commits)

The scoped packages move to @tech-pioneer, an npm namespace that already exists, instead of the @om-data-mapper org that Phase 0-1 left as a manual prerequisite for Phase 5:

@om-data-mapper/core               ->  @tech-pioneer/data-mapper-core
@om-data-mapper/class-transformer  ->  @tech-pioneer/data-mapper-class-transformer
@om-data-mapper/class-validator    ->  @tech-pioneer/data-mapper-class-validator
om-data-mapper                     ->  unchanged (5.0.0)

The meta-package keeps its published name. om-data-mapper has version history through 4.2.1 and live installs; keeping it unscoped means existing users upgrade to v5 under the name they already depend on, and the import mapping in docs/migration-v4-to-v5.md (om-data-mapper, ./class-transformer-compat, ./class-validator-compat) stays correct as written.

Directories are deliberately not renamed — packages/core still holds @tech-pioneer/data-mapper-core. Only registry names change.

Beyond the manifests, three things had to move with them or the release would have broken:

  • Changeset frontmatter. The five pending changesets name their packages in YAML that Changesets resolves against the workspace; left at the old scope, changeset version fails on unknown packages and the Phase 2 / 4.5 release notes never reach the renamed packages. changeset status now resolves all four (2 minor, 2 patch) — unchanged from before.
  • The lockfile. Re-resolved via pnpm install; the clean-room --frozen-lockfile run below is what proves it matches the new manifests.
  • The design spec. docs/superpowers/specs/2026-08-24-monorepo-v5-design.md carries an amendment note, the Phase 1 step no longer says to register an org, and the "npm scope" risk is marked resolved. The Phase 0-4 plans under docs/superpowers/plans/ keep the old scope — they record what was executed at the time, not what ships.

Prettier re-wrapped six markdown files whose tables and prose lines no longer fit the longer names (verified line-by-line: table alignment only, no code block touched).

Test plan (Phase 4.6)

  • Clean-room: rm -rf node_modules packages/*/build + pnpm install --frozen-lockfile — lockfile matches the renamed manifests
  • pnpm run build — 4 packages clean
  • pnpm lint and pnpm exec prettier --check . — clean
  • pnpm test — 44 files / 584 tests, identical to the pre-rename baseline
  • pnpm run test:esm — all 3 packages
  • pnpm --filter examples run typecheck and pnpm --filter benchmarks run typecheck — clean
  • pnpm bench — full suite, all honesty guards pass
  • pnpm exec changeset status — all 4 packages resolve
  • grep -r "@om-data-mapper" — only docs/superpowers/plans/ (historical) and the spec's amendment note, where it is named deliberately

Isqanderm and others added 14 commits August 24, 2026 19:21
Also fixes a latent cross-package import break in 3 test files
(branch-coverage-boost, complex-combinations, final-coverage-gaps)
left over from Task 4's class-transformer extraction: they imported
Type from the old packages/core/src/compat/class-transformer path,
which no longer exists. Repointed to ../../../../../class-transformer/src.
…e test imports

Replaces the deep relative import (../../../../../class-transformer/src)
in 3 class-validator tests with a proper dependency-graph-recorded
approach: @om-data-mapper/class-transformer as a workspace devDependency,
resolved in tests via a vitest resolve.alias pointing at the sibling
package's src (same pattern Task 6 will use for the meta-package).
@codecov-commenter

codecov-commenter commented Aug 24, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Isqanderm
Isqanderm marked this pull request as ready for review August 24, 2026 19:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

} from '../src/decorators';

P2 Badge Update examples to stop importing the deleted src tree

This example still imports from ../src/decorators, but this commit moved the root src tree under packages/core/src, so the documented npx tsx examples/ergonomic-api.ts flow now fails with module-not-found before users can run it. Update the examples to import from the public package/workspace entry point (or the new package source path) along with the other stale example imports found by repo-wide search.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"types": "build/cjs/index.d.ts",
"sideEffects": false,
"exports": {
".": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Export the decorators subpath used by consumers

Because this package now has an exports map but only declares the root entry, any real Node/npm consumer importing the subpath used in the moved meta tests (@om-data-mapper/class-validator/decorators) will get ERR_PACKAGE_PATH_NOT_EXPORTED; the Vitest alias masks this locally by rewriting to ../class-validator/src. Add an explicit ./decorators (and any intended nested subpaths such as ./decorators/custom) export, or change those imports to the package root.

Useful? React with 👍 / 👎.

Isqanderm and others added 12 commits August 24, 2026 22:21
…ties/skipUndefinedProperties

Add runtime skipProp guard to generated per-property validation code in
generatePropertyValidation and generateAsyncPropertyValidation. Every
constraint check is wrapped in `if (!skipProp)` except isDefined, which
class-validator always evaluates regardless of skip* options.
Wrap every constraint check (sync and async, including isDefined) in
an `if (!(opts.stopAtFirstError && Object.keys(propertyErrors).length > 0))`
guard so at most one constraint is recorded per property when the option
is set. Composes inside the existing skipProp guard from Task 2.
…settle

Fired-and-forgotten async constraint tasks (validateBy/custom, no
intermediate awaits) could still add a second constraints entry under
stopAtFirstError: true when they resolved after a later sync check on
the same property had already recorded its own failure. Add a
post-trim in generateAsyncPropertyValidation, run after
Promise.all(propertyAsyncTasks) and before error assembly, that keeps
only the first failing constraint in declaration order (codegen-computed
order array) and deletes the rest. The existing synchronous per-check
guard is unchanged — it already handles the common sync-only case.

Regression test: a bottom (registered-first) async ValidateBy that
always fails, combined with a sync MinLength on the same property,
reproduces two constraint keys pre-fix and one post-fix.
Add forbidUnknownValues option support to validate/validateSync functions. When enabled,
returns an unknownValue error for objects without validation metadata, maintaining
backward compatibility with default value of false.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code
…rrorFields

When validationError stripping options are combined with forbidUnknownValues,
early-return branches bypassed stripErrorFields, leaving target/value fields
in unknownValue errors. Now properly strips fields in all code paths.

Adds regression test: forbidUnknownValues + validationError stripping combined.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…ompat APIs

registerDecorator lets consumers build custom TC39-decorator validators
without a @ValidatorConstraint class, dispatching to the existing
'custom'/'validateBy' compiled constraint paths. Guards against
re-registering the same logical constraint on repeated addInitializer
runs (one per instantiation) via an early existence check.

getMetadataStorage exposes a minimal read-only facade
(getTargetValidationMetadatas) over the Symbol-based metadata storage.

Also hardens compiler.ts: validateBy constraint names are interpolated
as JS identifiers in generated code, so both the sync and async
validateBy branches now fall back to 'custom' when the name isn't a
valid identifier.
…d ClassTransformOptions fields

Coerce primitives via @type(() => Number|String|Boolean|Date) during
plainToClass-family transforms when enableImplicitConversion is true;
null/undefined pass through untouched. Without the flag, behavior is
unchanged (TC39 decorators have no reflected type metadata, so
conversion requires an explicit @type).

Removed enableCircularCheck, exposeUnsetFields, targetMaps, and
enableValidation from ClassTransformOptions - all four were declared
but never read anywhere in src, tests, or the meta package.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
Isqanderm and others added 30 commits August 25, 2026 23:59
…nly subpaths

The /decorators subpath isn't declared in class-validator's exports
map and only resolved through a vitest alias; real consumers get
ERR_PACKAGE_PATH_NOT_EXPORTED. Point the three test files at the
package root, which re-exports all decorators. Swept tests, examples,
and benchmarks for any other subpath not present in a package's
exports map - none found.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
… current generator

compat-class-validator.md (both mirrors): the claim that hoisting the
validator object out of addInitializer makes dedup "exact (by
reference) rather than source-text-based" oversold a mechanism with
no observable effect - the reference check is a fast path only,
since equal references always imply equal source text. Rewrite to
say what's actually true.

transformer-jit-internals.md (both mirrors): commit 5a219d4 changed
the core JIT generator so every key and path is JSON-escaped bracket
access (target["email"], source?.["user"]?.["profile"]?.["email"])
instead of dot access. Update every stale dot-notation sample to the
current bracket-access shape, including the generateSafePropertyAccess
listing itself. Illustrative "slow"/hypothetical comparison lines that
were never generated code are left as dot-notation on purpose.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
Adds the one case missing from codegen-escaping.test.ts (added in
5a219d4): a target property name containing a double quote, the only
surface where a key lands inside a string literal in generated source
(cache lookups via cacheKey(), and the _wrapInTryCatch error message).
The existing kebab-case target case only proves property-access syntax
is safe, not string-literal escaping - so the _wrapInTryCatch fix
previously shipped with zero coverage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…undle-size claim

Review round 1 fixes:

1. transformer-jit-internals.md (both mirrors): prettier's singleQuote
   rule was silently rewriting the "Generated code" samples to single
   quotes, while the generateSafePropertyAccess "Examples:" comment
   (untouched by prettier, since it's inside //) correctly kept double
   quotes - the same document contradicted itself about what
   generateSafePropertyAccess/prop/cacheKey actually emit. All three
   build brackets with JSON.stringify, which always emits double
   quotes. Added <!-- prettier-ignore --> above every affected fenced
   block (12 per mirror) and restored the literal double-quoted output,
   including cache['__defValues'] staying single-quoted (it's a
   hardcoded literal in the generator template, not JSON.stringify'd)
   and catch(error) with no space (matches _wrapInTryCatch verbatim).
   Verified with `pnpm exec prettier --write` (reports "unchanged")
   and `prettier --check .` (no unrelated regressions).

2. packages/core/src/index.ts: dropped the "70% smaller bundle size
   compared to class-transformer" bullet - an unverifiable figure of
   the same fabricated-benchmark class as the ones already purged from
   this file, missed because the Task 9 grep pattern didn't cover it.
   Its only checkable content (no runtime deps, tree-shakeable) already
   restates the "Zero Dependencies" bullet directly above it, so it's
   dropped as redundant rather than rewritten.

   Ran a wider sweep of packages/*/src for any other unverifiable
   quantitative/benchmark-shaped claim: `[0-9]+(\.[0-9]+)?[xX]\b`,
   `[0-9]+(\.[0-9]+)?%`, `[0-9]+ *times`, `compared to`, `outperform`,
   `beats`, `reduce(s|d)? .* by [0-9]`, `improve(s|d)? .* by [0-9]`,
   `up to [0-9]`, plus `faster than|slower than|fastest|smaller
   than|larger than`, `blazing|lightning fast|ultra-fast|ultra
   fast|best-in-class|industry-leading`, and `benchmark`. Only hits:
   three false positives in class-validator's ISBN-10 (`[0-9X]`) and
   Ethereum-address (`0x...`) regex literals - unrelated to
   performance claims. No further fabricated figures found.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…ion use real package resolution

`packages/core/src/index.ts` did `export * from './core/Mapper'` (the legacy
class with a static `.create()`) alongside `export { Mapper } from './decorators'`.
Under ES module semantics the explicit named export wins, so the legacy class
was never reachable from the published surface. Removing the star re-export
leaves the public surface byte-identical (verified against the built ESM index)
while retiring dead code from the export map.

The ESM post-install simulation reached `Mapper.create()` through a raw
node_modules file path that deliberately bypassed the very exports map it
claimed to validate, then printed "Package is ready for npm publication".
Rewritten so every scenario imports by package name only — `om-data-mapper`
and its declared subpaths — with a scenario asserting undeclared subpaths are
blocked, and a success message that claims only what was tested.

Also records a real behavior change in the migration guides (EN + RU):
`@Map('items[0].name')` worked by accident under the old codegen and no longer
resolves now that path segments are emitted as escaped literal keys; and warns
at the legacy class that its own generator still interpolates keys and paths
unescaped and must be fixed before the class is ever exported again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…v4 examples decay unnoticed

benchmarks/tsconfig.json failed tsc --noEmit with 4x TS2339 in the
transformation honesty guards, and nothing ran it. The root cause is not
overload inference (omPlainToInstance/ctPlainToInstance already infer the
exact class correctly) - it's that TS narrows `if (!(x instanceof X))`
to `never` when x's static type is already exactly X, which is precisely
the scenario these guards exist to check at runtime. Route the diagnostic
`.constructor.name` reads through an `unknown`-typed helper instead of
relying on narrowing to prove the negated branch is unreachable.

Wire `pnpm --filter examples run typecheck` and
`pnpm --filter benchmarks run typecheck` into CI, after build (both check
against built package output).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…e() ship broken

packages/core and packages/om-data-mapper each execute their built ESM
output in a real Node process via test:esm; class-transformer had nothing
equivalent. That's exactly why a require() call inside
TransformClassToPlain/TransformClassToClass/TransformPlainToClass survived
into the ESM build and threw ReferenceError for every consumer, and no
test noticed (fixed in 0f4e567).

A plain .mjs file can't use decorator syntax, so this manually applies
the TC39-shaped decorator functions (value, context) => replacement the
way a decorator transform would, then actually calls the decorated
methods - the require() bug only broke at call time, not at decoration
time. Verified by temporarily reintroducing require() into the built
output: the test fails hard (module-format ambiguity / ReferenceError)
instead of passing silently. Also verified the root `pnpm run test:esm`
(pnpm -r --workspace-concurrency=1 run test:esm) picks the new script up
automatically.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…overclaims

"Every entry point above was reached" was untrue of Scenario 5, whose two
subpaths are deliberately NOT reached - that's the assertion. "the same
resolution a consumer of the published package gets" glossed two things:
this is Node self-reference, not an installed node_modules lookup, and
nothing here runs `npm pack` or exercises the `files` array, so it
doesn't verify the published tarball actually ships build/. This file's
entire subject is claim accuracy (it was rewritten for exactly that
reason), so its own summary needs to be exact too.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…guidance

SECURITY.md's "Safe Usage" section documented Mapper.create() - an API no
consumer can import (0da556d removed its re-export; the decorator Mapper
shadows it) whose code generator carries an unescaped-interpolation
injection hazard regardless of config trust level (see the @internal
warning in packages/core/src/core/Mapper.ts). Rewrite the section around
the Decorator API, which is the actual published surface, and add an
explicit note that Mapper.create() is neither reachable nor safe. Also
fixes a pre-existing dead link (docs/DECORATOR_API.md, which doesn't
exist) to docs/transformer-usage.md.

CONTRIBUTING.md's test template did `import { Mapper } from '../src'`
then `Mapper.create(...)` - broken against the current tree, since `../src`
no longer re-exports the legacy class and the root `Mapper` is the
decorator. Replaced with a working Decorator API example; verified by
running it as a real vitest test (temporarily, not committed) before
writing it into the doc.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
The plan document was committed unformatted and fails the repo's
prettier --check CI gate. Formatting only; no content change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…ucible

fast-check drew fresh inputs on every run, so a failing property failed on
inputs nobody could reproduce - the next run was green and the evidence was
gone. That is exactly how this suite behaved: the URL property failed once
under load and never again in ~65 subsequent runs, including 20 under full
CPU saturation.

Pin all 25 properties to one shared seed via a runs(n) helper. Breadth is
unchanged - each property still explores 10-100 distinct generated inputs -
but the same ones every time, so CI stops reddening at random and a real
failure can be investigated instead of vanishing.

A determinism guard test fails if the seed is ever dropped: it draws twice
and asserts the draws are identical (verified red before this change,
producing two entirely different URL sets).

Investigated and ruled out as causes of the observed failure: the wall-clock
assertions in regression.test.ts (0.0020ms against a 0.5ms baseline - 250x
headroom even with every core saturated), CPU contention (6/6 clean under
load), concurrent vitest runs sharing the coverage dir (3/3 clean), and a
gap in @isurl itself (20,000 fc.webUrl draws, zero rejections).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
…ention

global.gc was never available: forceGC() checked `if (global.gc)` and did
nothing, so every 'Force GC before measurement' comment described something
that never happened. Without a collection, the heapUsed delta conflates
retained memory with garbage V8 had not gotten around to freeing - the tests
measured noise. Evidence: for the same operation, growth swung from -1.48MB
to +4.09MB across five runs. With a real gc it is +0.02..+0.09MB, and each
test varies by at most 0.02MB run to run.

- Expose gc via the ROOT vitest config. pool/poolOptions are root-only in
  Vitest; setting them in the project config is silently ignored, which cost
  a round of debugging.
- forceGC() now throws instead of no-oping. A missing --expose-gc must fail
  loudly, not leave the suite green while measuring nothing.
- Thresholds 10/15MB -> 1MB, sized from the measured 0.09MB worst case with
  ~10x headroom. The old numbers were sized for the multi-megabyte noise and
  would have passed a 9MB leak: verified by probe - a deliberate retention
  reads as 3.99MB growth, which trips 1MB and would NOT have tripped 10MB.
- Drop Math.abs from the assertions. It contradicted the comment beside it
  ('negative growth is good - it means GC is working') by failing the test
  when the heap ended smaller, which is the healthiest outcome.

Full suite 584/584 green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
The README said nothing about performance, which is not useful to someone
deciding whether to adopt the library - but v4's failure was publishing
numbers nobody could reproduce, so the snapshot is bound by rules rather than
just added:

- every figure comes from `pnpm bench:compat` / `pnpm bench:core`, named
  in the section, and the suite's honesty guards abort the run if either
  engine is not doing the same real work on the same fixtures;
- the environment is stamped (CPU, OS, Node, upstream versions, date) so a
  reader can tell whether it applies to them, and the wide relative margin of
  error on some samples is disclosed;
- the losses are published alongside the wins. Hand-written JavaScript beats
  the core mapper in three of four scenarios, and the section says so, with
  the honest conclusion: JIT pays off against interpreting metadata per call,
  not against direct property access.

benchmarks/README.md previously argued that numbers in documentation are
always wrong, which the new section would have contradicted. It now states
the split explicitly - none here, one dated snapshot in the root README - and
records the three rules above so the next person publishing a figure has them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E2BunJE88yu6YcADceinHe
The three internal packages move from the unregistered `@om-data-mapper`
scope to `@tech-pioneer`, an npm namespace that already exists:

  @om-data-mapper/core               -> @tech-pioneer/data-mapper-core
  @om-data-mapper/class-transformer  -> @tech-pioneer/data-mapper-class-transformer
  @om-data-mapper/class-validator    -> @tech-pioneer/data-mapper-class-validator

The meta-package keeps its published name, `om-data-mapper`, so the
existing 4.x installs upgrade to 5.0.0 under the name they already use
and the v4->v5 migration guide's import mapping stays correct. Its
`./class-transformer-compat` / `./class-validator-compat` subpaths are
untouched.

Directories are deliberately not renamed: `packages/core` still holds
`@tech-pioneer/data-mapper-core`. Only registry names change.

This removes the "register the om-data-mapper npm organization" manual
prerequisite that Phase 5 was blocked on.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
The five pending changesets name their packages in YAML frontmatter,
which Changesets resolves against the workspace: left at the old scope,
`changeset version` would fail on unknown packages and the Phase 2/4.5
release notes would never reach the renamed packages.

Verified with `changeset status`: all four packages resolve (two minor,
two patch), unchanged from before the rename.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
Root and per-package READMEs, docs/, docs-ru/, examples/, benchmarks/,
and the CHANGELOG's [Unreleased] entry now name the packages as they
will actually be published. Prettier re-wrapped six files whose markdown
tables and prose lines no longer fit the longer names.

The design spec is amended rather than rewritten: an amendment note at
the top records the namespace decision, the Phase 1 step no longer says
to register an org, and the "npm scope" risk is marked resolved with
what genuinely remains manual before publishing (`npm login`, an
NPM_TOKEN with publish rights for @Tech-Pioneer). The Phase 0-4 plans
under docs/superpowers/plans/ keep the old scope: they record what was
executed at the time, not what ships.

The meta-package's npm badges and links are untouched — they point at
`om-data-mapper`, which does not move.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
…ection

`transformValue` recursed only when `transformationType === 'plainToClass'`,
so on the way out a nested class instance was copied into the result as-is.
Its own metadata never ran: an `@Exclude()`d field one level down reached
`classToPlain`, `instanceToPlain` and `serialize` output, and a nested
`@Expose({ name })` rename was silently dropped. The same decorators worked
on a top-level property, which is what kept this hidden.

Recursion is deliberately not gated on `typeFunction`. In this direction the
class is known from the value itself, and class-transformer 0.5.1 recurses
whether or not the property carries a `@Type` — verified by running both
implementations over the same fixtures.

Only the `classToPlain` direction is touched. `classToClass` passes its own
transformation type through both legs and keeps its current shallow
behavior; that is a separate defect, fixed separately.

Boundaries verified against upstream: `Date` passes through instead of being
expanded, plain object literals are copied key-for-key including undecorated
keys, primitives and arrays of primitives are untouched, and a self-referential
graph throws `RangeError` on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
`each` was declared nowhere — not in `ValidationDecoratorOptions`, not in
the constraint metadata, and not in the compiler. `@IsString({ each: true })`
type-checked, ran, and validated nothing per element: the constraint was
applied to the array itself, which for most predicates silently passes. That
made it the widest silent gap in the package, since `each` is the most-used
decorator option upstream.

The generated check was already parameterised by the name of the value
variable, so per-element validation needed no changes to the ~90 constraint
branches: `emitConstraintCheck` and its async twin wrap the existing output in
a loop over the collection and emit the check against the element instead.
Errors stay keyed by constraint name, so a failing element writes the same key
as any other and the property ends up with one error, matching upstream rather
than one error per element.

Behaviour verified by running this package and class-validator 0.14.4 over the
same fixtures: element counts, Sets, empty collections, non-collections,
custom messages and the reported `value` all agree. The default-message prefix
deliberately differs: upstream's "each value in " reads on into the property
name it puts in every message, and this package's messages carry no property
name, so the prefix stops at "each value".

Deduplication now compares `each`. Without that, `@MinLength(5)` and
`@MinLength(5, { each: true })` on one property collapse into a single
constraint and one of the two checks disappears silently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
`each` appeared in neither the supported nor the missing list, so the tables
gave no signal either way about the most-used decorator option upstream. Both
mirrors now carry a row, including the deliberate difference in the default
message prefix.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
`getValidationMetadata` tested `target[VALIDATION_METADATA]` for truthiness.
That lookup walks the static prototype chain, so `class Child extends Parent`
found Parent's metadata object and never created its own — every constraint
Child declared was written into Parent's map. Compiled validators are cached
by `metadata.target`, which for that shared object is Parent, so validating a
Child instance returned Parent's validator and Child's own constraints were
never checked. Nothing failed; the fields were simply not validated.

The outcome depended on which class was constructed first. Subclass first: it
created the map, and both sets of constraints ran. Parent first: Parent owned
the map and the subclass's fields went unvalidated. A base DTO carrying shared
fields — the ordinary NestJS arrangement — is the broken order.

Both lookups now test for an own property. No merging from the parent is
needed: TC39 field initializers run for the whole hierarchy when a subclass is
constructed, with `this.constructor` pointing at the subclass, so inherited
constraints are already registered against it. Verified against
class-validator 0.14.4 for both orders, a three-deep chain, a subclass that
declares nothing, a property decorated at two levels, and a subclass reached
through `@ValidateNested`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
Prettier normalises `*own*` to `_own_` in markdown emphasis. The file was
written after the local format check ran, so CI caught it instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
…type

BREAKING CHANGE: a decorated property whose value is of the wrong type — or
absent — is now reported as invalid rather than passing silently.

The generated checks read "if the type fits and the value is bad, report an
error". A value of the wrong type fell out of the condition entirely, so
`@IsEmail()` passed on `5`, `@Min(5)` passed on `'a'`, `@ArrayMinSize(2)`
passed on `'ab'`, and all of them passed on `undefined` and `null`. Upstream's
predicates return false for a value they cannot measure, which is an error.

Two shapes had to change. Fifteen checks guarded inline (`typeof v === 'x' &&
bad`) and were inverted to `typeof v !== 'x' || bad`. Fifty opened a block
(`if (typeof v === 'x') { ... }`) and now report the constraint's own error in
the mismatch branch and validate in the `else`. `@IsLatitude` and
`@IsLongitude` accept a number or a numeric string, so their two-branch chains
were rewritten by hand rather than mechanically; the inline `@IsEmpty` and
`@IsNotEmptyObject` guards were left alone, their semantics being the inverse.

Verified against class-validator 0.14.4 over 46 decorators × six value shapes:
321 of 322 comparisons agree. The one that does not is unrelated to types —
this package's `@IsBase64` regex accepts a string upstream rejects.

The escape hatches for absent values are upstream's and already work here:
`@IsOptional()`, `skipUndefinedProperties`, `skipNullProperties`,
`skipMissingProperties`. Tests cover each of them alongside the new behaviour.

No existing test changed. All 726 pass, and the benchmark honesty guards still
hold.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
BREAKING CHANGE: error keys and what `validateOrReject` rejects with have
changed to match class-validator 0.14.4.

Both are read directly by consumers — `error.constraints.isUrl` and
`catch (errors) { errors.map(...) }` — and both were spelled differently here,
so migrated code read `undefined` and failed later, away from the cause.

Measured across 48 decorators: 14 reported a key upstream does not use. Ten
were acronym casing (isURL/isUUID/isJSON/isIP/isFQDN/isISO8601/isJWT/
isMACAddress/isISIN/isISBN). Four reported a different constraint's key:

- `@Length` emitted separate minLength and maxLength constraints, so it
  reported the wrong key and, when both ends failed, two of them. It is now a
  single `isLength` constraint with an optional `max`, as upstream has it.
- `@IsDateString` delegated to `@IsISO8601` and inherited `isIso8601`.
- `@IsPositive` was `min: 0.000001` reporting `min`, `@IsNegative` was
  `max: -0.000001` reporting `max`. They now test the sign, which also fixes
  the range: 5e-7 is positive and was rejected.

`validateOrReject` rejects with the error array; `validateOrRejectSync` throws
it too, so the twins agree. `ValidationFailedError` stays exported for anyone
importing it, but nothing throws it now.

21 existing tests asserted the old keys — they encoded the divergence rather
than catching it — and were updated to the upstream spelling. No assertion was
weakened: each still requires the same error, under the name upstream gives it.
All 750 tests pass, and a fresh comparison across the 48 decorators reports
zero key differences.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
BREAKING CHANGE: which constraints run under a group filter has changed to
match class-validator 0.14.4.

The filter was inverted in both directions. A constraint carrying `groups` ran
only when a matching group was passed, so a plain `validate(dto)` skipped it —
upstream treats an absent filter as no filtering and runs everything. An
ungrouped constraint ran even under a filter selecting other groups, where
upstream skips it.

`always` — the upstream escape hatch for precisely that situation — was
declared on every decorator and in ValidatorOptions, stored in metadata, and
never read by the compiler. It is honoured now, per decorator and as a
call-wide default via `ValidatorOptions.always`. `@IsOptional`'s `groups` and
`always` used the same dead paths and are fixed alongside, on the sync and
async generators both; the async optional branch had kept the old rule after
the sync one was corrected, which is what the async tests caught.

All three gates now share one `groupGateExpression` helper rather than
repeating the condition at four sites.

13 existing tests asserted the inverted behaviour — one was even named "should
validate all constraints when no groups specified" while asserting that
grouped constraints do not run. Their expectations were corrected to what
upstream does; none was weakened.

A fresh comparison across the four filter shapes × three constraint shapes
reports zero differences from upstream. All 768 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
Same slip as the inheritance changeset: the file was written after the local
format check ran, so CI caught it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
Two parity gaps left from the audit.

`IsUrl` is upstream's spelling and the implementation was already present —
only the export name was missing, so `import { IsUrl }` failed for anyone
migrating. Both names now point at the same decorator; the error key was
already `isUrl`.

`@IsBase64` accepted `'zz'` and `'abc'`: its pattern ended with an optional
group of two or three unpadded characters, which let any length through.
validator.js requires a multiple of four with padding on, which is its
default. Checked against upstream over twelve inputs — padded, unpadded and
malformed — with no differences left.

One existing test asserted a 15-character string is valid base64. Upstream
rejects it unless `IsBase64Options.padding: false` is passed, which this
package does not accept, so the test now asserts the rejection.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
BREAKING CHANGE: default validation messages now begin with the property name.

`@MinLength(3)` on `username` produced `must be at least 3 characters`. Any
surface that shows `error.constraints` to a person had to re-attach
`error.property` by hand to say which field the sentence was about. Upstream
writes `username must be longer than or equal to 3 characters`; the subject
now matches, even where the wording does not.

Under `each` the property follows upstream's preposition: `each value in tags
must be ...`. It previously stopped at `each value`, since there was no
property name for `in` to lead into.

Caller-supplied messages, string or function, are untouched.

Five defaults opened with their own noun and would have read as `lat latitude
must be ...` once prefixed, so they were reworded: @IsLatitude, @IsLongitude,
@ArrayUnique, @mindate and @MaxDate. Upstream embeds the property mid-sentence
for the last three (`All tags's elements must be unique`); rather than special-
case them, they were rewritten to read after a prefix.

Of six messages compared with upstream, all six now share the subject and
three are identical word for word. No existing test needed changing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
BREAKING CHANGE: classToClass / instanceToInstance now clone nested values
instead of sharing them, and @Transform runs once rather than twice.

It was a classToPlain -> plainToClass round trip carrying the 'classToClass'
type through both legs. Neither leg recursed — each descends only for its own
direction — so nested instances and arrays came out shared with the source,
and mutating the clone mutated the original. @Transform ran on both legs, so a
`value + 1` transform produced `value + 2`.

Replaced with one recursive pass. A nested value's class comes from the value
rather than from @type, matching upstream, which restores a nested instance to
its own class with or without the decorator. Date is cloned instead of being
walked as a plain object, and Map / Set keep their type instead of collapsing
to `{}` through the plain round trip.

Two behaviours that look like bugs and are not — both measured against
class-transformer 0.5.1: @exclude() does not remove a property in this
direction, and the target is built with `new`, so an excluded property is
never copied onto it and keeps its field initializer's value rather than the
source's.

Seven comparisons against upstream — nested cloning, class preservation,
arrays, Dates, @Transform call count and result, excluded-property value —
report no differences. All 810 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
…ckage did not

IsAscii, IsBase32, IsBase58, IsBooleanString, IsByteLength, IsFirebasePushId,
IsFullWidth, IsHSL, IsHalfWidth, IsHash, IsHexadecimal, IsISRC, IsISSN,
IsMilitaryTime, IsMultibyte, IsNumberString, IsOctal, IsRgbColor,
IsSurrogatePair, IsTaxId, IsVariableWidth.

Each gets a decorator, a codegen branch and the same treatment as the rest:
upstream's error key, the package's type rules (a non-string fails rather than
passing), and support for each / groups / always / a custom message. Comparing
the two packages' runtime exports, nothing upstream exports is missing here now.

Every expectation came from running class-validator 0.14.4 rather than from
reading its source, which is the only reason these match: IsFirebasePushId
reports under a capitalised key, IsRgbColor rejects spaces between components,
IsByteLength counts UTF-8 bytes, IsBase58 accepts 'abc', IsAscii rejects the
empty string, IsNumberString rejects exponent notation, and IsISSN verifies its
mod-11 check digit. The last two surfaced only in the wide comparison after the
first implementation passed the narrower tests.

IsTaxId implements the en-US format only and IsHash takes the algorithm name
without the further options; both are marked in the compat table, whose
"Missing vs upstream" section is now empty and no longer hedged as written
"from memory".

58 comparisons against upstream over the new decorators report no differences
in outcome or error key. All 901 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T9KKEkbFmzqeFcrv18auEB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants