Skip to content

refactor: domain-layer restructure + correctness fixes & collection-validate perf - #39

Merged
parkrevil merged 31 commits into
mainfrom
refactor/structure-domain-layers
Jun 21, 2026
Merged

refactor: domain-layer restructure + correctness fixes & collection-validate perf#39
parkrevil merged 31 commits into
mainfrom
refactor/structure-domain-layers

Conversation

@parkrevil

Copy link
Copy Markdown
Contributor

Restructures the package into clean per-domain layers and lands a batch of correctness fixes and one performance win. Three changesets (all minor) describe the consumer-facing changes:

Consumer-facing (see .changeset/)

  • audit-bugfixes@IsEnum numeric-enum lookup, momentTransformer UTC parsing, luxonTransformer invalid-date passthrough, per-call groups validation. (2 observable behavior changes — see changeset.)
  • discriminator-array-and-validator-fixes — discriminated arrays now work, serialize throws on unmatched discriminator subtype, each messages get the failing element, isDateString/isISO8601 leap-year for years 0–99, isHash/isTaxId reject unknown key at construction, isURL constraint-array isolation.
  • declared-collection-validation-and-perf — declared @Type(() => Set/Map) now validate their elements (Map previously dropped element rules; groups were ignored; function-message value was wrong); collection validate ~4.7× faster on large arrays (lazy error-path); createRule exported from the ./rules subpath; precise luxon/moment peer-dep error.

Internal (no API change)

Per-directory layering (common/config/metadata/decorators/rules/transformers/seal/runtime), published barrels split into public.ts, strict per-scope types/interfaces/constants/enums, DI-class seal pipeline (SealRun), extracted TypeNormalizer/codegen utilities, large static tables → constants.ts, string-format split into cohesive modules, benchmarks migrated to the Baker API.

Verification

  • CI-gated: typecheck, typecheck:bench, build, test (2469 pass / 0 fail), test:coverage (per-file ≥90%), test:memory — all green locally.
  • Also green: lint (oxlint 0/0), format:check, deps:check (0 circular), knip (0 dead).
  • Three independent adversarial reviews (codegen correctness, refactor behavior-preservation, public-surface/file-reorg) — all clean; public export surface verified unchanged except the intentional createRule subpath addition.

🤖 Generated with Claude Code

parkrevil and others added 30 commits June 19, 2026 21:29
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…config (Phase B)

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) <noreply@anthropic.com>
… (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) <noreply@anthropic.com>
…ims (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) <noreply@anthropic.com>
…laceholder (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) <noreply@anthropic.com>
…ase D)

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) <noreply@anthropic.com>
…, 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…uilder; 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n state-threading)

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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
…ain 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 '../<dir>' (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) <noreply@anthropic.com>
…plit, public/internal barrels)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ervable behavior)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… bugs

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<keyof SealOptions> 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) <noreply@anthropic.com>
…odegen

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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…dit residuals

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<keyof SealOptions>) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…llection 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) <noreply@anthropic.com>
@parkrevil
parkrevil merged commit 20b05a7 into main Jun 21, 2026
1 check passed
@parkrevil
parkrevil deleted the refactor/structure-domain-layers branch June 22, 2026 05:01
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.

1 participant