router: case-insensitive fix, bunup build, mutation testing + gap closures#51
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the
feat/routerwork tomain.mainwas merged in first, so the newhelmet/cookie/compressionpackages are preserved (onlybun.lockneededregeneration).
@zipbul/router changes (covered by changesets)
router-options-enum-to-boolean)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,
\wregex, optional+regex, non-ASCII method, raw?fixes (
router-build-fix-and-match-correctness)router-bench-audit-and-isolation)Quality
gaps it surfaced (pattern-tester boundaries, param-name boundaries,
/a?+ UTF-8codepoint boundaries, cache overwrite capacity, factor-detect tenant pattern
mismatch) — each verified by applying the mutant.
Test plan
bun test(coverage) green locally