@nextrush/router 3.0.7 → 4.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +227 -178
  3. package/dist/index.js +1075 -657
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +59 -0
  8. package/src/__tests__/canonical-path-security.test.ts +198 -0
  9. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  10. package/src/__tests__/dispatch-deasync.test.ts +292 -0
  11. package/src/__tests__/find-node-differential.test.ts +220 -0
  12. package/src/__tests__/fixtures/match-golden.json +556 -0
  13. package/src/__tests__/head-auto-registration.test.ts +197 -0
  14. package/src/__tests__/helpers/differential-corpus.ts +314 -0
  15. package/src/__tests__/match-differential.test.ts +46 -0
  16. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  17. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  18. package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
  19. package/src/__tests__/match-prenormalized.test.ts +95 -0
  20. package/src/__tests__/match-safety.test.ts +223 -0
  21. package/src/__tests__/match-single-alloc.test.ts +82 -0
  22. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  23. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  24. package/src/__tests__/param-decoding.test.ts +78 -0
  25. package/src/__tests__/public-surface.test.ts +72 -0
  26. package/src/__tests__/registration-max-depth.test.ts +56 -0
  27. package/src/__tests__/route-metadata.test.ts +172 -0
  28. package/src/__tests__/router-audit.test.ts +315 -0
  29. package/src/__tests__/router-edge-cases.test.ts +2 -7
  30. package/src/__tests__/router.test.ts +148 -7
  31. package/src/__tests__/static-map-reset.test.ts +48 -0
  32. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  33. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  34. package/src/canonicalize.ts +137 -0
  35. package/src/composition.ts +79 -0
  36. package/src/constants.ts +43 -0
  37. package/src/dispatch.ts +145 -0
  38. package/src/find-node.ts +125 -0
  39. package/src/group-router.ts +208 -0
  40. package/src/index.ts +14 -5
  41. package/src/match-route.ts +241 -0
  42. package/src/matching.ts +246 -0
  43. package/src/middleware-adapter.ts +59 -0
  44. package/src/redirect.ts +97 -0
  45. package/src/registration.ts +343 -0
  46. package/src/route-metadata.ts +68 -0
  47. package/src/router.ts +219 -872
  48. package/src/segment-trie.ts +227 -0
  49. package/src/state.ts +53 -0
  50. package/src/walk-pool.ts +208 -0
  51. package/src/radix-tree.ts +0 -184
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @nextrush/router - HEAD-on-GET auto-registration (RFC 9110 §9.3.2)
3
+ *
4
+ * A `GET` route must also answer `HEAD`. Fastify, Express, Koa and Hono all do
5
+ * this; before this suite NextRush returned 404 for `HEAD` on every `GET` route,
6
+ * making conditional-request revalidation, CDN HEAD probes and HEAD-configured
7
+ * health checks fail.
8
+ *
9
+ * @see reports/investigations/2026-07-31-measured-floor-params-compliance/04-http-compliance-head.md
10
+ */
11
+
12
+ import type { HttpMethod } from '@nextrush/types';
13
+ import { describe, expect, it } from 'vitest';
14
+ import { createRouter, Router } from '../router';
15
+
16
+ const noop = (): void => {};
17
+
18
+ describe('HEAD auto-registration for GET routes', () => {
19
+ it('matches HEAD on a static GET route', () => {
20
+ const router = createRouter();
21
+ router.get('/health', noop);
22
+
23
+ expect(router.match('GET', '/health')).not.toBeNull();
24
+ expect(router.match('HEAD', '/health')).not.toBeNull();
25
+ });
26
+
27
+ it('matches HEAD on a param GET route and extracts the same params', () => {
28
+ const router = createRouter();
29
+ router.get('/users/:id', noop);
30
+
31
+ const get = router.match('GET', '/users/42');
32
+ const head = router.match('HEAD', '/users/42');
33
+
34
+ expect(head).not.toBeNull();
35
+ expect(head?.params).toEqual({ id: '42' });
36
+ expect(head?.params).toEqual(get?.params);
37
+ });
38
+
39
+ it('matches HEAD on a wildcard GET route', () => {
40
+ const router = createRouter();
41
+ router.get('/static/*', noop);
42
+
43
+ const head = router.match('HEAD', '/static/a/b.txt');
44
+ expect(head).not.toBeNull();
45
+ expect(head?.params['*']).toBe('a/b.txt');
46
+ });
47
+
48
+ it('reuses the GET handler and executor rather than compiling a second one', () => {
49
+ const router = createRouter();
50
+ router.get('/x', noop);
51
+
52
+ const get = router.match('GET', '/x');
53
+ const head = router.match('HEAD', '/x');
54
+
55
+ expect(head?.handler).toBe(get?.handler);
56
+ expect(head?.executor).toBe(get?.executor);
57
+ });
58
+
59
+ it('does not auto-register HEAD for non-GET methods', () => {
60
+ const router = createRouter();
61
+ router.post('/submit', noop);
62
+ router.put('/replace', noop);
63
+
64
+ expect(router.match('HEAD', '/submit')).toBeNull();
65
+ expect(router.match('HEAD', '/replace')).toBeNull();
66
+ });
67
+
68
+ describe('an explicit router.head() always wins', () => {
69
+ it('when registered AFTER the GET route', () => {
70
+ const router = createRouter();
71
+ const getHandler = (): void => {};
72
+ const headHandler = (): void => {};
73
+
74
+ router.get('/x', getHandler);
75
+ expect(() => router.head('/x', headHandler)).not.toThrow();
76
+
77
+ expect(router.match('HEAD', '/x')?.handler).toBe(headHandler);
78
+ expect(router.match('GET', '/x')?.handler).toBe(getHandler);
79
+ });
80
+
81
+ it('when registered BEFORE the GET route', () => {
82
+ const router = createRouter();
83
+ const getHandler = (): void => {};
84
+ const headHandler = (): void => {};
85
+
86
+ router.head('/x', headHandler);
87
+ expect(() => router.get('/x', getHandler)).not.toThrow();
88
+
89
+ expect(router.match('HEAD', '/x')?.handler).toBe(headHandler);
90
+ expect(router.match('GET', '/x')?.handler).toBe(getHandler);
91
+ });
92
+ });
93
+
94
+ it('still rejects a genuine duplicate HEAD registration', () => {
95
+ const router = createRouter();
96
+ router.head('/x', noop);
97
+ expect(() => router.head('/x', noop)).toThrow(/already registered/);
98
+ });
99
+
100
+ it('still rejects a genuine duplicate GET registration', () => {
101
+ const router = createRouter();
102
+ router.get('/x', noop);
103
+ expect(() => router.get('/x', noop)).toThrow(/already registered/);
104
+ });
105
+
106
+ it('does not emit a duplicate introspection row for the derived HEAD', () => {
107
+ const router = createRouter();
108
+ router.get('/users/:id', noop);
109
+
110
+ const rows = router.getRoutes();
111
+ expect(rows).toHaveLength(1);
112
+ expect(rows[0]?.method).toBe('GET');
113
+ expect(rows.some((r) => r.method === 'HEAD')).toBe(false);
114
+ });
115
+
116
+ it('emits exactly one introspection row when HEAD is registered explicitly', () => {
117
+ const router = createRouter();
118
+ router.get('/x', noop);
119
+ router.head('/x', noop);
120
+
121
+ const rows = router.getRoutes();
122
+ expect(rows.filter((r) => r.method === 'HEAD')).toHaveLength(1);
123
+ expect(rows.filter((r) => r.method === 'GET')).toHaveLength(1);
124
+ });
125
+
126
+ it('leaves router.all() working (its own HEAD registration must not conflict)', () => {
127
+ const router = createRouter();
128
+ expect(() => router.all('/any', noop)).not.toThrow();
129
+
130
+ const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
131
+ for (const method of methods) {
132
+ expect(router.match(method, '/any'), `expected ${method} to match`).not.toBeNull();
133
+ }
134
+ // .all() still consolidates to a single introspection row
135
+ expect(router.getRoutes()).toHaveLength(1);
136
+ });
137
+
138
+ it('leaves redirect() working (it registers GET and HEAD itself)', () => {
139
+ const router = createRouter();
140
+ expect(() => router.redirect('/old', '/new')).not.toThrow();
141
+
142
+ expect(router.match('GET', '/old')).not.toBeNull();
143
+ expect(router.match('HEAD', '/old')).not.toBeNull();
144
+ });
145
+
146
+ it('survives a mounted sub-router without a route conflict or duplicate rows', () => {
147
+ const sub = createRouter();
148
+ sub.get('/list', noop);
149
+ sub.get('/:id', noop);
150
+
151
+ const parent = createRouter();
152
+ expect(() => parent.mount('/users', sub)).not.toThrow();
153
+
154
+ expect(parent.match('GET', '/users/list')).not.toBeNull();
155
+ expect(parent.match('HEAD', '/users/list')).not.toBeNull();
156
+ expect(parent.match('HEAD', '/users/7')).not.toBeNull();
157
+
158
+ // one row per copied GET route, no HEAD rows
159
+ const rows = parent.getRoutes();
160
+ expect(rows.filter((r) => r.method === 'HEAD')).toHaveLength(0);
161
+ expect(rows.filter((r) => r.method === 'GET')).toHaveLength(2);
162
+ });
163
+
164
+ it('reports HEAD in the Allow set for a GET-only route', () => {
165
+ const router = createRouter();
166
+ router.get('/only-get', noop);
167
+
168
+ // allowedMethods() walks the trie; HEAD is now a registered method there.
169
+ const middleware = router.allowedMethods();
170
+ expect(typeof middleware).toBe('function');
171
+
172
+ // Direct trie assertion via match(), which is the observable contract.
173
+ expect(router.match('HEAD', '/only-get')).not.toBeNull();
174
+ });
175
+
176
+ it('clears derived HEAD entries on reset()', () => {
177
+ const router = new Router();
178
+ router.get('/x', noop);
179
+ expect(router.match('HEAD', '/x')).not.toBeNull();
180
+
181
+ router.reset();
182
+ expect(router.match('GET', '/x')).toBeNull();
183
+ expect(router.match('HEAD', '/x')).toBeNull();
184
+
185
+ // re-registering after reset must not report a conflict
186
+ expect(() => router.get('/x', noop)).not.toThrow();
187
+ expect(router.match('HEAD', '/x')).not.toBeNull();
188
+ });
189
+
190
+ it('honours a case-insensitive static route for HEAD too', () => {
191
+ const router = createRouter();
192
+ router.get('/Health', noop);
193
+
194
+ expect(router.match('HEAD', '/health')).not.toBeNull();
195
+ expect(router.match('HEAD', '/HEALTH')).not.toBeNull();
196
+ });
197
+ });
@@ -0,0 +1,314 @@
1
+ /**
2
+ * @nextrush/router - Differential / characterization corpus
3
+ *
4
+ * Shared corpus + canonical serializer backing the golden-master regression
5
+ * harness for the `router-match-path-allocation-trim` change. The serializer
6
+ * reduces a `Router.match()` result to a stable, JSON-comparable shape so the
7
+ * pre-change matcher's output (captured once into `match-golden.json`) can be
8
+ * asserted byte-identical against the post-rewrite matcher across every trim.
9
+ *
10
+ * WHY a golden master rather than two live matchers: the trims land in-place on
11
+ * a single matcher, so the "old" implementation cannot coexist with the "new"
12
+ * one in the same process. Persisting the pre-change output as a committed
13
+ * fixture is the standard characterization technique for exactly this — the
14
+ * fixture IS the old matcher's behavior, frozen.
15
+ *
16
+ * Intentional-delta cases (null-prototype params, and `__proto__`/`constructor`/
17
+ * `prototype` param NAMES) are deliberately NOT in this corpus — they change
18
+ * observably by design (design.md D8) and are pinned by dedicated forward
19
+ * scenarios instead. This corpus covers only the behavior-PRESERVING contract:
20
+ * which route resolves, the own-enumerable param entries and their values, and
21
+ * whether an executor is present.
22
+ *
23
+ * @packageDocumentation
24
+ * @internal
25
+ */
26
+
27
+ import type { Context, RouteHandler } from '@nextrush/types';
28
+ import { createRouter, Router } from '../../router';
29
+
30
+ /** A tagged no-op handler so a match result's `handler` is identifiable. */
31
+ function h(tag: string): RouteHandler {
32
+ const fn = (async (_ctx: Context) => {}) as RouteHandler & { tag: string };
33
+ fn.tag = tag;
34
+ return fn;
35
+ }
36
+
37
+ /** A single (method, path) probe against a named route set. */
38
+ export interface Probe {
39
+ method: string;
40
+ path: string;
41
+ }
42
+
43
+ /** A named router plus the probes run against it. */
44
+ export interface RouteSet {
45
+ name: string;
46
+ build: () => Router;
47
+ probes: Probe[];
48
+ }
49
+
50
+ /**
51
+ * Canonical, JSON-stable serialization of one match result.
52
+ *
53
+ * Deliberately excludes the params [[Prototype]] from the golden diff: the
54
+ * prototype flips from `Object.prototype` to `null` for populated params by
55
+ * design (D8), so it is asserted as a forward invariant elsewhere, not baked
56
+ * into the byte-identical golden. Own-enumerable key OWNERSHIP and VALUES —
57
+ * the real behavioral contract — ARE captured here.
58
+ */
59
+ export interface SerializedMatch {
60
+ matched: boolean;
61
+ handlerTag: string | null;
62
+ params: Array<[string, string]>;
63
+ hasExecutor: boolean;
64
+ }
65
+
66
+ /** Run one probe against a built router and reduce it to the canonical shape. */
67
+ export function serializeMatch(router: Router, method: string, path: string): SerializedMatch {
68
+ const result = router.match(method as never, path);
69
+ if (!result) {
70
+ return { matched: false, handlerTag: null, params: [], hasExecutor: false };
71
+ }
72
+ const handler = result.handler as (RouteHandler & { tag?: string }) | undefined;
73
+ const params = Object.keys(result.params)
74
+ .sort()
75
+ .map((k) => [k, result.params[k]] as [string, string]);
76
+ return {
77
+ matched: true,
78
+ handlerTag: handler?.tag ?? null,
79
+ params,
80
+ hasExecutor: typeof result.executor === 'function',
81
+ };
82
+ }
83
+
84
+ /**
85
+ * The default route set: exercises static, nested static, single/nested params,
86
+ * backtracking, static-over-param-over-wildcard precedence, wildcard (incl.
87
+ * empty capture), and param + trailing wildcard. Case-insensitive, non-strict,
88
+ * decode on (framework defaults).
89
+ */
90
+ function buildDefault(): Router {
91
+ const r = createRouter();
92
+ r.get('/', h('root'));
93
+ r.get('/users', h('users'));
94
+ r.get('/users/me', h('users.me')); // static beats param
95
+ r.get('/users/:id', h('users.id'));
96
+ r.get('/a/b/c', h('a.b.c')); // nested static
97
+ r.get('/a/:x/c', h('a.x.c')); // backtracking target
98
+ r.get('/a/b/d', h('a.b.d'));
99
+ r.get('/a/:x/b/:y', h('a.x.b.y')); // nested params
100
+ r.get('/p/me', h('p.me')); // static > param > wildcard at one node
101
+ r.get('/p/:id', h('p.id'));
102
+ r.get('/p/*', h('p.star'));
103
+ r.get('/files/*', h('files.star')); // wildcard incl. empty capture
104
+ r.get('/g/:x/*', h('g.x.star')); // param + trailing wildcard
105
+ r.post('/users', h('users.post')); // method variety on a static path
106
+ return r;
107
+ }
108
+
109
+ function buildCaseSensitive(): Router {
110
+ const r = createRouter({ caseSensitive: true });
111
+ r.get('/Users/:id', h('cs.users.id'));
112
+ r.get('/Static', h('cs.static'));
113
+ return r;
114
+ }
115
+
116
+ function buildStrict(): Router {
117
+ const r = createRouter({ strict: true });
118
+ r.get('/strict', h('strict.static'));
119
+ r.get('/strict/:id', h('strict.id'));
120
+ return r;
121
+ }
122
+
123
+ function buildDecodeOff(): Router {
124
+ const r = createRouter({ decode: false });
125
+ r.get('/raw/:id', h('raw.id'));
126
+ r.get('/raw/*', h('raw.star'));
127
+ return r;
128
+ }
129
+
130
+ function buildAll(): Router {
131
+ const r = createRouter();
132
+ r.all('/any', h('any'));
133
+ return r;
134
+ }
135
+
136
+ function buildPrefix(): Router {
137
+ const r = createRouter({ prefix: '/api' });
138
+ r.get('/health', h('api.health'));
139
+ r.get('/items/:id', h('api.items.id'));
140
+ return r;
141
+ }
142
+
143
+ function buildMount(): Router {
144
+ const child = createRouter();
145
+ child.get('/x', h('mount.x'));
146
+ child.get('/:p', h('mount.p'));
147
+ const parent = createRouter();
148
+ parent.mount('/sub', child);
149
+ return parent;
150
+ }
151
+
152
+ function buildGroup(): Router {
153
+ const r = createRouter();
154
+ r.group('/g', (g) => {
155
+ g.get('/y', h('group.y'));
156
+ g.get('/:z', h('group.z'));
157
+ });
158
+ return r;
159
+ }
160
+
161
+ /**
162
+ * Backtracking-specific set. `/bt/:x/c` + `/bt/b/d` forces the walk to try the
163
+ * static `b` branch, fail at `c`, and fall back to the param branch. `/w/:x/deep`
164
+ * + `/w/*` is the stale-param guard: the param branch binds `:x`, fails deeper,
165
+ * backtracks, and the wildcard must match with NO leftover `x` binding.
166
+ */
167
+ function buildBacktrack(): Router {
168
+ const r = createRouter();
169
+ r.get('/bt/:x/c', h('bt.x.c'));
170
+ r.get('/bt/b/d', h('bt.b.d'));
171
+ r.get('/w/:x/deep', h('w.x.deep'));
172
+ r.get('/w/*', h('w.star'));
173
+ return r;
174
+ }
175
+
176
+ /** Every route set + its probe corpus. */
177
+ export const ROUTE_SETS: RouteSet[] = [
178
+ {
179
+ name: 'default',
180
+ build: buildDefault,
181
+ probes: [
182
+ { method: 'GET', path: '/' },
183
+ { method: 'GET', path: '/users' },
184
+ { method: 'GET', path: '/users/' }, // trailing slash (static, non-strict)
185
+ { method: 'GET', path: '/users/me' }, // static beats param
186
+ { method: 'GET', path: '/users/42' }, // param
187
+ { method: 'GET', path: '/users/42/' }, // trailing slash (param, non-strict)
188
+ { method: 'GET', path: '/Users/AbC' }, // non-ASCII-free cased; value keeps case
189
+ { method: 'GET', path: '/users/a%20b' }, // percent-decoded space
190
+ { method: 'GET', path: '/users/a%2Fb' }, // encoded slash stays in value
191
+ { method: 'GET', path: '/users/a%2Eb' }, // encoded dot stays in value
192
+ { method: 'GET', path: '/users/%zz' }, // malformed encoding → raw
193
+ { method: 'GET', path: '/users/%' }, // malformed → raw
194
+ { method: 'GET', path: '/users/%2' }, // truncated → raw
195
+ { method: 'GET', path: '/users/\u00dcrl' }, // non-ASCII uppercase param value
196
+ { method: 'GET', path: '/a/b/c' }, // nested static
197
+ { method: 'GET', path: '/a/b/c/' },
198
+ { method: 'GET', path: '/a/b/d' },
199
+ { method: 'GET', path: '/a/b/x' }, // backtrack: static b fails at x → param :x=b
200
+ { method: 'GET', path: '/a/1/b/2' }, // nested params
201
+ { method: 'GET', path: '/p/me' }, // static
202
+ { method: 'GET', path: '/p/other' }, // param
203
+ { method: 'GET', path: '/p/a/b/c' }, // wildcard
204
+ { method: 'GET', path: '/files/A/b/c' }, // wildcard original-case remainder
205
+ { method: 'GET', path: '/files' }, // wildcard empty capture
206
+ { method: 'GET', path: '/files/' }, // wildcard empty capture (trailing)
207
+ { method: 'GET', path: '/g/1/b/c' }, // param + trailing wildcard
208
+ { method: 'GET', path: '/users?q=1' }, // query stripped
209
+ { method: 'GET', path: '/users/42?x=y' }, // query stripped, param
210
+ { method: 'GET', path: '//a//b//c' }, // repeated-slash collapse
211
+ { method: 'GET', path: '///' }, // all-slash
212
+ { method: 'GET', path: '/nope' }, // miss
213
+ { method: 'POST', path: '/users' }, // method variety
214
+ { method: 'POST', path: '/users/me' }, // method-miss on known path
215
+ { method: 'DELETE', path: '/users/42' }, // method-miss on param path
216
+ { method: 'GET', path: `/deep/${Array.from({ length: 200 }, (_, i) => `s${i}`).join('/')}` }, // deep miss
217
+ ],
218
+ },
219
+ {
220
+ name: 'caseSensitive',
221
+ build: buildCaseSensitive,
222
+ probes: [
223
+ { method: 'GET', path: '/Users/AbC' },
224
+ { method: 'GET', path: '/users/abc' }, // lowercase miss (case-sensitive)
225
+ { method: 'GET', path: '/Static' },
226
+ { method: 'GET', path: '/static' }, // miss
227
+ ],
228
+ },
229
+ {
230
+ name: 'strict',
231
+ build: buildStrict,
232
+ probes: [
233
+ { method: 'GET', path: '/strict' },
234
+ { method: 'GET', path: '/strict/' }, // strict: trailing slash NOT stripped → behavior as today
235
+ { method: 'GET', path: '/strict/42' },
236
+ { method: 'GET', path: '/strict/42/' },
237
+ ],
238
+ },
239
+ {
240
+ name: 'decodeOff',
241
+ build: buildDecodeOff,
242
+ probes: [
243
+ { method: 'GET', path: '/raw/a%20b' }, // raw, not decoded
244
+ { method: 'GET', path: '/raw/a%2Fb' },
245
+ { method: 'GET', path: '/raw/x/y/z' }, // wildcard raw remainder
246
+ ],
247
+ },
248
+ {
249
+ name: 'all',
250
+ build: buildAll,
251
+ probes: [
252
+ { method: 'GET', path: '/any' },
253
+ { method: 'POST', path: '/any' },
254
+ { method: 'PUT', path: '/any' },
255
+ { method: 'DELETE', path: '/any' },
256
+ { method: 'PATCH', path: '/any' },
257
+ { method: 'HEAD', path: '/any' },
258
+ { method: 'OPTIONS', path: '/any' },
259
+ ],
260
+ },
261
+ {
262
+ name: 'prefix',
263
+ build: buildPrefix,
264
+ probes: [
265
+ { method: 'GET', path: '/api/health' },
266
+ { method: 'GET', path: '/health' }, // miss (no prefix)
267
+ { method: 'GET', path: '/api/items/7' },
268
+ ],
269
+ },
270
+ {
271
+ name: 'mount',
272
+ build: buildMount,
273
+ probes: [
274
+ { method: 'GET', path: '/sub/x' }, // copied static
275
+ { method: 'GET', path: '/sub/other' }, // copied param
276
+ ],
277
+ },
278
+ {
279
+ name: 'group',
280
+ build: buildGroup,
281
+ probes: [
282
+ { method: 'GET', path: '/g/y' }, // group static
283
+ { method: 'GET', path: '/g/other' }, // group param
284
+ ],
285
+ },
286
+ {
287
+ name: 'backtrack',
288
+ build: buildBacktrack,
289
+ probes: [
290
+ { method: 'GET', path: '/bt/b/c' }, // static b fails at c → param :x=b matches
291
+ { method: 'GET', path: '/bt/b/d' }, // static b/d
292
+ { method: 'GET', path: '/bt/z/c' }, // param directly (no static z)
293
+ { method: 'GET', path: '/w/foo/other' }, // param :x binds, fails, backtracks → wildcard, NO stale x
294
+ { method: 'GET', path: '/w/foo/deep' }, // param :x=foo matches deep
295
+ { method: 'GET', path: '/w/a/b/c' }, // wildcard multi-segment
296
+ ],
297
+ },
298
+ ];
299
+
300
+ /**
301
+ * Compute the full golden map: `"<set> <METHOD> <path>" -> SerializedMatch`.
302
+ * Deterministic and side-effect-free (each route set is freshly built).
303
+ */
304
+ export function computeGolden(): Record<string, SerializedMatch> {
305
+ const out: Record<string, SerializedMatch> = {};
306
+ for (const set of ROUTE_SETS) {
307
+ const router = set.build();
308
+ for (const probe of set.probes) {
309
+ const key = `${set.name} ${probe.method} ${probe.path}`;
310
+ out[key] = serializeMatch(router, probe.method, probe.path);
311
+ }
312
+ }
313
+ return out;
314
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @nextrush/router - Differential / characterization harness
3
+ *
4
+ * Golden-master regression contract for `router-match-path-allocation-trim`.
5
+ * `match-golden.json` was captured from the PRE-CHANGE matcher; this asserts
6
+ * the current matcher reproduces it byte-for-byte across the broad corpus in
7
+ * `helpers/differential-corpus.ts`. Any behavioral drift on a preserved case
8
+ * (route resolution, own-param entries/values, executor presence) fails here.
9
+ *
10
+ * Regenerate intentionally (only when a delta is understood and approved):
11
+ * GEN_GOLDEN=1 pnpm --filter @nextrush/router test match-differential
12
+ *
13
+ * Intentional deltas (null-prototype params; `__proto__`/`constructor`/
14
+ * `prototype` param names) are covered by dedicated forward scenarios, NOT by
15
+ * regenerating this golden — see `match-safety.test.ts`.
16
+ */
17
+
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+ import { describe, expect, it } from 'vitest';
22
+ import { computeGolden, type SerializedMatch } from './helpers/differential-corpus';
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const GOLDEN_PATH = join(HERE, 'fixtures', 'match-golden.json');
26
+
27
+ describe('Router match — differential / characterization harness', () => {
28
+ const computed = computeGolden();
29
+
30
+ if (process.env.GEN_GOLDEN) {
31
+ writeFileSync(GOLDEN_PATH, `${JSON.stringify(computed, null, 2)}\n`, 'utf-8');
32
+ }
33
+
34
+ const golden = JSON.parse(readFileSync(GOLDEN_PATH, 'utf-8')) as Record<string, SerializedMatch>;
35
+
36
+ it('reproduces the pre-change matcher output for every corpus probe', () => {
37
+ expect(computed).toEqual(golden);
38
+ });
39
+
40
+ it('covers the full corpus (no probe silently dropped)', () => {
41
+ const computedKeys = Object.keys(computed).sort();
42
+ const goldenKeys = Object.keys(golden).sort();
43
+ expect(computedKeys).toEqual(goldenKeys);
44
+ expect(computedKeys.length).toBeGreaterThan(50);
45
+ });
46
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @nextrush/router - HP-18 deopt-pattern regression guard
3
+ *
4
+ * Static source guard for OpenSpec change `router-context-final-cleanup` (HP-18).
5
+ * The P2 rewrite (`router-match-path-allocation-trim`) removed the two V8
6
+ * deopt/allocation patterns from the router match path:
7
+ *
8
+ * - the eager-bind + backtrack `Reflect.deleteProperty(params, name)` used to
9
+ * undo a stale param binding, and
10
+ * - the `Object.keys(...)`-based post-match loop that materialized params.
11
+ *
12
+ * Both are gone today (params are bound via deferred parallel stacks and a
13
+ * bind-count loop). This guard fails if EITHER is reintroduced into the router
14
+ * match sources, so a future edit cannot silently regress the hot path.
15
+ *
16
+ * WHY a source scan rather than a behavioral spy: the invariant is "this
17
+ * mechanism never comes back", not "one probe doesn't trigger it". A spy
18
+ * (see `match-safety.test.ts`) proves a specific match doesn't call them; this
19
+ * proves the identifiers are absent from the source entirely — a stronger,
20
+ * edit-resistant contract. Comments are stripped first because the source prose
21
+ * deliberately NAMES these removed patterns to explain why they're gone.
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { describe, expect, it } from 'vitest';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ const SRC = join(HERE, '..');
31
+
32
+ /**
33
+ * The files that make up the router match path (design.md / proposal). `find-node.ts`
34
+ * holds the method-agnostic walk split out of `matching.ts` during the HP-17
35
+ * rewrite, so it is guarded alongside the two match sources.
36
+ */
37
+ const MATCH_SOURCES = ['matching.ts', 'match-route.ts', 'find-node.ts'] as const;
38
+
39
+ /**
40
+ * Remove block (`/* ... *\/`) and line (`// ...`) comments so the guard scans
41
+ * only executable code — the source comments intentionally reference the very
42
+ * patterns this guard forbids, to document that they were removed.
43
+ */
44
+ function stripComments(source: string): string {
45
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
46
+ }
47
+
48
+ function readCode(file: string): string {
49
+ return stripComments(readFileSync(join(SRC, file), 'utf-8'));
50
+ }
51
+
52
+ describe('HP-18 — router match path stays free of the removed deopt patterns', () => {
53
+ it.each(MATCH_SOURCES)('%s contains no backtrack Reflect.deleteProperty', (file) => {
54
+ expect(readCode(file)).not.toContain('Reflect.deleteProperty');
55
+ });
56
+
57
+ it.each(MATCH_SOURCES)('%s contains no Object.keys post-match loop', (file) => {
58
+ expect(readCode(file)).not.toContain('Object.keys');
59
+ });
60
+
61
+ it('actually scanned real, non-empty match sources (guard is not vacuous)', () => {
62
+ for (const file of MATCH_SOURCES) {
63
+ const code = readCode(file);
64
+ // Sanity: the file exists, has real content, and the comment-stripper
65
+ // left the actual matcher code (so the negative assertions above are
66
+ // scanning something, not an empty string).
67
+ expect(code.length).toBeGreaterThan(200);
68
+ expect(code).toContain('export function');
69
+ }
70
+ });
71
+ });