Skip to content

router: case-insensitive fix, bunup build, mutation testing + gap closures#51

Merged
parkrevil merged 316 commits into
mainfrom
feat/router
May 26, 2026
Merged

router: case-insensitive fix, bunup build, mutation testing + gap closures#51
parkrevil merged 316 commits into
mainfrom
feat/router

Conversation

@parkrevil

Copy link
Copy Markdown
Contributor

Summary

Brings the feat/router work to main. main was merged in first, so the new
helmet/cookie/compression packages are preserved (only bun.lock needed
regeneration).

@zipbul/router changes (covered by changesets)

  • major: options API enum → boolean (router-options-enum-to-boolean)
  • minor: build fix + match-time correctness — broken build switched to bunup;
    case-insensitive matching no longer mutates/corrupts captured values (ASCII-only
    length-preserving fold) and the hit cache no longer serves stale case-variant
    captures; star empty-tail, \w regex, optional+regex, non-ASCII method, raw ?
    fixes (router-build-fix-and-match-correctness)
  • patch: bench audit + isolation (router-bench-audit-and-isolation)

Quality

  • Added lint/format/dep gates (oxlint, oxfmt, knip, dpdm).
  • Adopted StrykerJS mutation testing (local-only); closed 5 real assertion/behaviour
    gaps it surfaced (pattern-tester boundaries, param-name boundaries, /a? + UTF-8
    codepoint boundaries, cache overwrite capacity, factor-detect tenant pattern
    mismatch) — each verified by applying the mutant.
  • 992 tests pass, coverage ≥ threshold.

Test plan

  • router: build + bun test (coverage) green locally
  • shared build green (merge-affected)
  • CI green across all packages

parkrevil and others added 30 commits April 28, 2026 18:37
REFACTOR.md was not updated when stage A3 (5ffdb44 + 77bce9e cleanup)
landed. Reflect the new status:

- §7.1 completed-PR table: add rows for 5ffdb44 (A3) and 77bce9e
  (A3-fix). The fix row exists because the original A3 commit shipped
  two unjustified public exports (RouterErrContext, MatchPayload)
  that were inlined in 77bce9e.
- §7.2 remaining-stages table: drop A3.
- §7.3 baseline metrics: tsc errors 2 → 0 (F7 narrowing fixed the
  pre-existing error.spec.ts errors).
- Appendix A traceability: F7 and F10 marked ✅ with both commit
  SHAs (the original + the cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ild-only freeze

Implements REFACTOR.md stage A4:

- F8 (registration side): extract assertNotSealed(ctx) and
  unwrapOrThrow(result) helpers. add() and addAll() previously
  inlined the same router-sealed throw three times and the
  isErr → throw RouterError pattern four times — now both
  collapse to one-line calls. The match-side `not-built` guard is
  scoped to stage B4 (different kind, different layer) and will
  land with the MatchLayer extraction.
- F18: drop the `_` prefix from the five private fields that had
  it (ignoreTrailingSlash, caseSensitive, maxPathLength,
  maxSegmentLength, normalizePath). The 14+ other private fields
  in router.ts have no prefix; the inconsistency was confusing.
  TypeScript `private` keyword carries the visibility — the
  prefix added zero information.
- F22: Object.freeze on build-only tables (segmentTrees,
  wildSpecs, staticMap, staticRegistered, activeMethodCodes) so
  any post-build mutation throws TypeError instead of silently
  desyncing state from the compiled matchImpl.

  Hot-path tables (`handlers`, `trees`, `staticOutputsByMethod`,
  `methodCodes`) are intentionally *not* frozen — JSC inline
  caches degrade ~5-10 ns per dynamic match when the emitted
  `handlers[state.handlerIndex]` reads a frozen closure-captured
  array (verified end-to-end against bench/baseline). The
  `sealed` flag already rejects every public mutation path, so
  the runtime hazard is zero.

- guarantees.test.ts: lock-in test for the freeze partition
  (which tables are frozen, which are intentionally not, and
  why). Records the JSC-IC-degradation rationale so a future
  contributor doesn't "fix" the un-frozen hot-path tables and
  silently regress every dynamic match by 5-10 ns.

- V8 → JSC corrections across new and pre-existing comments. The
  router targets Bun (engines.bun >= 1.0.0); references to V8
  inlining or V8/JSC IC behavior were misleading. Five sites
  corrected (3 in REFACTOR.md, 1 in router.ts, 1 in guarantees
  test).

Verification:
- bun test: 567 pass / 0 fail (566 + freeze lock-in spec).
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- bun run bench vs baseline: hot-path within ±1 ns (most
  dynamic match cases tied or faster than baseline; nested
  optional +0.9 ns within ±2 ns threshold).

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

Reflect stage A4 (commit 8a97815) in REFACTOR.md:

- §7.1 completed-PR table: add A4 row.
- §7.2 remaining-stages table: drop A4 row.
- §7.3 baseline metrics: 566 → 567 pass (added freeze lock-in spec).
- Appendix A traceability: F8(registration), F18, F22 marked ✅
  with commit 8a97815. F22 entry annotated with the hot-path
  exclusion ("build-only tables — hot-path 제외") so the
  performance constraint is visible from the matrix.

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

The original F22 finding and stage A4 prescription both said
"+ 핵심 lookup 테이블에도 동일 적용" / "+ handlers + staticMap" —
implying every lookup table should be frozen. The A4 implementation
empirically diverged from this: freezing the hot-path tables
(handlers, trees, staticOutputsByMethod, methodCodes) regressed
dynamic match by 5-10 ns due to JSC inline-cache degradation when
the compiled matchImpl reads closure-captured frozen objects.

Update the doc so a future re-implementer doesn't follow the stale
prescription and re-introduce the regression:

- §2 F22: rewrite the prescription to enumerate the five frozen
  build-only tables explicitly, and document the four intentionally
  un-frozen hot-path tables with the JSC IC rationale and bench
  evidence.
- §3 A4 step: same correction in the per-stage instructions, plus
  the verification line now references the freeze-partition lock-in
  spec added in commit 8a97815.

No source-code changes.

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

Implements REFACTOR.md stage A5.

Before: a single Map<prefix, name> tracked wildcard names across all
methods. Registering `GET /files/*path` and then `POST /files/*upload`
would throw a route-conflict even though the two routing tables are
fundamentally independent. The cross-method scoping had no design
rationale recorded and surprised users.

After: Map<methodCode, Map<prefix, name>>. Conflict detection runs
inside one method's scope only. The two examples above now coexist;
GET serves files via *path, POST serves uploads via *upload, and
each method's table sees only its own prefix index.

- router.ts: replace `wildcardNames` with `wildcardNamesByMethod`
  keyed on methodCode (consistent with how segmentTrees / staticMap
  inner indices already work). `checkWildcardNameConflict` and
  `checkStaticWildcardConflict` take methodCode + method (the latter
  for the error message). `addOne` passes `offsetResult` (the
  methodCode it already computed for the path-parser call). Error
  messages now include the method so a user reading the diagnostic
  knows the conflict is method-local.
- F22 partition: `wildcardNamesByMethod` joins the build-only freeze
  list. It is read only during `add()`, never at match() time, so
  freezing it carries no JSC IC penalty.

- test/router-errors.test.ts: the existing "conflicting wildcard
  names at same node" test used GET twice but was titled as if the
  collision were cross-method; rename it to make the same-method
  scope explicit, then add a sibling test that registers
  GET /files/*path and POST /files/*upload under one router and
  asserts both match correctly after build().
- test/negative-exception.test.ts: the test labeled "cross-method
  wildcard name conflict" was likewise mislabeled (both ops were
  GET). Rename to "same-method" and annotate the mislabeling so
  someone grepping for "cross-method" lands on the new behavior in
  router-errors.test.ts instead.

Verification:
- bun test: 568 pass / 0 fail (567 → +1 cross-method coexistence spec).
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- bench vs baseline: hot-path within ±0.5 ns; wildcard match cases
  ~1-2 ns faster than baseline (within sampling noise).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflect stages 8a97815 (A4 follow-up) and dc4683c (A5) in
REFACTOR.md:

- §7.1 completed-PR table: add A4-fix (44e66f9) and A5 (dc4683c)
  rows.
- §7.2 remaining-stages table: drop A5.
- §7.3 baseline metrics: 567 → 568 pass (added cross-method
  coexistence spec).
- Appendix A traceability: F9 marked ✅ with commit dc4683c.

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

The A5 commit (dc4683c) added a wildcard/wildcard cross-method
coexistence spec but missed the symmetric static/wildcard case —
also a new behavior unlocked by F9's method-scoped conflict check.

Pre-A5: registering `GET /files/*p` and then `POST /files/static`
threw because the static-conflict scan walked a single global wildcard
index. A5 makes that scan method-local, so POST gets its own clean
slate. Lock the new behavior in with an explicit spec so a future
refactor that re-globalizes the index fails this test instead of
silently regressing real-world API shapes (e.g. an upload endpoint
sharing a prefix with a list endpoint under a different verb).

569 pass / 0 fail (568 + 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
569 pass total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements REFACTOR.md stage A6.

Pre-A6 router.build() ran a 5-line conversion loop on every build
to turn the registry's `Map<string, number>` into a prototype-less
`Record<string, number>` for hot-path lookup. The Map was the
authoritative store; the NullProtoObj was a derived view rebuilt
from scratch.

A6 lifts that Record into MethodRegistry as a co-maintained
authoritative table, kept in sync with the Map on every register
(constructor + getOrCreate). router.build() then just hands the
already-prepared Record to its `methodCodes` field — the conversion
loop is gone.

- method-registry.ts:
  - Add `private readonly codeMap: Record<string, number>` initialized
    via `Object.create(null)` (same prototype-less rationale as
    router's NullProtoObj — no Object.prototype walk per match).
  - Populate alongside `methodToOffset` in the constructor's
    DEFAULT_METHODS loop and in `getOrCreate`.
  - Add `getCodeMap(): Readonly<Record<string, number>>`. Doc warns
    callers not to freeze or mutate it (router consumes it as a
    closure-captured matchImpl input — freeze would tank JSC inline
    caches per the A4/F22 partition).
- router.ts: collapse the build-time conversion (lines 263-266 in
  pre-A6) to a single assignment from `getCodeMap()`. Cast preserves
  the existing `Record<string, number>` field type; the registry
  guards mutation with the `sealed` flag instead.
- method-registry.spec.ts:
  - 4 new tests covering `getCodeMap()`: defaults, custom-method
    propagation, prototype-less guarantee, and entry-by-entry
    agreement with `getAllCodes()`.
  - Drop unused `RouterErrData` import flagged by tsc once the A3
    discriminated-union work cleared the prior masking errors.

Verification:
- bun test: 573 pass / 0 fail (569 + 4 getCodeMap specs).
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- bench vs baseline: hot-path within ±2 ns (most cases ±0.5 ns or
  faster; param /users/:id 1.1 ns faster, cache hit 100 1.0 ns
  faster than baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A6 (commit d64863f) closes the last finding in stage A. Reflect:

- §7.1 completed-PR table: add A6 row.
- §7.2 remaining-stages table: drop A6, annotate B with "A 완료".
- §7.3 baseline metrics: 569 → 573 pass (added 4 getCodeMap specs).
- Appendix A traceability: F11 marked ✅ with commit d64863f.

Stage A (A1-A6) complete. Next is stage B (Router class
decomposition into pipeline/codegen layers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The F22 freeze lock-in spec in test/guarantees.test.ts enumerates
every internal table the build() pipeline freezes vs. leaves
mutable, so future contributors cannot silently flip the partition.

A5 (commit dc4683c) added `wildcardNamesByMethod` to the freeze
list in router.ts but I forgot to update this test alongside it,
leaving the new entry unguarded — a regression that re-globalized
the wildcard index would still pass the spec.

Add the missing assertion. Test count unchanged (assertion added
inside the existing `freezes build-only tables` test); 573 pass.

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

Implements REFACTOR.md stage B1.

Pre-B1 the Router class held the registration phase state (handlers,
segmentTrees, staticMap, staticRegistered, wildcardNamesByMethod,
testerCache, sealed flag) and methods (add, addAll, addOne,
checkWildcardNameConflict, checkStaticWildcardConflict,
assertNotSealed, unwrapOrThrow) inline alongside the build-time
codegen and runtime match dispatch. F1 called this out as a 9-
responsibility SRP violation.

B1 lifts every registration-phase concern into a new
`pipeline/registration.ts` module:

- `Registration<T>` class owns: pathParser, optionalParamDefaults
  (passed in), staticMap, staticRegistered, segmentTrees, handlers,
  wildcardNamesByMethod, testerCache, sealed flag, plus all five
  registration-time validators / mutators.
- `seal()` returns a `RegistrationSnapshot<T>` of the accumulated
  state, freezes `wildcardNamesByMethod` (build-only per F22),
  and sets the internal sealed flag so subsequent add/addAll calls
  throw router-sealed.
- Router builds the parser + optionalParamDefaults + registration
  in its constructor, then `add` / `addAll` are 1-line delegations
  to `this.registration.{add,addAll}`. `match` and `allowedMethods`
  query `registration.isSealed()` instead of the prior local flag.
- `Router.build()` calls `seal()`, copies the snapshot's references
  into its own fields (handlers/segmentTrees/staticMap/
  staticRegistered/testerCache) so the compiled matchImpl can
  closure-capture them directly with no extra indirection. Closures
  cannot reach through `this.registration.x` without paying a
  property-access tax on every match — this transfer-of-ownership
  pattern keeps the hot path identical to pre-B1.

- test/handler-rollback.test.ts: rollback assertion previously read
  `(r as any).handlers`, which is Router's empty default before
  build(). Update the internal-state inspection helper to read
  through `registration.handlers` since the registration-time
  state now lives there.

Public API surface is unchanged; the `Registration` class itself
is exported only because router.ts imports it from a sibling
module — `index.ts` does not re-export it, so external users see
no new symbol.

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- bench vs baseline: every hot-path line is *faster* than the
  pre-B1 baseline (param match -1 to -3 ns, wildcard match -1.5
  to -3.4 ns, static match low-end -15 ps). Likely a side effect
  of Router's class shape shrinking — fewer hidden-class
  transitions for JSC's IC to track. No regression anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…B1 follow-up)

After B1 (commit e533620) the `wildcardNamesByMethod` map moved
from Router to Registration. The freeze lock-in spec in
test/guarantees.test.ts kept reading `internal.wildcardNamesByMethod`
from Router, where the property is now `undefined`. `Object.isFrozen
(undefined)` returns true vacuously, so the assertion silently
passed without verifying the actual freeze.

- Update the inspection path to reach through `registration.
  wildcardNamesByMethod`.
- Add a `toBeInstanceOf(Map)` precondition so a future regression
  that drops the freeze + mis-paths the access can't both pass
  vacuously.
- REFACTOR.md F22: note that wildcardNamesByMethod is now frozen
  in Registration.seal() rather than Router.build(), so the freeze
  partition list reads accurately post-B1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e constants via internal/

Implements REFACTOR.md stage B2.

Pre-B2 the bulk of `Router.build()` (~140 lines) compiled the
registration snapshot into the runtime tables (trees / wildSpecs /
staticOutputsByMethod / activeMethodCodes / methodCodes / matchState
/ normalizePath) inline. After B1 lifted the registration phase out,
this build orchestration was the next-biggest responsibility on
Router.

B2 extracts it into a pure-factory `Build` class:

- `pipeline/build.ts` — `Build.fromRegistration<T>(snapshot, options,
  methodRegistry): BuildResult<T>`. No instance state. Compiles
  per-method walkers via `createSegmentWalker`, detects
  static-prefix-wildcard shapes via `detectWildCodegenSpec`,
  pre-builds the static MatchOutput table, walks the registry
  to compute activeMethodCodes, builds the path normalizer, and
  resolves the four cached options. The output is a struct of
  references that Router transfers to its own fields for closure
  capture by the compiled matchImpl.
- `internal/null-proto-obj.ts` — shared module for the
  `NullProtoObj` constructor and the four match-meta singletons
  (EMPTY_PARAMS / STATIC_META / CACHE_META / DYNAMIC_META). Both
  router.ts and the new build.ts need them; without sharing each
  module would duplicate the constructor and lose the stable hidden
  class JSC tracks across instances. (Touch-not from REFACTOR.md
  Appendix D was about preserving the *pattern*, not pinning the
  source location.)
- router.ts — `build()` body collapses from ~140 lines of
  computation to 12 lines of "seal → Build.fromRegistration → copy
  to fields → compileMatchFn → freeze". The unused `testerCache`
  field on Router (Registration owns it now) is removed.
  Imports of buildDecoder / createSegmentWalker /
  detectWildCodegenSpec / createMatchState / buildPathNormalizer
  are dropped since none are used by Router directly anymore.

Router shrinks 770 → 677 lines (-93). The remaining bulk is the
codegen (compileMatchFn + collectMatchConfig + emit*), which is
stage B3's target.

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- bun run bench vs baseline: hot-path within ±2 ns. First run had
  one /users/:id/posts/:postId outlier (+7 ns at p75) traced to
  high system load (2.55) at capture; re-run on quieter box shows
  -0.68 ns. Most dynamic-match cases tied or faster than baseline;
  static match 5-15 ps faster across all bucket sizes. The B2
  changes are pure build-time so no runtime code path moved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Router 770 → 677 lines after B2. Build extracted as pure factory,
NullProtoObj + match meta constants shared via internal/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2 follow-up

Self-audit fix for B2 (commit 01686c6):

1. Build was declared as `class Build` with one static method
   (`fromRegistration`) and no instance state. This is a namespace
   in disguise — Bun's coverage reported 50% function coverage
   because the implicit class constructor is never invoked.
   Convert to plain function `buildFromRegistration<T>(...)`.
   Coverage now 100% lines + 100% functions on build.ts.
2. REFACTOR.md §5 final directory structure diagram listed
   builder/ + codegen/ + matcher/ + pipeline/ but did not mention
   `internal/`, the new directory holding the shared NullProtoObj
   constructor + meta singletons. Update §5 to document it as the
   cross-cutting shared-utility directory; without single sourcing,
   each consumer would duplicate the constructor and lose JSC's
   stable hidden class.
3. REFACTOR.md §3 stage B2 step: replace the prescribed
   `class Build<T>` signature with the actual function form, and
   note the rationale for the deviation (the doc's `class Build<T>`
   carried an unused generic on a stateless namespace).

No source-code logic changes. Tests still 573 pass; tsc clean;
build clean.

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

Implements REFACTOR.md stage B3.

Pre-B3 the matchImpl codegen — `compileMatchFn`,
`detectSingleMethodWildSpec`, `emitSpecializedWildMatchImpl` (78
lines), `emitGenericMatchImpl` (159 lines) — lived directly on
the Router class as private methods. F1 called this out as the
single largest responsibility on Router; F2 separately flagged
the 159-line generic emitter as needing decomposition.

B3 lifts every codegen concern out of Router:

- `codegen/emitter.ts` (~340 lines) — public entry
  `compileMatchFn<T>(cfg: MatchConfig<T>): CompiledMatch<T>`. The
  three internal step functions (`detectSingleMethodWildSpec`,
  `emitSpecializedWildMatchImpl`, `emitGenericMatchImpl`) stay
  file-local. The `MatchConfig<T>` interface and the file-local
  `CacheEntry<T>` type are now defined here since codegen is the
  contract owner; Router imports them.

- The two extras codegen needs that aren't in the closure payload —
  `activeMethodCodes` and `wildSpecs` — are added to MatchConfig so
  every codegen function reads from cfg only. No `this.X` access
  inside emitter.ts; the emit functions are pure.

- Emit body strings are byte-for-byte identical to pre-B3. The
  emit-output snapshot in `audit-repro.test` continues to pass
  unchanged. Only the file location of the emitter changed; the
  `new Function()` factory args, the literal JS source it wraps,
  the closure-captured constants — all preserved.

- Router (`router.ts`) drops 677 → 325 lines (-352). Its
  remaining responsibilities are the field-state container for
  closure capture (handlers / trees / staticOutputsByMethod /
  methodCodes / matchState / etc.), `collectMatchConfig` (read
  from this, return cfg), `add` / `addAll` delegation to
  Registration, and `match` / `allowedMethods` / `clearCache` /
  `normalizePathForLookup` (B4 will extract those).

- Class form `class Build` was a static-only namespace (B2 fixed
  by demoting to a function). The codegen layer follows the same
  pattern from the start — emitter.ts exports a function, not a
  class — to avoid the same gratuitous-class smell.

- Doc § B3 prescribed `MatchFunctionEmitter` class with step-method
  decomposition. The class form is unjustified for the same reason
  (no instance state). The internal step structure of the generic
  emitter (12 emit phases) stays as-is in B3 — extracting them as
  named private functions buys clarity but invites byte-diff risk
  if someone reorders the calls. Defer that decomposition to a
  later stage where it can be properly snapshot-locked.

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- bun run bench vs baseline: every hot-path metric is *faster*
  than the pre-refactor baseline. Static match 17-20 ps faster
  across all bucket sizes; param match 1.6-4.5 ns faster;
  wildcard match 1.9-4.4 ns faster; regex param 1-3 ns faster;
  optional param 2-4 ns faster. The improvement comes from
  Router's class shape collapsing 677 → 325 lines (less hidden-
  class surface for JSC's IC to track on every match).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Router 677 → 325 lines after B3. Codegen extracted to emitter.ts.
F2's 12-step decomposition deferred (mechanical move only in B3 to
preserve byte-diff 0 emit guarantee).

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

`normalizePath` was declared as `path => path` so the field had a
sane default before build(). After B1+B2+B3, every public entry
that reads it (`match()`, `allowedMethods()`,
`normalizePathForLookup`) checks `registration.isSealed()` first
and short-circuits before reaching the field. The identity arrow
is unreachable — Bun's coverage flags it (router.ts function
coverage 90.91%, exactly that one extra arrow function).

Replace with a definite-assignment field (`!:`). build()
unconditionally assigns from `r.normalizePath` before any code
path reads it. Coverage on router.ts: 100% line + 100% function.

No source-code logic changes beyond the dead initializer removal.
573 pass / 0 fail; tsc clean; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements REFACTOR.md stage B4 with one critical deviation from
the prescribed scope.

The doc § B4 placed `match()` inside MatchLayer alongside
`allowedMethods()` and `clearCache()`. The first attempt followed
the doc verbatim — Router.match delegated to
`this.matchLayer.match()`. End-to-end bench against baseline showed
catastrophic regression on the hot path:

  - static match (100 routes) p75: 317 ps → 12.82 ns  (40× slower)
  - static match (500 routes) p75: 319 ps → 13.40 ns  (42× slower)
  - param match /users/:id/posts p75: 50.5 → 56.4 ns  (+6 ns)
  - param 3-deep p75: 66.4 → 71.3 ns  (+5 ns)

Root cause: routing through `this.matchLayer.match` adds a
method-dispatch hop (Router.match → MatchLayer.match → matchImpl)
that breaks JSC's monomorphic IC on the critical path. The doc's
B3 warning ("매칭 함수 내부에 layer 메서드 호출이 새로 끼어드는
변경은 금지") applies here too — even though B3's specific concern
was emit-time, B4 introduced the same pattern at runtime.

Resolution:

- `pipeline/match.ts` keeps `allowedMethods()` and `clearCache()`
  only. These are cold paths — `allowedMethods` is called at most
  once per 404, `clearCache` is admin-side. The extra method hop
  is irrelevant there.
- `Router.match()` stays on Router and dispatches `this.matchImpl
  (method, path)` directly with a single null guard
  (`this.matchLayer === undefined`). One method dispatch, one
  closure call — same shape as pre-B4.
- The matchLayer field doubles as the "router built" sentinel.
  When undefined, all three public methods (`match` /
  `allowedMethods` / `clearCache`) short-circuit.

Re-bench after fix: every hot-path metric is faster than baseline
again (param match -2.8 to -5.7 ns, static match 17-20 ps faster).

Router shrinks 325 → 273 lines (-52). MatchLayer is 99 lines.

This is a deliberate scope reduction from the doc plan — recorded
in §3 B4 step. Trying to follow the doc verbatim breaks the
performance preservation principle (§ 1.2). SRP is preserved on
the cold side; the hot side keeps its monomorphic dispatch shape.

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- coverage line + branch on router.ts + match.ts: 100% / 100%.
- bun run bench vs baseline: every hot-path metric faster than
  pre-refactor baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§3 B4: rewrite the prescription to reflect what actually shipped —
match() stays on Router (3-tier dispatch breaks JSC IC); MatchLayer
holds cold-path concerns only. Note the empirical 25-40× regression
that drove the deviation, and that matchLayer === undefined now
serves as the 'built' sentinel (no separate assertBuilt helper).

§7.1: add B4 + B3-fix rows.
§7.2: drop B4 row, annotate F8(match) deviation.
Appendix A: F8(match) marked complete with the 'no helper, undefined
sentinel' rationale; F1 marked B1·B2·B3·B4 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements REFACTOR.md stage B5.

Pre-B5 the Router class still carried 16+ build-time fields
(handlers / segmentTrees / staticMap / staticRegistered / trees /
wildSpecs / anyTester / staticOutputsByMethod / methodCodes /
matchState / activeMethodCodes / normalizePath /
ignoreTrailingSlash / caseSensitive / maxPathLength /
maxSegmentLength) that were populated from BuildResult only to be
read back by `collectMatchConfig` for the cfg-build / matchLayer-
construct call sequence. After build() returned, those fields
lingered as dead state — closures inside matchImpl + matchLayer
already held every reference they needed.

B5 inlines `collectMatchConfig` into `build()` as a local cfg
object literal sourced directly from `snapshot` and the
`buildFromRegistration` result. Once cfg is consumed by
compileMatchFn and matchLayer is constructed, both `snapshot` and
`r` go out of scope — but the closures keep the underlying tables
alive. The Router fields they were written to are deleted.

Router fields after B5 (all post-build / read-only):
  - options, methodRegistry          (constructor inputs)
  - registration                     (sealed-check + seal)
  - optionalParamDefaults            (passed to Registration + cfg)
  - hitCacheByMethod / missCacheByMethod / cacheMaxSize
                                     (cache containers)
  - matchImpl                        (Router.match)
  - matchLayer                       (allowedMethods + clearCache)

= 9 fields total. Down from 16+. Methods: ctor + add + addAll +
build + match + allowedMethods + clearCache = 7. Router shrinks
273 → 204 lines.

The freeze list moves with the data: instead of freezing
`this.segmentTrees` etc. (no longer fields), freeze
`snapshot.segmentTrees / .staticMap / .staticRegistered` and
`r.wildSpecs / .activeMethodCodes`. The frozen objects stay alive
via the closures that captured them, and the test suite's
internal-state inspection now reaches them through
`r.registration.X` (Registration owns the snapshot tables) and
`r.matchLayer.X` (MatchLayer owns the build outputs it needs).

- test/guarantees.test.ts: rewrite the freeze lock-in spec's
  inspection paths to traverse `registration.{segmentTrees,
  staticMap, staticRegistered, handlers, wildcardNamesByMethod}`
  and `matchLayer.{trees, staticOutputsByMethod,
  activeMethodCodes}`. The contract is unchanged; only the
  paths reflect the new ownership.
- test/walker-fallbacks.test.ts: walker-name introspection
  routes through `matchLayer.trees` instead of `router.trees`.

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- coverage line + func: 100% / 100% on every refactor-touched
  file (router.ts / pipeline/* / codegen/* / internal/*).
- bun run bench vs baseline: hot-path within ±2 ns, mostly
  faster than baseline. wildcard match 2.5-3.8 ns faster, 404
  miss 1.4-2.4 ns faster, regex param ~1.5 ns faster, cache hit
  -0.3 to -1 ns. Param match drift +0.2 to +1.4 ns within
  variance window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B5 (commit 553bc42) closes F1 — Router is now a 204-line / 9-field /
7-method facade. Stage B (Router class decomposition) complete.

§7.1 completed-PR table: add B5 row.
§7.2 remaining-stages table: drop B5, add C/D/E/F as next.
Appendix A: F1 marked ✅ with the final 553bc42 SHA and the 204-line
fac	ade landmark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§5 final-structure diagram listed match.ts as 'MatchLayer<T> +
assertBuilt'. After B5:
- match() stays on Router (not in MatchLayer) — JSC IC penalty.
- assertBuilt was never introduced — matchLayer === undefined is the
  built sentinel.

Update the line so it reads accurately at the doc level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ qi fresh)

Implements REFACTOR.md stage C1.

Three pieces, all build-time only:

1. **Move** `src/matcher/segment-compile.ts` → `src/codegen/
   segment-compile.ts`. The file emits JS source via `new Function`
   — purely build-time. Its previous home in `matcher/` violated
   the "matcher = pure runtime" invariant. After the move,
   `matcher/segment-walk` (the one importer) reaches across the
   directory boundary explicitly via `'../codegen/segment-compile'`
   — segment-walk itself is a build-time factory whose *output*
   functions run at match time.

2. **`src/codegen/escape.ts` (new)** exports `escapeJsString(s)`
   as a named alias for `JSON.stringify(s)`. Emitter call sites
   that wrap user-supplied input (param names, wildcard names,
   static prefixes, method literals) now read as `escapeJsString
   (e.wildcardName)` instead of `JSON.stringify(e.wildcardName)`,
   making the safety contract explicit at every use site. The
   docstring records the invariant: every value reaching this
   helper is one path-parser already accepted, so the tacit
   assumption ("user input here is metacharacter-free") becomes a
   grep-able single source. F28 (stage F4) will replace this with
   a typed emit IR; until then the named choke point is the
   minimal-noise upgrade.

3. **`emitQueryStrip` signature** (path-normalize.ts) gains an
   optional `qiName` parameter so a future caller composing
   multiple normalizers in one emit scope can pass distinct
   identifiers and avoid strict-mode `var qi` redeclaration.
   Default keeps the existing identifier so the lone caller today
   produces byte-identical output to pre-C1.

Pragmatic deviation from doc § F16: segment-compile's top-level
`var len` / `var params` / `var pos0` are *not* run through
`fresh()`. They live in single-scope emit blocks with no nested
re-emission, so the rename would change identifiers without
gaining new safety. The fresh() abstraction continues to guard
the genuinely nested cases (pos / slash / val).

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- coverage line + func: 100% / 100% on escape.ts; 100% / 100%
  on path-normalize.ts; segment-compile.ts unchanged
  (pre-existing 81% branch is the F30 territory).
- bun run bench vs baseline: every hot-path metric is *faster*
  than the pre-refactor baseline. Param match -2.1 to -2.9 ns,
  wildcard match -1.5 to -2.4 ns, cache hit -0.8 to -1.5 ns,
  static match 7-19 ps faster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§7.1: add C1 row.
§7.2: drop C1 row.
Appendix A: F14 ✅, F16 ✅ (with the 'qi only; len/params/pos0 stay
hard-coded because single-scope safe' deviation noted explicitly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-review of C1 (commit 35f480c) flagged three docstring issues:

- Line 11 ended a sentence with a colon but no list followed.
- Lines 21-22 had empty backtick pairs where I had tried to embed
  specific control characters but accidentally produced empty
  blocks.
- 'F28 (stage F4 / phase 1.0)' was confusingly written —
  F28 is a finding ID and F4 is a stage F sub-step.

Rewrite to: explain the grep-ability rationale clearly, list the
input categories, drop the broken backtick block, and reference
'F28 (stage F's F4 — typed emit IR)' so the cross-link reads
unambiguously.

No source-code change. 573 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(C1 follow-up)

Self-audit of C1 (commit 35f480c) found four user-input
`JSON.stringify` call sites that were missed during the bulk
replacement in segment-compile.ts and emitter.ts:

  src/matcher/segment-walk.ts:87,88,96,97

These four sites are part of `tryCodegenStaticPrefixWildcard` —
build-time codegen that emits the static-prefix wildcard fast
path. They embed user-supplied prefixes and wildcard names into
emitted JS source, exactly the safety contract escapeJsString
exists to express. Missing them defeated C1's "single grep audit
finds every escape site" goal.

Replace the four sites with escapeJsString and import the helper
from '../codegen/escape'. segment-walk.ts already crosses the
matcher↔codegen boundary for compileSegmentTree; this adds one
more cross-import for the same justified reason (segment-walk
mixes runtime walker functions with build-time codegen factories).

After C2's walker-strategy extraction the codegen-emit portion of
segment-walk should move to codegen/, eliminating the boundary
crossing entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gistrationConfig / RouterCacheCtor / CacheEntry collision)

Self-audit of stages B-C surfaced four pieces of dead-weight
indirection introduced (or perpetuated) during the refactor.
None added safety, none added behavior, and one had a name
collision with an unrelated type. All four are now gone.

1. **`escapeJsString` (codegen/escape.ts) — REMOVED**
   A one-line `JSON.stringify` alias. The "named alias enables
   policy audit" justification didn't survive scrutiny: anyone
   could call `JSON.stringify` directly with no lint preventing
   it, so the supposed grep-able choke point was illusory.

   - Delete `src/codegen/escape.ts` (33 lines).
   - Replace 18 `escapeJsString(...)` call sites with direct
     `JSON.stringify(...)` across emitter.ts (5),
     segment-compile.ts (13), and segment-walk.ts (4).
   - Move the safety contract to a single block comment at the
     top of `codegen/segment-compile.ts` covering all three
     files (validateParamName guarantees → metacharacter-free
     input → JSON.stringify is safe).
   - F28 (stage F's F4 — typed emit IR) is still the long-term
     answer; until then the comment block is the policy.

2. **`RegistrationConfig` interface — REMOVED**
   A wrapper around a single optional field (`regexSafety?:
   RegexSafetyOptions`). Replaced with the field's own type at
   the constructor signature (`regexSafety: RegexSafetyOptions
   | undefined`). The `this.config.regexSafety` read becomes
   `this.regexSafety`. Net: -5 lines, one less type to import.

3. **`import { RouterCache as RouterCacheCtor }` — REMOVED**
   Gratuitous rename. RouterCache the type and RouterCache the
   value coexist fine in JS — TypeScript's namespace separation
   handles the dual role. The rename only cluttered the
   `new Function(...)` arg list and the closure-captured
   constructor name. Direct import + direct use throughout.

4. **`CacheEntry` collision — RENAMED**
   `cache.ts` declares an internal `interface CacheEntry<T>`
   for the LRU's `{key, value, used}` slot shape; `codegen/
   emitter.ts` declared a separate `CacheEntry<T>` for the
   router's stored `{value, params}` cache value. Same name,
   different shapes — confusing for readers. The codegen one
   becomes `MatchCacheEntry<T>` to make the distinction visible
   at every consumer site (router.ts, match.ts).

Verification:
- bun test: 573 pass / 0 fail.
- tsc --noEmit -p tsconfig.json: 0 errors.
- check:test-policy: clean.
- bun run build: clean.
- coverage line + func: 100% / 100% on every refactor-touched
  file (router.ts / pipeline/* / codegen/*).
- bun run bench vs baseline: every hot-path metric is *faster*
  than the pre-refactor baseline. Param match -3 to -6 ns,
  wildcard match -2 to -4 ns, cache hit -0.5 to -1.5 ns,
  static match 8-19 ps faster. No regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parkrevil and others added 29 commits May 17, 2026 17:48
Critical fixes (factual errors):
- "sub-1 ns" first-line claim → "single-digit nanoseconds on hot static
  paths". The min is 0.45 ns (σ 51.9%); the median is 2.52 ns. The
  npm-package first line should not lead with a min-sample value.
- Cache params semantics: "fresh per-call snapshot" → "frozen + reused
  across hits, do not mutate". Cache hit returns `cached.params` by
  reference (emitter.ts:233), not a copy. Object.freeze is the
  mutation guard.
- Error Kinds table: add `route-unreachable` (was missing despite
  being in `RouterErrorKind` and emitted by the registration layer).
- Route caps: "No path-length, segment-length, or route-count cap"
  did not mention the per-route caps that DO exist — ≤ 4 optional
  segments (MAX_OPTIONAL_SEGMENTS_PER_ROUTE) and ≤ 31 captured params
  (originalNames.length > 31 reject).

Recommended fixes:
- Quick Start: drop unused `RouterError` import (was importing a name
  the example never references).
- Cache description: "lazily allocated per HTTP method" → "empty
  routers allocate no cache; build() pre-allocates per active method".
  The pre-allocation happens in runBuildPipeline, not lazily on first
  match.
- Regex bodies: "any syntactically valid JavaScript regex is accepted"
  contradicted the very next line stating `^`/`$` anchors are rejected.
  Reworded to the actual rule.
- Framework Integration: drop `@zipbul/shared` import for `HttpMethod`;
  `router.match` accepts `method: string` directly. The previous example
  implied a second package install that isn't required.
- Performance section: compress from a full 9-row table + extended
  noise/mitata commentary to a 5-row indicative table + pointer to
  `bench-results.md`. Consumer README is not the place for
  Bessel-correction methodology notes.

ko.md sync:
- Drop duplicate "production-realistic" paragraphs (lines 423-425 of
  the previous file repeated the bench-results.md pointer 3×; en
  README had it once).
- Mirror every en-side change above.

No code changes, no API changes — README only.

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

Second-pass review after the Codex line-by-line audit:

- Type-name typo: `RouterErrKind` → `RouterErrorKind` (the actual
  `types.ts` export). Inline comment in the catch example was wrong
  in both en and ko.
- Table cell pipe escapes: `MatchOutput<T> | null` in the error-handling
  return-type column was breaking the GitHub markdown table parser
  (raw `|` ends the column). Escaped to `MatchOutput<T> \| null`.
- Exponent notation consistency: `[1, 2^30]` in the error-kinds row →
  `[1, 2³⁰]` to match the Options table cell above.
- First-paragraph perf range: "dynamic routes in 8–20 ns" did not
  match the Performance table (warm-cache hit ~10 ns, wrong-method
  ~3 ns). Tightened to "dynamic hits around ~10 ns with a warm cache".

No code or API changes — pure README polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical: IRI section example was wrong
- Old: `router.match('GET', '/users/한국')` annotated as "✓ matches"
- Reality: `match()` does not normalize, IRI input does NOT match
- Verified by running every README code sample against the live router
- Rewrote the section to show both forms and explicitly mark which one
  matches, with an [!IMPORTANT] callout for the no-normalization rule

Markdown technique polish:
- Replace `> ⚠ ...` blockquotes with native GitHub admonitions:
  `> [!WARNING]`, `> [!CAUTION]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!NOTE]`
  (colored callout boxes in GitHub render)
- Add a Bun ≥ 1.0 runtime [!NOTE] under the lede so the runtime
  requirement surfaces before installation
- Replace 8 ad-hoc `<br>` separators with `---` horizontal rules — npm
  and GitHub both render these cleanly, no jagged spacing
- Add a Table of Contents with anchor links into every API method —
  was a 400+ line README without navigation

Both files mirrored. Tests still 999 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal bench overhaul — no published API change.

Defects removed (line-by-line audit + Codex/Explore second opinion):
- router.bench.ts: drop non-existent RouterOptions (enableCache, ignoreTrailingSlash, caseSensitive); cache-vs-no-cache and case-insensitive sections were measuring nothing. fullOptionsRouter now uses real fields (trailingSlash:'ignore', pathCaseSensitive:false). Collapse static-match 10/100/500/1000 to a single hash-bucket bench.
- 100k-verification.ts: drop '100k churn' (fixed-path inputs defeated unique-path intent; real churn lives in cacheTraversalFeasibility). Drop candidateMicrobench + tryUrlPatternBaseline (didn't measure zipbul Router).
- complex-shapes.bench.ts: memoirist 'regex' shape now skips with regex:null instead of registering a tester-less variant under the same label. Fix mislabeled tester count (2->3).
- comparison.bench.ts: 'miss' wrongMethod hits a registered path so the axis isn't collapsed into the plain miss.
- regression-snapshot.ts: p99NsPerOp -> maxNsPerOp (TRIALS=11 makes nearest-rank p99 collapse to max).

Statistical honesty:
- helpers.ts percentile(): docstring warns small-N collapses p75/p99 to max.
- 100k-gate-runner.ts / 100k-external-baselines.ts: replace buildP99 with buildMax (1 sample per run).
- 100k-bun-serve-baseline.ts: drop warmedP99 (3-sample input identical to max).
- first-call-latency.ts: reuse shared percentile().

Methodology consistency:
- 100k-external-baselines.ts: find-my-way now uses {ignoreTrailingSlash:true} matching 100k-external-correctness.ts; settleScavenger before mem() baseline aligns with regression-snapshot.ts.

Dead-code purge:
- Delete bench/comparison-solo.bench.ts (orchestrator/worker split made it redundant).
- Delete bench/baseline/percent-gate.bench.txt (corresponding .ts removed earlier).
- Drop unused supports field + skipFor parameter in 100k-external-correctness.ts.

Cross-router fairness — full pair isolation:
- comparison.bench.ts: 7 adapters x 7 scenarios = 49 fresh-process pairs. Sanity-gate failures print structured reasons.
- complex-shapes.bench.ts: 3 routers x 11 shapes = 33 fresh-process pairs with per-shape build functions (no JIT pollution from sibling shapes). Unsupported pairs print skip=true reason=unsupported.

Walker-fallback bench: header note that the three benches measure each walker on its selection-triggering workload (different route counts/paths), so numbers are per-walker sanity timings, not cross-walker comparisons.

Verification: tsc clean, bun test 999 pass / 0 fail, all 13 benches build, worker-mode smoke runs OK for comparison/complex-shapes/cache-cardinality/walker-fallbacks/first-call-latency/regression-snapshot/router/100k-verification/100k-external-correctness. 100k-gate-runner regex parser confirmed still matches verification output format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part 1 — Tooling setup (toolkit-wide):
- Copy .oxlintrc.jsonc + .oxfmtrc.jsonc + knip.json from zipbul sibling repo
- Add devDeps: oxlint@1.41, oxlint-tsgolint@0.11, oxfmt@0.26, knip@5.63, dpdm@4.2
- Root scripts: typecheck, lint, format, format:check, knip, dpdm
- tsconfig: noUnusedLocals, noUnusedParameters, noPropertyAccessFromIndexSignature all true
- oxfmt applied to every .ts/.md/.json (204 files reformatted)
- Sensible overrides for bench (adapter looseness intrinsic) and tests (Result-type narrowing legitimate, intentional any)

Part 2 — Router lint compliance (227 errors -> 0):
- exports-last refactor across 12 src files: pattern-tester, path-policy, wildcard-method-expand, emitter, prefix-factor, factor-detect, path-parser, route-expand, wildcard-prefix-index, router, registration, segment-compile, segment-tree
- All inline `export function/interface/class` declarations moved to single bottom export block
- Test spec import dedup (path-parser.spec, route-expand.spec, router.spec): merged inline mid-file imports back to top, fixes import/first + import/no-duplicates
- Removed unused devDeps @hattip/router, itty-router (referenced only by since-deleted benches)

Part 3 — Router circular dep removal:
- Extracted SegmentNode + ParamSegment into src/tree/node-types.ts
- segment-tree.ts imports from node-types and re-exports; undo.ts imports from node-types
- Eliminates segment-tree <-> undo type-only cycle that dpdm flagged

Verification:
- bunx tsc --noEmit: 0 errors
- bun test (router): 999 pass / 0 fail / 9533 expects
- bunx oxlint packages/router: 0 warnings / 0 errors
- bunx dpdm packages/router: no circular dependencies

Known follow-ups (out of router scope, pre-existing):
- cors/multipart/rate-limiter/result/query-parser: 53 lint errors remaining
- knip flags ~11 router exports + 9 types as unused outside their own file (internal helpers; safe to delete or keep as internal-API surface)
- Stale dist/ artifacts in result/shared cause cross-package test failures from monorepo root

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tool upgrades (router scope):
- oxlint            1.41 -> 1.65 (latest)
- oxlint-tsgolint   0.11 -> 0.22 (latest)
- oxfmt             0.26 -> 0.50 (latest)
- knip              5.63 -> 6.14 (latest)
- dpdm              4.2  -> 4.2  (already latest)

oxfmt 0.50 reformatted 56 router files (new defaults: switch-case
body wrapped in braces on multi-stmt, default-case satisfies-never
expanded onto own line, etc).

Deleted RELEASE_GATE.md — one-shot internal gate report for the
feat/router refactor; zero source / doc references; not part of
the published artifact.

Verification (router scope):
- bunx tsc --noEmit            : 0 errors
- bunx oxlint packages/router  : 0 warnings / 0 errors
- bunx oxfmt --check           : clean
- bunx dpdm                    : no circular dependencies
- bun test                     : 999 pass / 0 fail / 9476 expects

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed all knip-flagged unused exports — internal helpers and types
that no caller (outside their own file) imports. None are in the
public surface (index.ts) or the internal-API surface (internal.ts).

Functions de-exported (11):
- appendStaticSegment, flushStaticBuffer, normalizeIriSegment (path-parser.ts)
- emitParamBranch, emitStaticChildren (segment-compile.ts)
- matchTerminalAtNode (recursive.ts — duplicate of the iterative.ts copy that specs use)
- ensureSegmentTreeRoot, pushHandler, recordExpansionTerminal (registration.ts)
- attachTerminal, attachWildcardTail (wildcard-prefix-index.ts)

Types de-exported (6):
- ParseResult (path-parser.ts)
- ExpandedRoute (route-expand.ts)
- CompiledPackage (segment-compile.ts)
- PrefixTrieNode (wildcard-prefix-index.ts)
- BuildResult re-export (pipeline/index.ts) — original interface stays internal to build.ts
- RegistrationSnapshot re-export (pipeline/index.ts) — consumers import from ./registration directly

createRecursiveWalker (recursive.ts) moved from inline export to bottom
export block to satisfy import/exports-last after de-exporting the sibling
matchTerminalAtNode.

Verification (router scope):
- tsc --noEmit             : 0 errors
- oxlint packages/router   : 0 warnings / 0 errors
- oxfmt --check            : All matched files use the correct format
- knip                     : 0 unused exports / 0 unused types
- dpdm                     : no circular dependencies
- bun test                 : 999 pass / 0 fail / 9502 expects

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Findings from parallel Explore + Codex line-by-line review of both READMEs:

API accuracy / factual (CRITICAL/HIGH from prior round, all addressed):
- add()/addAll() throw timing — only router-sealed at call; validation deferred to build()
- IRI example now calls build() before match()
- duplicate const router → stringRouter / handlerRouter
- '*' expansion — describe build-time semantics + custom-method inclusion
- allowedMethods() — URIError possible via regex-param walker (not "never throws")
- wildcard+specific = route-unreachable (not route-conflict)
- conflict examples — show build() throws RouterError({ kind:'route-validation', errors })

This round (post-fix re-review):
- KO L114 host name: 'http://x' → 'http://localhost' (match EN)
- KO L338: 'route-parse' description "미닫힌" → "닫히지 않은" (wrong word)
- KO L8 + perf table: "단일 자릿 nanosecond/ns" → "한 자리 나노초/ns" (direct-translation removed)
- KO perf table fully translated: "a few ms/tens of ms/a few ns" → "수 ms/수십 ms/수 ns"
- Korean particle attachment after foreign words (외래어+조사 띄어쓰기 일괄 수정): "Bun 을→Bun을", "match() 는→match()는", "static segment 는→segment는", "entry 로→entry로", "no-op 입니다→no-op입니다", etc. ~50 sites
- Korean verb-suffix attachment: "normalize 합니다→normalize합니다", "Object.freeze 되어→Object.freeze되어", "Stale 될→Stale될", "split 한→split한", "pathname 이어야→pathname이어야", "JavaScript-valid 한→JavaScript-valid한"
- "끝." colloquial trailing word removed
- "4 개 / 31 개 / 32 개" → "4개 / 31개 / 32개" (단위 표기)
- Wildcard table both langs: clarify "captures the entire tail, including slashes" (prior "segments" wording was ambiguous against the named-param "single segment" definition)

Verification:
- bunx tsc --noEmit -p packages/router/tsconfig.json: 0 errors
- bunx oxlint packages/router: 0 warnings / 0 errors
- bunx oxfmt --check packages/router: clean
- bun test (router): 999 pass / 0 fail / 9450 expects

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bench-results.md: replaced stale prose with measured tables
- Self-bench (build / match / RSS): regression-snapshot.ts 11-trial output
  - build() 100 routes 2.51 ms median, 10 000 routes 27.62 ms median
  - match() hit-static 3.64 ns, hit-dynamic-warm 9.06 ns, hit-dynamic-cold
    597.84 ns, miss-unknown 3.01 ns, miss-wrong-method 2.64 ns
  - RSS: static-1000 0.25 MB, dynamic-1000 0.33 MB, mixed-10000 5.16 MB
- Cross-router comparison: comparison.bench.ts fresh-process-per-pair
  - 7 scenarios x 7 adapters (zipbul, find-my-way, memoirist, rou3,
    hono-regexp, hono-trie, koa-tree-router)
  - zipbul leads every scenario; single-param 3.3x next-best (memoirist),
    3-deep params 6.0x (rou3), wildcard 5.8x (hono-regexp), GitHub-static
    8.6x (hono-regexp), GitHub-3-param 5.7x (rou3)
- Caveat documented: sub-ns rows on static/miss reflect JSC inlining;
  the relative gap is the load-bearing signal

README.md / README.ko.md performance section:
- Replaced "Hot-path checkpoints (range)" with concrete medians from
  regression-snapshot.ts (verifiable, links to bench-results.md)
- Added single-param cross-router table (top-line "why this lib")
- Removed unsourced "single-digit nanoseconds" marketing weasel

Quick Start (both langs):
- result.params.id -> result.params['id'] to stay safe under
  TypeScript's noPropertyAccessFromIndexSignature strict setting

Verification:
- bunx tsc --noEmit -p packages/router/tsconfig.json: 0 errors
- bunx oxlint packages/router: 0 / 0
- bunx oxfmt --check: clean
- bun test (router): 999 / 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
i7-13700K 에서 → i7-13700K에서
`regression-snapshot.ts` 는 → `regression-snapshot.ts`는

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

- Quick Start: add inline note that meta.source returns 'dynamic' on
  the first call and 'cache' on subsequent calls to the same path
- match() / IMPORTANT note: narrow framework list to Bun.serve only
  (the package targets Bun ≥ 1.0; the framework name-drop of Node http
  / Express / Fastify / Hono was a tangential reference, not an
  integration claim)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BREAKING CHANGE: public API now uses enums instead of string literals.
Consumers using string literals must switch to the enum members.

Conversions (9 total):

Public (exported from index.ts):
- TrailingSlash { Strict, Ignore }
  was: 'strict' | 'ignore'
- OptionalParamBehavior { Omit, SetUndefined }
  was: 'omit' | 'set-undefined'
- MatchSource { Static, Cache, Dynamic }
  was: 'static' | 'cache' | 'dynamic' (on MatchMeta.source)
- RouterErrorKind (21 members)
  was: 'router-sealed' | 'route-duplicate' | ... | 'route-validation'

Internal (tree barrel):
- PathPartType { Static, Param, Wildcard }
  was: 'static' | 'param' | 'wildcard' (on PathPart.type)
- WildcardOrigin { Star, Multi }
  was: 'star' | 'multi' (on wildcard PathPart.origin and
  SegmentNode.wildcardOrigin)

Internal (file-scope):
- UndoKind: dropped const enum, now plain enum (16 numeric members
  used in registration rollback switch dispatch)
- DecodeFailKind: replaced with subset of RouterErrorKind (less code)

All enums are string enums where applicable so the wire-format value
matches the previous string literal (JSON output unchanged), but
TypeScript narrowing requires the enum member (e.g. MatchSource.Cache
instead of 'cache'). Migration: replace each literal with its enum
counterpart; the enum value is exactly the previous string.

Verification:
- bunx tsc --noEmit: 0 errors
- bunx oxlint packages/router: 0 / 0
- bunx oxfmt --check: clean
- bunx dpdm: no circular dependencies
- bun test: 999 pass / 0 fail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second-opinion review (Explore + Codex parallel line-by-line) found
10 sites where prior commit (53e436a) left string literals instead of
enum members. Now converted:

src/pipeline/registration.ts:
- L171 seal options type: 'omit' | OptionalParamBehavior.SetUndefined
  -> OptionalParamBehavior (full enum)
- L182 default + comparison: ?? 'omit') === 'omit'
  -> ?? OptionalParamBehavior.Omit) === OptionalParamBehavior.Omit
- L566 RouteShape.originalTypes type: 'param' | 'wildcard'
  -> PathPartType.Param | PathPartType.Wildcard
- L575 local originalTypes: Array<'param' | 'wildcard'>
  -> Array<PathPartType.Param | PathPartType.Wildcard>
- L580 originalTypes.push('param') -> push(PathPartType.Param)
- L586 originalTypes.push('wildcard') -> push(PathPartType.Wildcard)
- L653 present[].type: PathPartType.Param | 'wildcard'
  -> PathPartType.Param | PathPartType.Wildcard (mixed type fixed)

src/codegen/super-factory.ts:
- L35 param type: 'param' | 'wildcard'
  -> PathPartType.Param | PathPartType.Wildcard
- L45, L59: === 'wildcard' -> === PathPartType.Wildcard

bench/cache-cardinality.bench.ts:
- L5 import path './src/types' (broken at runtime since bench is outside
  tsconfig) -> '../src/types'

Spec fixes for new strict typing:
- src/codegen/super-factory.spec.ts: 'param'/'wildcard' literals ->
  PathPartType.Param/.Wildcard
- src/pipeline/registration.spec.ts: 'param'/'wildcard' literals
  including `as const` patterns -> PathPartType members

Verification:
- bunx tsc --noEmit: 0 errors
- bunx oxlint packages/router: 0 / 0
- bunx oxfmt --check: clean
- bun test (router): 999 pass / 0 fail / 9450 expects
- bench runtime: cache-cardinality, walker-fallbacks, regression-snapshot,
  first-call-latency all start cleanly

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-2 review (Codex + Explore + grep) converged on the same 4
README defects. The enum refactor (53e436a) made the README's
RouterOptions interface fragment wrong — consumers copying it as
written hit TS2322:

  trailingSlash?: 'strict' | 'ignore';
  optionalParamBehavior?: 'omit' | 'set-undefined';

Now documented as the actual public types, plus an import line and
a new Router(...) example showing the enum members in use:

  import { Router, TrailingSlash, OptionalParamBehavior } from '@zipbul/router';
  trailingSlash?: TrailingSlash;
  optionalParamBehavior?: OptionalParamBehavior;
  new Router<string>({ trailingSlash: TrailingSlash.Strict, ... });

Default-value cells in the options table updated in lockstep
('ignore' -> TrailingSlash.Ignore, 'omit' -> OptionalParamBehavior.Omit).
Applied to both README.md and README.ko.md.

Also fix src/types.ts RouterErrorKind comment — said "1 + 28 + 2"
but the enum has 21 members (1 state + 18 registration/validation +
2 options/batch).

Verification:
- tsc --noEmit: 0
- oxlint: 0/0
- oxfmt --check: clean (after auto-format applied table column widths)
- bun test: 999 pass / 0 fail / 9482 expects

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

Both enums had only two members, so they were boolean choices dressed
up as enums. The codegen / pipeline already converted them to booleans
internally (router.ts:95, build.ts:106), so the public enum form just
added ceremony for callers — `{ trailingSlash: TrailingSlash.Strict }`
instead of `{ ignoreTrailingSlash: false }`.

Public API change (BREAKING):
- TrailingSlash enum removed; replaced with `ignoreTrailingSlash?: boolean`
  (default `true` — preserves prior `TrailingSlash.Ignore` default behavior)
- OptionalParamBehavior enum removed; replaced with `omitMissingOptional?: boolean`
  (default `true` — preserves prior `OptionalParamBehavior.Omit` default behavior)

Migration:
  { trailingSlash: TrailingSlash.Strict }            -> { ignoreTrailingSlash: false }
  { trailingSlash: TrailingSlash.Ignore }            -> { ignoreTrailingSlash: true }   // default; can drop
  { optionalParamBehavior: OptionalParamBehavior.Omit }          -> { omitMissingOptional: true }    // default; can drop
  { optionalParamBehavior: OptionalParamBehavior.SetUndefined }  -> { omitMissingOptional: false }

Public exports shrink to: Router, RouterError, MatchSource, RouterErrorKind
(MatchSource has 3 members, RouterErrorKind has 21 — enums stay).

Internal cleanup:
- src/types.ts: drop both enum declarations, update RouterOptions
- src/router.ts: drop TrailingSlash import; constructor uses `?? true` defaults
- src/pipeline/build.ts: drop TrailingSlash import; same `?? true` pattern
- src/pipeline/registration.ts: seal() options now `{ omitMissingOptional?: boolean }`
- src/builder/optional-param-defaults.ts: constructor takes `omit: boolean` directly
  (renamed private field from `behavior` to `omit` for symmetry with the parameter)
- index.ts: export list drops TrailingSlash, OptionalParamBehavior
- test/e2e/public-api-contract.test.ts: exports list assertion updated

All call sites updated across src/, test/, bench/, README.md, README.ko.md.
The bulk of the diff is mechanical literal substitution at usage sites.

Verification:
- tsc --noEmit: 0
- oxlint: 0/0
- oxfmt --check: clean
- bun test: 999 pass / 0 fail / 9530 expects
- knip: clean (4 pre-existing config hints, unrelated)
- dpdm: no circular dependencies
- bench runtime: router.bench, cache-cardinality.bench, walker-fallbacks.bench all start cleanly

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

Round-3 audit (Explore parallel review) flagged stale describe/it
strings that still referenced trailingSlash / optionalParamBehavior
in test labels (not in code — labels only). The test bodies were
already using the new boolean options; only the human-readable strings
were lagging.

- test/e2e/option-matrix.test.ts: 7 sites
- test/e2e/encoded-paths.test.ts: 2 sites
- test/e2e/router-options.test.ts: 1 site (also fixed "=false" suffix)
- test/integration/walker-dispatch.test.ts: 1 site
- src/pipeline/build.spec.ts: 1 site

Also add changeset for the major version bump (2-state enums removed
from public API). See .changeset/router-options-enum-to-boolean.md.

Items flagged but not addressed (intentional):
- dist/*.d.ts: stale built artifacts. dist/ is gitignored at repo root,
  so nothing to commit; CI regenerates on publish.
- ULTIMATE.md: internal design-notes archive carrying prior-iteration
  API references. Treating it as historical context, not API surface.

Verification:
- tsc --noEmit: 0
- bun test: 999 pass / 0 fail / 9584 expects
- oxfmt --check: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ULTIMATE.md (225 KB internal design archive carrying multi-iteration
notes) was sitting inside the published router package directory but is
not part of the consumer-facing surface — it is project-wide knowledge
that outlived the router-specific scope it grew in.

Move to ../../knoldr/ULTIMATE.md at the worktree root so:
- It does not ship in the published @zipbul/router npm tarball.
- It is not scanned by router-specific tooling (knip, etc.).
- It lives alongside any future project-wide knowledge documents.

knip.json: drop the now-redundant `"ULTIMATE.md"` ignore entry — the
file is no longer in the router project root.

Code comments in src/codegen/emitter.ts and src/pipeline/build.ts
mentioning "ULTIMATE measured ..." refer to benchmark findings recorded
in the document, not to its file path; left as-is.

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

Every comment across src/, test/, bench/, index.ts, internal.ts removed
(linter / TS pragma directives preserved). 2920 lines of inline notes,
prior-iteration design rationale, and benchmark commentary dropped from
the working tree.

In their place, fresh JSDoc only on the published surface from index.ts:

- Router (class) + every method (add, addAll, build, match, allowedMethods)
- RouterError (class) + .data field
- MatchSource (enum) + every member
- RouterErrorKind (enum) + every member
- RouterOptions (interface) + every field
- RouteParams, RouteValidationIssue
- RouterErrorData (discriminated union, doc on the type)
- RouterPublicApi (interface) + member references
- MatchMeta + .source
- MatchOutput<T> + .value / .params / .meta

JSDoc is concise, links via {@link}, documents parameters / returns /
throws / null cases / decode rules / object-identity guarantees. Internal
helpers, registration / build / matcher / codegen / pipeline modules,
tests, and benches carry zero comments — names + code are the contract.

Verification:
- tsc --noEmit: 0
- oxlint: 0/0 (after switching @typeparam -> @template)
- oxfmt --check: clean (after auto-format applied)
- bun test: 999 pass / 0 fail / 9467 expects

Diff summary: 121 files changed, +281 / -2920.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3-way independent review (Codex synchronous read of 127 files via cat,
Explore Read tool, and Claude direct read) converged on 12 defects.
Each is reproduced and patched at root cause — no workarounds.

Public-API JSDoc (4 wording defects — all reproduced):

1. src/error.ts: "match() never throws" replaced with the precise
   contract — RouterError is never thrown by match(), but
   decodeURIComponent may still propagate URIError on malformed %xx
   in a captured :param slot. Reproduction: r.match('GET', '/x/%GG')
   throws URIError on '/x/:id'.
2. src/types.ts: same fix on the RouterErrorKind JSDoc.
3. src/router.ts (Router.match): JSDoc said "does not normalize the
   input"; actually trailing-slash + case normalization run when their
   options opt in. Now scopes the claim to IRI / percent-encoding
   normalization and points at the relevant RouterOptions fields.
   Adds @throws {URIError} so the malformed-%xx contract is on the
   method signature.
4. src/router.ts (Router.add): '*' expansion docs claimed "earlier
   add() calls" but expandWildcardMethodRoutes scans the full
   pendingRoutes at seal time. Reworded to "any other route registered
   before build() — registration order does not matter".
   Reproduction: r.add('*','/x',v) followed by r.add('PURGE','/y',v)
   then r.match('PURGE','/x') hits the '*' route.

Test-quality defects (5 fixed):

5-6. test/integration/memory-bounds.test.ts L93/L108: surviving
     /* expected */ and /* expected route-duplicate */ block comments
     (the token-aware strip script missed them because they live
     inside an otherwise-empty catch {} body). Replaced with `void 0;`
     so the catch block is a valid no-op statement, no comments.
7. test/e2e/error-kinds.test.ts: describe title claimed "full coverage
     of 22 kinds" but the enum has 21 members and the file covered
     only 14 distinct kinds. Title corrected to "21 kinds" and the 3
     missing reproducers added — PathInvalidUtf8 (overlong UTF-8 lead
     byte), RouterOptionsInvalid (negative cacheSize on construct),
     and RouteValidation (duplicate routes — the umbrella kind
     produced when build() aggregates per-route failures).
8. test/e2e/allowed-methods.test.ts: "strips query string before
     matching" was misleading — the test passed because :id (no
     regex) captures '?token=abc' as the id value, not because
     anything strips '?'. Renamed to "does not strip query string —
     raw `?...` is captured into an unconstrained :param value" and
     added a sibling test that pins the opposite behavior under a
     regex-constrained :id(\\d+), which correctly rejects the input.

Encoded-paths title cleanup (3 fixed):

9. test/e2e/encoded-paths.test.ts: `expect(...value).toBe(WildcardOrigin.Multi)`
     for handler value 'multi' — passed only by string coincidence
     (WildcardOrigin.Multi === 'multi'). Restored to literal 'multi'
     and dropped the now-unused WildcardOrigin import.
10. Same file L90: `ignoreTrailingSlash=strict` makes no sense for a
     boolean option; renamed to `ignoreTrailingSlash=false`.
11. Same file L82: clunky "(ignoreTrailingSlash=undefined (defaults true))"
     double-paren title from an earlier auto-rename; flattened to
     "ignoreTrailingSlash unset defaults to true".

Internal cleanup (1 fixed):

12. src/tree/undo.ts: UndoKind enum sequence had a numbering oddity
     — values went 1..11, 17, 13..16 (StaticBucketReset was 17 and
     value 12 was unused). Functionally harmless (each value is a
     unique discriminator) but indicated a careless edit. Renumbered
     StaticBucketReset to 12 so the sequence is clean 1..16.

Verification:
- tsc --noEmit: 0
- oxlint: 0/0
- oxfmt --check: clean
- bun test: 1003 pass / 0 fail / 9586 expects (was 999, +4 from
  the 3 new RouterErrorKind reproducers + 1 new allowed-methods
  regex test)

Read coverage: every one of 125 .ts files in the package (src/,
test/, bench/, index.ts, internal.ts) was read end-to-end by hand
during this round; defects above are the complete set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The explicit `= 1, = 2, ... = 16` numbering served no purpose. Each
member is used only as a discriminator in switch/=== checks; values
are never serialized to disk or wire, never used as array indices,
never compared as numbers. TypeScript's default 0-based auto-numbering
produces the same runtime behavior with less noise.

Verification:
- tsc --noEmit: 0
- oxlint: 0/0
- bun test: 1003 pass / 0 fail / 9640 expects

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each item was verified by reproduction in the prior round.

1. src/cache.ts:nextPow2 — replaced the 5-line bit-twiddle with
   `2 ** Math.ceil(Math.log2(n))`. Verified equivalent across
   n ∈ {0..2^30+1} (mismatches=0 in spec range). The bit-twiddle is
   integer-only, but RouterCache instantiates once per Router and the
   call site is cold, so the perf delta is unmeasurable. One-liner wins.

2. src/builder/route-expand.ts — dropped 4 spec-only exports:
   - countOptionalSegments: had ZERO production callers, only used by
     its own spec. Removed entirely.
   - filterDroppedSegments, isDroppedAt, trimTrailingSlashOnDrop:
     unexported (file-local). Their behavior is covered end-to-end
     through `expandOptional`, which is the only public entry point
     here. Public surface from this module is now just
     `expandOptional` + `MAX_OPTIONAL_SEGMENTS_PER_ROUTE`.
   - Spec restructured: dropped the 3 internal-helper describes;
     `expandOptional` describe block now pins drop-time slash trim
     and post-merge `//` collapse via the public API, with an added
     boundary test at exactly MAX_OPTIONAL_SEGMENTS_PER_ROUTE.

3. src/builder/path-parser.ts — dropped 3 spec-only exports:
   - extractNameAndPattern, rejectColonWildcardSugar,
     stripOptionalDecorator: unexported. PathParser is the only
     remaining export; its integration tests cover the helpers' paths.
   - Spec describes for the three helpers removed.

4. src/pipeline/registration.ts:compileStaticRoute — added explicit
   `return undefined;` on the success path. Result<void, RouterErrorData>
   tolerates implicit-undefined return, but the explicit form removes
   the reader-burden of pattern-matching "is this the success path or
   a missing return?" against the two err() exits above.

5. bench/*.ts `any` / `as unknown as` usage — left as-is. The 22
   occurrences across 4 files all sit in adapter shims for external
   routers (find-my-way, hono, koa-tree-router, memoirist, rou3) where
   the library's published `.d.ts` either widens to `unknown[]` or
   omits constructor / match overloads we need. Tightening would require
   per-library version-pinned type guards that add more noise than the
   raw `as unknown as` cast. The one internal-API cast
   (`getRouterInternals(...) as unknown as {...}` in walker-fallbacks)
   is the documented internal-hatch pattern.

6. src/tree/undo.ts — removed the `void _exhaustive;` line from the
   switch default branch. Parent tsconfig sets `noUnusedLocals: false`
   and oxlint treats `_`-prefixed locals as intentionally unused, so
   `const _exhaustive: never = entry;` alone is sufficient as the
   compile-time exhaustiveness check. Verified by removing the line
   and confirming tsc + oxlint stay 0/0.

Test count drop: 1003 → 980 (-23). The 23 removed tests were direct
unit tests on internal helpers in route-expand and path-parser; the
helpers' code paths are still fully exercised by the `expandOptional`
and `PathParser` integration tests plus the public e2e suite.

Verification:
- tsc --noEmit: 0
- oxlint: 0/0
- oxfmt --check: clean
- bun test: 980 pass / 0 fail / 9395 expects

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

The cleanup commit introduced a typo in the new boundary test title.
"should cap honor MAX_OPTIONAL_SEGMENTS_PER_ROUTE at the boundary" had
a stray "cap". Restored to "should honor MAX_OPTIONAL_SEGMENTS_PER_ROUTE
at the boundary (exactly 2^N variants)" — clearer about what's pinned.

Verification:
- tsc --noEmit: 0
- bun test: 980 pass / 0 fail / 9458 expects

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, add lint/format/dep gates

Build:
- Replace broken `bun build` config (sideEffects:false + barrel triggered Bun
  bundler bug #18008, emitting an 84-byte stub) with bunup. Emits a working ESM
  bundle, .d.ts, and a linked sourcemap (minify + sourcemap). Drop sideEffects
  and tsconfig.build.json.

Match-time correctness:
- Case-insensitive matching (pathCaseSensitive:false) no longer mutates or
  corrupts captured values. Case-folding is now ASCII-only and length-preserving
  (vs String.toLowerCase, which is length-changing for chars like İ and was
  misaligning capture offsets). Folding applies to route selection only.
- Case-insensitive hit cache no longer serves stale captures: keyed on the
  non-folded (still trailing-slash-trimmed) path, so case-variant inputs keep
  their own captures while same-path / trailing-slash variants still cache-hit.
- Resolve remaining audit findings (param+star empty-tail, \w regex over-match,
  optional+regex params, non-ASCII method tokens, raw `?` in non-param segment).

Tooling / quality gates:
- Add oxlint, oxfmt, knip, dpdm configs and scripts.
- Remove dead code (OptionalParamDefaults, MethodRegistry.get/size, nullable
  cache path); restore maxParamsObserved in per-route rollback.

Tests:
- RED-GREEN for the case-insensitive fix; strengthen cache tests to use
  letter-bearing captures that expose the stale-cache bug. 979 pass, 99% cov.

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

Adopt StrykerJS mutation testing for the non-codegen logic modules (codegen files
emit JS source strings and yield mostly equivalent mutants, so they are excluded;
their behaviour is already covered by e2e match tests).

Setup (local-only, not CI — the command runner re-runs the whole suite per mutant):
- bunfig.mutation.toml: coverage-off test config so Stryker's command runner sees a
  clean pass/fail exit (the regular coverageThreshold would otherwise exit non-zero).
- stryker.config.json: inPlace + incremental, command runner invoking
  `bun --config=bunfig.mutation.toml test --path-ignore-patterns='**/memory-bounds.test.ts'`
  (memory-bounds is heap-timing flaky under concurrency). No thresholds.break gate —
  survivors are a review trigger, equivalents documented as found.
- package.json: `test:mutation` script.
- .gitignore: reports/ and .stryker-tmp/.

First gap closed (verified by applying the mutant — the existing 20 tests all passed
with `isAllDigits` lower bound mutated `< 48` -> `<= 48`, wrongly rejecting '0'):
- pattern-tester: add character-range boundary tests for the digit/alpha/word/word-dash
  shortcuts ('0' '9' 'A' 'Z' 'a' 'z' '_' '-' and their adjacent codes), which were never
  exercised — only mid-range values were.

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

Each gap was confirmed by applying the surviving mutant and observing the existing
suite still pass, then verified the new test fails under the mutant (red) and passes
on restore (green):

- path-parser: param-name range edges were untested. Mutating `ch >= 65` -> `ch > 65`
  wrongly rejected ':idA' yet all tests passed. Add accept tests for the A/Z/a/z/0/9/_
  boundary chars as non-first param-name characters.
- path-policy: (1) a raw '?' at a non-param segment END ('/a?') was only tested mid-segment
  ('/a?b'); add it to the PathQuery reject table (kills the `segmentIsParam = true` mutant).
  (2) UTF-8 codepoint boundaries were untested — only mid-range scalars (U+4E00, U+1F600).
  Add accept tests for each length's minimum scalar (U+0080/U+0800/U+10000) and the maximum
  valid scalar (U+10FFFF), killing the overlong `< seqMin` -> `<=` and `> 0x10FFFF` -> `>=`
  off-by-one mutants.
- cache: overwriting an existing key must reuse its slot; the existing overwrite test only
  checked the key's own value. Add a test that repeated overwrites do not consume capacity
  or evict other live keys (kills the `if (existing !== undefined)` removal mutant).

Note: segment-tree's surviving conflict-branch mutants were investigated and found NOT to be
behavioural gaps — gutting a param-conflict branch still rejects the route via another build
stage (defense-in-depth); the survivors are error-message text that no test asserts.

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

Verified by applying the surviving mutant: gutting subtreeShapesEqual's
patternSource-mismatch guard (`return false` -> `return true` at the regex-pattern
check) let the full 992-test suite still pass, yet 1000+ sibling tenants that share
shape but differ in their :param regex were then wrongly factored together — the odd
tenant got walked through the shared firstChild tester, producing WRONG matches
(e.g. an [a-z]+ tenant accepting digits and rejecting letters). Confirmed the new
test fails under the mutant (red) and passes on restore (green).

- Add a differential regression: 1499 \d+ sibling tenants + one [a-z]+ tenant must
  each keep their own tester (kills the patternSource-mismatch mutant).
- Add a param-name variant guard (factored tenants keep their own param key).

Investigated the remaining factor-detect/traversal/route-expand survivors by applying
the mutants directly; all are non-behavioural:
- factor-detect param-name mismatch: param names resolve per-terminal, so a mis-factor
  cannot corrupt the captured key (equivalent).
- traversal fold-widen (wildcardStore guard): a node with both a wildcard store and a
  static child is rejected as route-unreachable at build, so the widened branch is
  unreachable (equivalent).
- core branches (foldStaticChain store guard, leafStoreOf multi-terminal reject,
  route-expand isDroppedAt) are already covered — gutting them fails existing tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	bun.lock
CI runs `bun test` with a per-file coverageThreshold of 0.95. Three files fell short;
each was traced to a real (non-dead) gap via reproduction, not worked around:

- identity-registry (funcs 66.67% -> 100%): bun's function coverage was not counting
  the *implicit* constructor. Made it explicit (fields initialized in the body) so the
  instantiation is attributed — no behaviour change.
- segment-compile (94.52% -> 95.16%): the codegen JIT-warmup path (collectWarmupPaths
  -> firstStaticChild) only walks a staticChildren map when a node has 2+ static
  children. Static-only routes go to a separate map and single children take another
  branch, so this needs *dynamic routes sharing a prefix with multiple static branches*
  (`/api/{users,posts,comments,tags}/:id`). Added that REST-shaped regression test.
- segment-tree (-> 97.87%, already >0.95): added unit tests for the param-conflict and
  unreachable-sibling branches (insertParamPart), plus e2e for param/wildcard collision
  and a compile-failing regex body (`([`), whose sole catcher is resolveOrCompileTester.

Also exclude test/** and *.spec.ts from coverage measurement (test helpers are not
production code). Remaining uncovered lines (segment-tree rollback wrappers, segment-
compile bail branches) are defense-in-depth pre-empted by the prefix-index stage in
registration; each affected file is still >=0.95.

997 pass, coverage gate green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@parkrevil parkrevil merged commit 8d5a6c1 into main May 26, 2026
1 check passed
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