From ac3e130a4d0c72581ace7941a56c3ce78361d5eb Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 19:41:56 -0700 Subject: [PATCH 1/3] test(packs): express 4 to 5 ground truth from four public upgrades (UPT-55) Fixed before the first score: netlify/cli#7927, bcgov/tenant-management-system#699, amiantos/lurker#147, openhab/openhab-cloud#550. Drafted with packs:truth and reviewed by hand. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .../core/src/packs/express/ground-truth.json | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 packages/core/src/packs/express/ground-truth.json diff --git a/packages/core/src/packs/express/ground-truth.json b/packages/core/src/packs/express/ground-truth.json new file mode 100644 index 0000000..be18cde --- /dev/null +++ b/packages/core/src/packs/express/ground-truth.json @@ -0,0 +1,590 @@ +{ + "$comment": "Public repositories at the commit before they upgraded express from >=4 <5 to >=5 <6, and every finding the pack must report there. Drafted with `pnpm packs:truth` (compiler + upgrade diff) and reviewed by hand on 2026-10-14, before the first score. See RESEARCH.md and docs/packs.md, \"Ground truth\".", + "package": "express", + "repos": [ + { + "repo": "netlify/cli", + "commit": "63b6f1cbca316fa6c338d59e5b5053a11de25c6c", + "migration": "https://github.com/netlify/cli/pull/7927", + "from": "4.22.1", + "to": "5.2.1", + "with": { + "@types/express": "5.0.6", + "http-proxy-middleware": "3.0.5" + }, + "why": "CLI, one package, npm. Upgrade 6414c142 renames the unnamed `*` route wildcards path-to-regexp 8 rejects (source and test servers, and route data of a mock API) and drops two test assertions on an empty `req.body`. The compiler reports nothing new. http-proxy-middleware 2 to 3 in the same commit is that package's own change and is not a site.", + "findings": [ + { + "file": "src/lib/functions/server.ts", + "line": 296, + "rule": "route-syntax", + "note": "`app.all('*', \u2026)` becomes `'{*splat}'`" + }, + { + "file": "tests/integration/commands/blobs/blobs.test.ts", + "line": 51, + "rule": "route-syntax", + "note": "route data `path: 'blobs/*'`, registered by the mock API's `app[method](path)`, becomes `'blobs/{*splat}'`" + }, + { + "file": "tests/integration/commands/dev/dev.test.ts", + "line": 236, + "rule": "req-body-undefined", + "note": "an assertion that a GET request's body is `{}` is removed: without a parser `req.body` is undefined in Express 5" + }, + { + "file": "tests/integration/commands/dev/dev.test.ts", + "line": 357, + "rule": "req-body-undefined", + "note": "same assertion, second test" + }, + { + "file": "tests/integration/commands/dev/v2-api.test.ts", + "line": 16, + "rule": "route-syntax", + "note": "route data `'sites/*/service-instances'` becomes `'sites/:siteId/service-instances'`" + }, + { + "file": "tests/integration/commands/dev/v2-api.test.ts", + "line": 17, + "rule": "route-syntax", + "note": "route data `'sites/*'` becomes `'sites/{*splat}'`" + }, + { + "file": "tests/integration/utils/external-server.ts", + "line": 8, + "rule": "route-syntax", + "note": "`app.all('*', \u2026)` becomes `'{*splat}'`" + }, + { + "file": "tests/integration/utils/mock-api-vitest.ts", + "line": 87, + "rule": "route-syntax", + "note": "`app.all('*', \u2026)` becomes `'{*splat}'`" + }, + { + "file": "tests/integration/utils/mock-api.ts", + "line": 93, + "rule": "route-syntax", + "note": "`app.all('*', \u2026)` becomes `'{*splat}'`" + }, + { + "file": "tests/unit/lib/functions/server.test.ts", + "line": 45, + "rule": "route-syntax", + "note": "the path `'*'` of a multi-line `app.all(` becomes `'{*splat}'`" + } + ] + }, + { + "repo": "bcgov/tenant-management-system", + "commit": "6fd01755043a3db9ed0577ffa6b1b4a184ae670f", + "migration": "https://github.com/bcgov/tenant-management-system/pull/699", + "directory": "backend", + "from": "4.22.2", + "to": "5.2.1", + "with": { + "@types/express": "5.0.6" + }, + "why": "API in `backend/`, npm lockfile beside it. Upgrade 74470563 casts every `req.params.x` the compiler now types `string | string[]` (@types/express-serve-static-core 5.1) and names one wildcard route.", + "findings": [ + { + "file": "src/common/auth.mw.ts", + "line": 105, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/common/tenant-access.mw.ts", + "line": 11, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/routes/routes.ts", + "line": 24, + "rule": "route-syntax", + "note": "`app.get('/v1/swagger-resources/*', \u2026)` becomes `/*rest`: an unnamed wildcard throws in path-to-regexp 8" + }, + { + "file": "src/services/assertion.service.ts", + "line": 76, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 29, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 96, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 97, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 144, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 162, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 163, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 264, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 265, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 266, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 298, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 299, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 313, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 332, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 333, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 347, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 348, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 373, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 374, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 396, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/group.service.ts", + "line": 397, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 50, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 70, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 86, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 87, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 105, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 121, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/shared-service.service.ts", + "line": 150, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant-request.service.ts", + "line": 104, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant-request.service.ts", + "line": 176, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 76, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 127, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 168, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 182, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 210, + "rule": "params-string-array", + "note": "`const { tenantId, tenantUserId } = req.params` becomes two typed consts; the compiler reports the uses at 214 and 215" + }, + { + "file": "src/services/tenant.service.ts", + "line": 214, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 215, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 242, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 254, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 255, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 267, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 268, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 269, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 282, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 305, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 343, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 344, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 388, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 389, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 423, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + }, + { + "file": "src/services/tenant.service.ts", + "line": 424, + "rule": "params-string-array", + "note": "TS2322 req.params value is string | string[]; the upgrade casts it to string" + } + ] + }, + { + "repo": "amiantos/lurker", + "commit": "f7a0f3dac77570ec3fd93a487ccf0aaf2f6ca5db", + "migration": "https://github.com/amiantos/lurker/pull/147", + "from": "4.22.2", + "to": "5.2.1", + "with": { + "@types/express": "5.0.6" + }, + "why": "Server at the root, npm. Upgrade 84d7bb12 types route params (`Request<{ token: string }>`, a generic `asyncHandler`) where the compiler now reports `string | string[]`.", + "findings": [ + { + "file": "integrations/autonotes/server.ts", + "line": 176, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/admin.ts", + "line": 107, + "rule": "params-string-array", + "note": "the handler is annotated `Request<{ \u2026 }>` so its params are typed strings; the compiler reports the uses below" + }, + { + "file": "server/routes/admin.ts", + "line": 115, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/admin.ts", + "line": 124, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/auth.ts", + "line": 244, + "rule": "params-string-array", + "note": "the handler is annotated `Request<{ \u2026 }>` so its params are typed strings; the compiler reports the uses below" + }, + { + "file": "server/routes/auth.ts", + "line": 245, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/auth.ts", + "line": 259, + "rule": "params-string-array", + "note": "the handler is annotated `Request<{ \u2026 }>` so its params are typed strings; the compiler reports the uses below" + }, + { + "file": "server/routes/auth.ts", + "line": 260, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/auth.ts", + "line": 306, + "rule": "params-string-array", + "note": "the handler is annotated `Request<{ \u2026 }>` so its params are typed strings; the compiler reports the uses below" + }, + { + "file": "server/routes/auth.ts", + "line": 317, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/auth.ts", + "line": 352, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/auth.ts", + "line": 379, + "rule": "params-string-array", + "note": "the handler is annotated `Request<{ \u2026 }>` so its params are typed strings; the compiler reports the uses below" + }, + { + "file": "server/routes/auth.ts", + "line": 380, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/auth.ts", + "line": 402, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/routes/settings.ts", + "line": 35, + "rule": "params-string-array", + "note": "the handler is annotated `Request<{ \u2026 }>` so its params are typed strings; the compiler reports the uses below" + }, + { + "file": "server/routes/settings.ts", + "line": 36, + "rule": "params-string-array", + "note": "TS2345 req.params value is string | string[]" + }, + { + "file": "server/utils/asyncHandler.ts", + "line": 10, + "rule": "params-string-array", + "note": "`AsyncRouteHandler` becomes generic over the params type" + }, + { + "file": "server/utils/asyncHandler.ts", + "line": 12, + "rule": "params-string-array", + "note": "`asyncHandler` passes the params type through (12-13, one change)" + } + ] + }, + { + "repo": "openhab/openhab-cloud", + "commit": "5a1866efe1c038e287d2d59091951e1912db12f8", + "migration": "https://github.com/openhab/openhab-cloud/pull/550", + "from": "4.22.1", + "to": "5.2.1", + "with": { + "@types/express": "5.0.6", + "body-parser": "2.2.2", + "serve-static": "2.2.1" + }, + "why": "Server at the root, npm. Upgrade b25ee437 rewrites wildcard routes for path-to-regexp 8 (single routes and a list registered in a loop), replaces csurf (typed for Express 4) and moves body-parser and serve-static to 2.x with express.", + "findings": [ + { + "file": "src/app.ts", + "line": 235, + "rule": "generic", + "note": "TS2345 `csrf(req, res, next)`: csurf's Request type (@types/express 4) no longer accepts Express 5's; the upgrade replaces csurf with csrf-sync" + }, + { + "file": "src/routes/index.ts", + "line": 668, + "rule": "route-syntax", + "note": "`router.all('/addAndroidRegistration*', \u2026)` becomes `\u2026{*path}`" + }, + { + "file": "src/routes/index.ts", + "line": 669, + "rule": "route-syntax", + "note": "`router.all('/addIosRegistration*', \u2026)` becomes `\u2026{*path}`" + }, + { + "file": "src/routes/index.ts", + "line": 678, + "rule": "route-syntax", + "note": "`router.all('/ws/*', \u2026)` becomes `'/ws/{*path}'`" + }, + { + "file": "src/routes/index.ts", + "line": 681, + "rule": "route-syntax", + "note": "the `proxyPaths` list (681-684), registered by one `for\u2026of` over `router.all(path, \u2026)`: every `*` becomes `{*path}`, `/jquery.*` becomes `/jquery.:ext`" + }, + { + "file": "src/routes/middleware.ts", + "line": 82, + "rule": "generic", + "note": "`createMiddleware` gains an explicit `RouteMiddleware` return type, \"to resolve type portability issue with Express 5's bundled types\" (commit message); not reported by a no-emit compile" + } + ] + } + ] +} From 3254485afd5d552623bd2cf64c906e4064c2d4fe Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 19:55:02 -0700 Subject: [PATCH 2/3] feat(packs): candidate express 4 to 5 pack (UPT-55) Route strings path-to-regexp 8 rejects are detected on calls of an Express app or router this file binds, including const lists and for...of loops; req.params widened to string | string[], req.param, res.sendfile, express.static.mime and express.query are claimed from the compiler; redirect(url, status), app.param(fn), redirect('back') and send() are detected in the source; runtime defaults go to decisions. No rule rewrites: the codemods' rewrites are unsafe or unexercised (RESEARCH.md). Scored against four public upgrades (netlify/cli, bcgov, lurker, openhab): breaking precision 100%, recall 85% (73 TP, 13 FN). Candidate: pack test fails because two upgrades moved packages check leaves in place (http-proxy-middleware, body-parser, serve-static; a verdaccio blocker). With the pack in use, check no longer adds its text-matched express 5 runtime findings for the package. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .changeset/express-five.md | 5 + packages/core/src/check/check.ts | 7 +- packages/core/src/packs/express/RESEARCH.md | 194 +++++++++ packages/core/src/packs/express/behavior.ts | 85 ++++ packages/core/src/packs/express/detect.ts | 368 ++++++++++++++++++ .../core/src/packs/express/express.test.ts | 115 ++++++ .../packs/express/fixtures/responses/after.ts | 26 ++ .../express/fixtures/responses/before.ts | 26 ++ .../express/fixtures/route-syntax/after.ts | 54 +++ .../express/fixtures/route-syntax/before.ts | 54 +++ packages/core/src/packs/express/index.ts | 141 +++++++ .../core/src/packs/express/verification.json | 19 + packages/core/src/packs/registry.ts | 3 + 13 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 .changeset/express-five.md create mode 100644 packages/core/src/packs/express/RESEARCH.md create mode 100644 packages/core/src/packs/express/behavior.ts create mode 100644 packages/core/src/packs/express/detect.ts create mode 100644 packages/core/src/packs/express/express.test.ts create mode 100644 packages/core/src/packs/express/fixtures/responses/after.ts create mode 100644 packages/core/src/packs/express/fixtures/responses/before.ts create mode 100644 packages/core/src/packs/express/fixtures/route-syntax/after.ts create mode 100644 packages/core/src/packs/express/fixtures/route-syntax/before.ts create mode 100644 packages/core/src/packs/express/index.ts create mode 100644 packages/core/src/packs/express/verification.json diff --git a/.changeset/express-five.md b/.changeset/express-five.md new file mode 100644 index 0000000..4b639cd --- /dev/null +++ b/.changeset/express-five.md @@ -0,0 +1,5 @@ +--- +"uptide": minor +--- + +Add a candidate express 4 to 5 migration pack. Route strings that path-to-regexp 8 rejects or reads differently are found on calls of an Express app or router (including lists registered in a loop); `req.params` values widened to `string | string[]`, `req.param`, `res.sendfile`, `express.static.mime` and `express.query` are claimed from the compiler; `res.redirect(url, status)`, `app.param(fn)`, `res.redirect('back')` and `res.send()` are found in the source; runtime defaults (async errors, `req.body`, the query parser, `urlencoded`, dotfiles, Node 18) go to decisions. No rule rewrites. With the pack in use, `check` no longer adds its text-matched express 5 runtime findings. diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index e124f77..a2f0c47 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -2125,8 +2125,11 @@ async function checkGroup( const findings: Finding[] = []; for (const p of prepared) { - // Curated behaviour changes the declarations do not show (express 5 routing). - const usageFiles = [...new Set(p.scan.usages.map((u) => u.file))]; + // Curated behaviour changes the declarations do not show (express 5 routing); a pack in + // use for the package owns them, with detection that knows the receiver. + const usageFiles = packsOf(ctx.opts).some((pack) => pack.name === p.name) + ? [] + : [...new Set(p.scan.usages.map((u) => u.file))]; findings.push( ...runtimeChangeFindings( { package: p.name, from: p.installedVersion, to: p.target }, diff --git a/packages/core/src/packs/express/RESEARCH.md b/packages/core/src/packs/express/RESEARCH.md new file mode 100644 index 0000000..f91faa1 --- /dev/null +++ b/packages/core/src/packs/express/RESEARCH.md @@ -0,0 +1,194 @@ +# express 4 → 5: evidence and verification limits + +Read date for every source below: **2026-10-14**. Scope: any 4.x release to any 5.x release. +The latest at the read date are express **4.22.3** (2026-09-14, `latest-4`) and **5.3.0** +(2026-10-09, tag `cf6722bf89a86905281d9160b6482e08166b5ef0`), the pack's `defaultTarget`. The +ground truth upgraded to 5.2.1. The map below is built from the official guide, the changelogs, +the codemods' source and the published declarations, not from memory. + +## Source ledger + +- Migration guide, `src/content/pages/en/guide/migrating-5.mdx` at expressjs.com + [`472119366bbffc206e0deba32277d607cf9f6e13`](https://github.com/expressjs/expressjs.com/blob/472119366bbffc206e0deba32277d607cf9f6e13/src/content/pages/en/guide/migrating-5.mdx) + (2026-06-23, the last change to the file; the older `en/guide/migrating-5.md`, last at + `de7293572d50cb6dca6939ca8838d1f01eeb2807`, is a subset). +- [History.md at v5.3.0](https://github.com/expressjs/express/blob/cf6722bf89a86905281d9160b6482e08166b5ef0/History.md): + 5.0.0-alpha.1 to 5.3.0. Tags: 5.0.0 `344b022f`, 5.0.1 `d14b2de7`, 5.1.0 `cd7d4397`, 5.2.0 + `4007ad10`, 5.2.1 `dbac741a`, 4.22.3 `899b5249`. `engines.node` is `>= 18` in 5.3.0. +- [path-to-regexp Readme at v8.4.2](https://github.com/pillarjs/path-to-regexp/blob/cbf30259e6d34d6135f9e7dbaa3371e7188f9936/Readme.md) + (Express 5 uses router 2.2.0 `e6d6b609`, which depends on `path-to-regexp ^8.0.0`; Express 4.22.3 + uses `~0.1.13`). +- body-parser HISTORY at [2.3.0 `d0f2ace6`](https://github.com/expressjs/body-parser/blob/d0f2ace6c74769da7d19b8661b9a01c01bdb0bf7/HISTORY.md) + (1.20.8 `5c08c200`); serve-static 2.2.1 `76312e12`; send 0.19.2 `34ba03b7` and 1.2.1 `096a6148`. +- Codemods, **read, never run**: expressjs/codemod at + [`2f7f1e5f31ebab6e840957b0cf171a014ad7a3b9`](https://github.com/expressjs/codemod/tree/2f7f1e5f31ebab6e840957b0cf171a014ad7a3b9/codemods) + (each transform byte-identical to its `v1.0.0@` tag; the recipe is v1.1.0). The npm package + `@expressjs/codemod` 0.0.5 (`df7e3b3c`) is deprecated on npm in favor of these. +- Published declarations (npm tarballs, nothing executed): @types/express 4.17.25 and 5.0.6, + @types/express-serve-static-core 4.19.9 and 5.1.3, @types/serve-static 1.15.10 and 2.2.0, @types/send + 0.17.6 and 1.2.1. Compiler visibility below was measured with this checkout's TypeScript 5.9.3 + (`--strict --noEmit`) on a probe file against each set. + +## Classified changes + +**A** assisted rule (guide for the agent), **B** behavior note, **N** no action. There is no +mechanical rule (below). "Already v4" means @types/express 4 already rejected the call, so a +TypeScript project cannot have it and `check` never reports it as new. + +| Change | Class and treatment | Compiler | Source | +| --- | --- | --- | --- | +| Route strings on path-to-regexp 8: unnamed `*`, `:name?`, `:name*`, `:name+`, `(`, `)`, `[`, `]`, `?`, `+`, `!` reserved, `{}` groups, inline regex params; throws at registration | A `route-syntax` (detect): the first argument of `all/use/route/` on an app or router this file binds (`express()`, `Router()`, a parameter or property typed `Express`/`Application`/`Router`/`IRouter`, `this.app`), a `const` path list, or a `for…of` over one; one site per registration | No (strings) | guide, ptr Readme | +| `req.params` values typed `string \| string[]` (essc ≥ 5.1.1) | A `params-string-array` (message `'string \| string[]' is not assignable`): the guide types the params, never casts | TS2322/TS2345, new | essc 5.1.3 d.ts | +| `req.param(name)` removed | A `req-param-removed` | TS2551, new | guide, History alpha.2 | +| `res.sendfile` removed | A `sendfile-renamed` | TS2551, new | guide, History alpha.1 | +| `res.redirect(url, status)` removed | A `redirect-order` (detect: a second argument that is a number on an Express response) | TS2345, new, but the message is generic | guide, History alpha.6, 5.2.0 | +| `app.param(fn)` / `router.param(fn)` removed; throws at startup | A `param-callback-removed` (detect: one argument on a known app or router) | TS2554, new, generic message | guide, router 2.2.0 | +| `express.static.mime` removed | A `static-mime-removed` | TS2339, new | guide, serve-static HISTORY | +| `express.query()` removed | A `query-middleware-removed` (not in the guide) | TS2339, new | History alpha.1 | +| `res.redirect('back')`, `res.location('back')` removed | B `redirect-back` (finding, breaking, detect on Express responses) | No | guide, History 5.0.0 | +| `res.send()` sends the number as a body with status 200 | B `send-status-number` (finding, breaking, detect on Express responses) | No (`body: any`) | guide, response.js at v5.3.0 | +| Rejected promises reach error middleware | B `async-errors` (decision) | No | guide, router layer.js | +| `req.body` undefined without a parser | B `req-body-undefined` (decision, test follow-up) | No | guide, body-parser 2.0.0 | +| Query parser default `extended` → `simple`; `req.query` a getter | B `query-parser` (decision) | No | guide, application.js | +| `express.urlencoded()` `extended` default → false | B `urlencoded-extended` (decision) | No | guide, body-parser 2.0.0 | +| `dotfiles` default: send 1.x ignores every dot segment; v4 served dot-directories | B `static-dotfiles` (decision) | No | send 0.19.2 vs 1.2.1 source | +| Node ≥ 18 | B `node-minimum` (decision, only when `engines.node` allows older) | No | package.json 5.3.0 | +| `req.host` keeps the port, `res.status` validation, `res.clearCookie` ignores maxAge/expires, `res.vary()` without field throws, `.js` MIME | B `runtime-defaults` (decision) | No | guide, History 5.0.0 | +| `app.del`, singular `req.accepts*`, `res.json/jsonp/send(body, status)`, `hidden`/`from` options | N: already v4 in the types; not new for TypeScript | Already v4 | guide | +| `app.router` is a Router, `app.listen` passes errors to its callback, `res.render` always async, debug namespaces, body-parser 2.3.0 `limit` validation | N: no site in the ground truth; the listen and router changes relax or retype rather than break most code | Mostly no | guide, History | + +**No rule rewrites.** The codemods were read to decide which rewrites are safe whatever the +surrounding code; none is: + +- `route-del-to-delete` and `status-send-order` do not check the receiver (`redis.del('k')`, + `ws.send(1)`), and `status-send-order` drops arguments past the second. +- `explicit-request-params` reads only `req.params`, losing the body and query values `req.param` + also returned. +- `static-dotfiles` / `sendfile-options` add `dotfiles: 'allow'`, which serves dotfiles (`.env`) + that Express 4 hid. +- Route strings have no rewrite in any codemod. The guide's mappings change what handlers read + (`req.params[0]` becomes `req.params.splat`, an array), and the real upgrades chose different + names and forms (`{*splat}`, `*rest`, `{*path}`, `:siteId`), so the path is the owner's choice. +- `camelcase-sendfile` and `redirect-arg-order` are close to safe, but no ground-truth repository + has a site for them: under UPT-48 an unexercised rewrite would run as assisted anyway. + +`requiresBuild` is not set: no ground-truth failure appeared only in a build. Route errors throw at +startup, which tests that start the app see. + +Companions: none declared. `@types/express` moves with the lead as `check` pairs it already (every +scored repository shows `with @types/express … → 5.0.6`). The guide names no other package that +must move. + +## Independent ground truth + +Four public repositories, each with its own npm lockfile. Each parent was fetched shallow at its +pinned commit and installed from its lockfile with lifecycle scripts off; nothing from a +repository was built, tested or run, and nothing was pushed, commented or opened on any of them. +Expectations were drafted with `pnpm packs:truth` (each repository's own TypeScript, express 5.2.1 +and @types/express 5.0.6 linked as an install would) and the merged upgrade diff, reviewed by hand, +and committed (`ab94e71`) **before the first score**. They were not changed after scoring. + +| Repository | Parent | Merged upgrade | express | Expected | +| --- | --- | --- | --- | --- | +| [netlify/cli](https://github.com/netlify/cli/pull/7927) | `63b6f1cbca316fa6c338d59e5b5053a11de25c6c` | `6414c1424b7b0a6e8418d25ffd1e8f92d035fd69` | 4.22.1 → 5.2.1 | 8 route sites (5 `app.all('*')`, 3 route paths held as test data), 2 test assertions on an empty `req.body` | +| [bcgov/tenant-management-system](https://github.com/bcgov/tenant-management-system/pull/699) (`backend/`) | `6fd01755043a3db9ed0577ffa6b1b4a184ae670f` | `744705634f3f07fc1a180befd8964f61b56b8075` | 4.22.2 → 5.2.1 | 52 new `string \| string[]` diagnostics, the destructure the upgrade rewrote, 1 wildcard route | +| [amiantos/lurker](https://github.com/amiantos/lurker/pull/147) | `f7a0f3dac77570ec3fd93a487ccf0aaf2f6ca5db` | `84d7bb12bb65067352465ec778facf91a8498e2d` | 4.22.2 → 5.2.1 | 10 new `string \| string[]` diagnostics, 8 handler and wrapper annotations the upgrade added | +| [openhab/openhab-cloud](https://github.com/openhab/openhab-cloud/pull/550) | `5a1866efe1c038e287d2d59091951e1912db12f8` | `b25ee4371bbd4e561aed583b7e2b45c88e550eb0` | 4.22.1 → 5.2.1 | 4 route registrations (one a list in a loop), csurf's Request type (TS2345), an explicit return type for declaration portability | + +88 expected sites, 86 of them breaking (UPT-48 proposes a minimum of 5). + +Site convention, fixed before scoring: one site per route registration (each `router.all(...)` +statement, each `{ path }` entry of a mock API); a list registered by one loop is one site at its +first path. Comment-only lines and other packages' changes (http-proxy-middleware 3's options in +netlify, the csurf import line in openhab) are not sites. Where an upgrade fixed the params type +on the handler (`Request<{ token: string }>`) rather than at the reported use, both the +annotation and the compiler's line are sites. + +`with`, from each upgrade commit's lockfile: @types/express 5.0.6 everywhere; netlify also moved +http-proxy-middleware 2.0.9 → 3.0.5 (its 2.x has an optional peer `@types/express ^4`); openhab +also moved body-parser 1 → 2.2.2 and serve-static 1 → 2.2.1 (neither depends on express; the +repository chose to move them). + +### Screened out + +Searched read-only (blobless clones, `git log`/`show`, `gh search`): + +- **snapshot-labs/stamp#711** (yarn 1; inline regex params, `req.body ?? {}`, query parser, listen + callback): its `engines.node` is `>=24.15` and the frozen install refuses Node 22, which the Packs + CI job uses. The widest spread of changes; usable once CI runs Node 24. +- opengovsg/GoGovSG#2545: 391 unrelated files in the merge. pvince/omg-acurite-filter#12: @types + stays 4. 4ssh1/url-shortener-api#1: already on 5. SAP/spartacus#21063: a schematic, not an upgrade. +- Workspace packages with only a root lockfile (unsupported by the ground-truth installer): + event-driven-io/emmett#380, cedarjs/cedar#2583, 0xPolygon/apps-team-ts-template#11, + rocapellino/pokedex#491, ericfaris/vntypl8s#15, ericfaris/setlist#9. +- JavaScript-only route changes: ministryofjustice/offender-categorisation#1051, hardytool/server#365. +- Mixed with large unrelated refactors, or lockfile-only: misunacademy/misun-academy-server#3, + JoA-MoS/garage#226, PeteLeeds/Personal-Chart-Project#30, mpppk/mono#288. +- Pure bumps kept as possible negative controls, not scored: graycoreio/daffodil#4666, + ericfaris/meal-mate#23, gavdilabs/cap-mcp-plugin#146. + +## Measured pack test + +`pnpm uptide pack test express --write` with per-pack caches (`UPTIDE_GROUND_TRUTH_CACHE`, +`UPTIDE_CACHE_DIR`): **candidate**, 4 public repositories, and **the run fails** (below). + +| Rule / scoring group | Precision | Recall | TP | FP | FN | +| --- | ---: | ---: | ---: | ---: | ---: | +| params-string-array | 100% | 87% | 62 | 0 | 9 | +| route-syntax | 100% | 77% | 10 | 0 | 3 | +| generic | 100% | 50% | 1 | 0 | 1 | +| req-body-undefined | n/a | 0% | 0 | 0 | 2 | +| Overall sites | 100% | 83% | 73 | 0 | 15 | +| Behavior-note sites | n/a | 0% | 0 | 0 | 2 | +| Breaking sites | 100% | 85% | 73 | 0 | 13 | + +**Why it fails, and why that is not fixed here.** `pack test` fails a repository whose upgrade +moved a package `check` leaves in place: + +- netlify: http-proxy-middleware (moved to 3.0.5; `check` treats the 2.x optional peer on + `@types/express ^4` as a conflict and leaves it, as the engine does for every peer conflict), and + `blocked: verdaccio 6.2.4 depends on express 4.21.2` (verdaccio is a dev tool with its own + nested express; npm kept it at 4.21.2 in the real upgrade, so blocking on it is wrong for a nested + exact dependency). +- openhab: body-parser and serve-static, which the repository chose to move. + +Changing `with` or dropping these repositories after scoring would make the pack pass by editing +the evidence. Both are engine questions: move peer-conflicted dependents with the lead, do not block +on a dependency's exact nested pin, and decide whether a repository's own extra moves belong in +`with`. + +**Recall misses, all kept.** Lurker's handler annotations and bcgov's destructure are where the +upgrade fixed a params type, not where the compiler reports it. netlify keeps three route paths as +data passed to `app[method](path)`, and asserts on `req.body` in tests; neither has a source pattern +that would not also match unrelated code. openhab's `createMiddleware` return type fixes a +declaration-emit error that a no-emit compile does not report. + +**Before and after the first score.** The first scored run (shared cache) missed openhab's +`proxyPaths` list: the detector followed only top-level `const` lists, and this one is declared +inside `createRoutes`. The detector now follows the one `const` of that name at any depth, and the +fixture has the nested case. Two later runs in the shared `~/.cache/uptide/ground-truth` lost +repositories mid-run to another process evicting the cache (netlify predicted 0); scoring with +per-pack caches gave the table above. + +Not measured by public ground truth (fixtures and unit tests only): `req-param-removed`, +`sendfile-renamed`, `redirect-order`, `param-callback-removed`, `static-mime-removed`, +`query-middleware-removed`, `redirect-back`, `send-status-number`, and every decision note. None of +them rewrites, so under UPT-48 none is an unexercised rewrite. + +## UPT-48 readiness + +Written to the stricter gate as proposed (it had not landed at the read date): 86 expected breaking +sites across 4 repositories, per-rule TP/FP/FN above, and no mechanical rewrite to demote to +assisted. To rebase on UPT-48 before claiming verified; the companion failures above block verified +on either gate. + +## What the engine cannot express + +- **Route data.** A path reaching a router through a computed member (`app[method](path)`) or from + another file is not followed; `check` has no data flow. +- **Where the params fix goes.** The compiler reports the use; the right fix is often the handler's + type parameter, which `check` does not report as a site. +- **Moves around the lead** (above): peer-conflicted dependents, nested exact pins, and packages a + repository moves by choice. +- The curated express runtime list in `check/runtime-changes.ts` (text patterns, `unverified`) + still serves the generic tier; with the pack in use, the pack owns those sites. diff --git a/packages/core/src/packs/express/behavior.ts b/packages/core/src/packs/express/behavior.ts new file mode 100644 index 0000000..3fa6e61 --- /dev/null +++ b/packages/core/src/packs/express/behavior.ts @@ -0,0 +1,85 @@ +import { satisfies } from '../../fetch/range.js'; +import type { BehaviorNote } from '../contract.js'; +import type { PackContext } from '../types.js'; +import { redirectBackSites, sendStatusSites } from './detect.js'; + +/** The `engines.node` range a workspace's package.json declares, if any. */ +function nodeRange(context: PackContext): { file: string; range: string } | undefined { + for (const entry of context.inventory ?? []) { + if (!/(?:^|\/)package\.json$/.test(entry.file)) continue; + try { + const range = (JSON.parse(entry.text) as { engines?: { node?: unknown } }).engines?.node; + if (typeof range === 'string') return { file: entry.file, range }; + } catch { + // A manifest that does not parse says nothing about Node. + } + } + return undefined; +} + +export const expressBehavior: BehaviorNote[] = [ + { + id: 'redirect-back', + summary: + "`res.redirect('back')` and `res.location('back')` no longer read the Referrer: Express 5 redirects to a relative URL named `back`", + reported: ['finding'], + severity: 'breaking', + detect: (text) => redirectBackSites(text), + }, + { + id: 'send-status-number', + summary: + '`res.send()` no longer sets the status: Express 5 sends the number as the body with status 200; use `res.sendStatus(code)`', + reported: ['finding'], + severity: 'breaking', + detect: (text) => sendStatusSites(text), + }, + { + id: 'async-errors', + summary: + 'a rejected promise or an error thrown in an async handler now reaches the error middleware (`next(err)`), where Express 4 left it unhandled: error responses, logging and process crashes change where handlers relied on that, and wrappers such as express-async-errors or asyncHandler become redundant', + reported: ['decision'], + }, + { + id: 'req-body-undefined', + summary: + '`req.body` is `undefined`, not `{}`, when no body parser ran (body-parser 2): code and tests that read or assert on it without a parser change', + reported: ['decision', 'test-follow-up'], + }, + { + id: 'query-parser', + summary: + "`req.query` is parsed with the `simple` parser by default (no nested `a[b]=1` objects or arrays from brackets; Express 4 used `extended`), and it is a getter: assigning to it throws and changes to it are lost; `app.set('query parser', 'extended')` restores the old parsing", + reported: ['decision'], + }, + { + id: 'urlencoded-extended', + summary: + '`express.urlencoded()` defaults to `extended: false` (body-parser 2): form fields with brackets stay flat strings unless `extended: true` is set', + reported: ['decision'], + }, + { + id: 'static-dotfiles', + summary: + '`express.static` and `res.sendFile` ignore every path segment that starts with a dot by default (send 1.x), where Express 4 served dot-directories such as `/.well-known/`: serve those with their own `express.static` and `dotfiles: "allow"` rather than allowing dotfiles everywhere', + reported: ['decision'], + }, + { + id: 'node-minimum', + summary: 'Express 5 requires Node.js 18 or later', + reported: ['decision'], + applies: (context) => { + const declared = nodeRange(context); + if (!declared) return false; + return satisfies('16.20.2', declared.range) + ? `\`${declared.file}\` declares \`engines.node\` "${declared.range}", which allows Node older than 18; Express 5 requires Node 18 or later.` + : false; + }, + }, + { + id: 'runtime-defaults', + summary: + '`req.host` keeps the port; `res.status()` throws on a code outside 100-999 or not an integer; `res.clearCookie()` ignores `maxAge` and `expires`; `res.vary()` throws without a field; `.js` is served as `text/javascript`', + reported: ['decision'], + }, +]; diff --git a/packages/core/src/packs/express/detect.ts b/packages/core/src/packs/express/detect.ts new file mode 100644 index 0000000..dd8c9e0 --- /dev/null +++ b/packages/core/src/packs/express/detect.ts @@ -0,0 +1,368 @@ +import { + type CallExpression, + type Expression, + Node, + Project, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; +import type { SourceSite } from '../contract.js'; + +/** Methods whose first argument is a path: every HTTP verb Express 4 routes, `all`, `use`, `route`. */ +const ROUTING = new Set([ + 'all', + 'use', + 'route', + 'get', + 'post', + 'put', + 'delete', + 'patch', + 'options', + 'head', +]); + +/** Express types a receiver can be declared as: an app or a router. */ +const TYPES = /^(?:express\.)?(?:Express|Application|Router|IRouter)$/; + +export function parse(text: string): SourceFile { + return new Project({ useInMemoryFileSystem: true }).createSourceFile('consumer.ts', text, { + overwrite: true, + }); +} + +interface Names { + /** Locals bound to the module: default import, namespace, `import x = require`, `const x = require`. */ + module: Set; + /** Locals bound to `Router`. */ + router: Set; +} + +function expressNames(source: SourceFile): Names { + const module = new Set(); + const router = new Set(); + for (const declaration of source.getImportDeclarations()) { + if (declaration.getModuleSpecifierValue() !== 'express') continue; + const fallback = declaration.getDefaultImport(); + if (fallback) module.add(fallback.getText()); + const namespace = declaration.getNamespaceImport(); + if (namespace) module.add(namespace.getText()); + for (const element of declaration.getNamedImports()) + if (element.getName() === 'Router') + router.add((element.getAliasNode() ?? element.getNameNode()).getText()); + } + for (const node of source.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration)) + if (/^require\(\s*['"]express['"]\s*\)$/.test(node.getModuleReference().getText())) + module.add(node.getName()); + for (const variable of source.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + const init = variable.getInitializer(); + if (!init || !/^require\(\s*['"]express['"]\s*\)$/.test(init.getText())) continue; + const name = variable.getNameNode(); + if (Node.isIdentifier(name)) module.add(name.getText()); + else if (Node.isObjectBindingPattern(name)) + for (const element of name.getElements()) + if ((element.getPropertyNameNode() ?? element.getNameNode()).getText() === 'Router') + router.add(element.getNameNode().getText()); + } + return { module, router }; +} + +/** `express()`, `express.Router()`, `Router()`: an expression that makes an app or a router. */ +function makesApp(expression: Node | undefined, names: Names): boolean { + let inner = expression; + while (inner && (Node.isParenthesizedExpression(inner) || Node.isAsExpression(inner))) + inner = inner.getExpression(); + if (!inner || !Node.isCallExpression(inner)) return false; + const callee = inner.getExpression().getText(); + return ( + names.module.has(callee) || + names.router.has(callee) || + [...names.module].some((m) => callee === `${m}.Router`) + ); +} + +/** + * The names (identifiers, and `this.` properties) that hold an Express app or router in + * this file: initialized or assigned from `express()` or `Router()`, or declared with an Express + * app or router type. Anything else with a `.get` method (a Map, an HTTP client) is never one. + */ +function receivers(source: SourceFile, names: Names): Set { + const found = new Set(); + const typed = (type: Node | undefined) => type !== undefined && TYPES.test(type.getText()); + for (const node of [ + ...source.getDescendantsOfKind(SyntaxKind.VariableDeclaration), + ...source.getDescendantsOfKind(SyntaxKind.PropertyDeclaration), + ...source.getDescendantsOfKind(SyntaxKind.Parameter), + ]) { + const name = node.getNameNode(); + if ( + Node.isIdentifier(name) && + (typed(node.getTypeNode()) || makesApp(node.getInitializer(), names)) + ) + found.add(name.getText()); + } + for (const assignment of source.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (assignment.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue; + if (!makesApp(assignment.getRight(), names)) continue; + const left = assignment.getLeft(); + found.add(Node.isPropertyAccessExpression(left) ? left.getName() : left.getText()); + } + return found; +} + +/** The app or router a routing call is made on: `app`, `this.app`, or the start of `app.route('/a').get(…)`. */ +function receiverOf(callee: Expression): string | undefined { + if (!Node.isPropertyAccessExpression(callee)) return undefined; + const target = callee.getExpression(); + if (Node.isIdentifier(target)) return target.getText(); + if ( + Node.isPropertyAccessExpression(target) && + target.getExpression().getKind() === SyntaxKind.ThisKeyword + ) + return target.getName(); + if (Node.isCallExpression(target)) { + const inner = target.getExpression(); + if (Node.isPropertyAccessExpression(inner) && ROUTING.has(inner.getName())) + return receiverOf(inner); + } + return undefined; +} + +/** + * What path-to-regexp 8 (Express 5) reads differently from path-to-regexp 0.1 (Express 4) in a + * route string, or `undefined` when it means the same: `*` (an unnamed wildcard in 4, a named one + * in 8), `:name?` (an optional parameter in 4, `{/:name}` in 8), `?` `+` `(` `)` `[` `]` `!` + * (reserved in 8, which throws at registration), `{` `}` (literal text in 4, an optional group in + * 8), and a `:` with no parameter name after it. + */ +export function incompatible(path: string): string | undefined { + if (path.includes('*')) return 'a `*` wildcard is named in Express 5 (`/*splat`, `/{*splat}`)'; + if (/:[A-Za-z_$][\w$]*\?/.test(path)) + return 'an optional parameter is `{/:name}` in Express 5, not `/:name?`'; + if (/[?+()[\]!]/.test(path)) + return '`?`, `+`, `(`, `)`, `[`, `]` and `!` are reserved in Express 5 route strings'; + if (/[{}]/.test(path)) + return '`{` and `}` are optional groups in Express 5, literal text in Express 4'; + if (/:(?![A-Za-z_$])/.test(path)) return 'a `:` needs a parameter name in Express 5'; + return undefined; +} + +/** + * A path argument's string parts: a literal, a template's static text, each element of an array + * literal, and, for an identifier, the array or string a `const` or the array a `for…of` over a + * `const` holds (`for (const path of proxyPaths) router.all(path, …)`). + */ +function paths(argument: Node | undefined, depth = 0): { node: Node; value: string }[] { + if (!argument || depth > 4) return []; + if (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) + return [{ node: argument, value: argument.getLiteralValue() }]; + if (Node.isTemplateExpression(argument)) + return [ + { + node: argument, + value: [ + argument.getHead().getLiteralText(), + ...argument.getTemplateSpans().map((span) => span.getLiteral().getLiteralText()), + ].join('x'), + }, + ]; + if (Node.isArrayLiteralExpression(argument)) + return argument.getElements().flatMap((e) => paths(e, depth + 1)); + if (Node.isIdentifier(argument)) { + const name = argument.getText(); + // The loop that binds it: `for (const path of list)`. + for (const loop of argument.getAncestors()) + if ( + Node.isForOfStatement(loop) && + /^const\s/.test(loop.getInitializer().getText()) && + loop + .getInitializer() + .getText() + .replace(/^const\s+/, '') === name + ) + return paths(loop.getExpression(), depth + 1); + // Else the one `const` of that name in the file, at any depth; a `let` can be reassigned and + // a name declared twice is ambiguous, so neither is followed. + const declarations = argument + .getSourceFile() + .getDescendantsOfKind(SyntaxKind.VariableDeclaration) + .filter((d) => d.getName() === name); + const [only] = declarations; + if (declarations.length === 1 && only?.getVariableStatement()?.getDeclarationKind() === 'const') + return paths(only.getInitializer(), depth + 1); + } + return []; +} + +/** + * Route strings path-to-regexp 8 rejects or reads differently, on calls of an Express app or + * router this file binds (`app.get('/*', …)`, `router.use('/:id?', …)`, `app.route('/a(b)?')`). + * A path in a `const` or a `for…of` over one is followed; one in a `let` or computed is not. + * A `RegExp` path means the same in both majors. + */ +export function routeSites(text: string): SourceSite[] { + if (!/['"]express['"]/.test(text)) return []; + const source = parse(text); + const names = expressNames(source); + const known = receivers(source, names); + if (!known.size) return []; + const sites: SourceSite[] = []; + const lines = new Set(); + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || !ROUTING.has(callee.getName())) continue; + const receiver = receiverOf(callee); + if (!receiver || !known.has(receiver)) continue; + // One registration, one site: a list of paths is reported at its first one Express 5 rejects. + const first = paths(call.getArguments()[0]) + .map((path) => ({ ...path, why: incompatible(path.value) })) + .find((path) => path.why); + if (!first) continue; + const { line, column } = source.getLineAndColumnAtPos(first.node.getStart()); + if (lines.has(line)) continue; + lines.add(line); + sites.push({ + line, + column, + snippet: text.split('\n')[line - 1]?.trim() ?? '', + name: first.why, + }); + } + return sites.sort((a, b) => a.line - b.line); +} + +function siteAt(source: SourceFile, node: Node, name: string): SourceSite { + const { line, column } = source.getLineAndColumnAtPos(node.getStart()); + return { line, column, snippet: source.getFullText().split('\n')[line - 1]?.trim() ?? '', name }; +} + +/** + * `app.param(fn)` and `router.param(fn)`: Express 4's deprecated form that customizes `param()` + * itself is gone, and Express 5 throws "argument name must be a string" at startup. The two-argument + * form (`app.param('id', fn)`) is unchanged and never reported. + */ +export function paramCallbackSites(text: string): SourceSite[] { + if (!/\.param\(/.test(text) || !/['"]express['"]/.test(text)) return []; + const source = parse(text); + const known = receivers(source, expressNames(source)); + return source + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((call) => { + const callee = call.getExpression(); + const receiver = Node.isPropertyAccessExpression(callee) ? receiverOf(callee) : undefined; + return ( + Node.isPropertyAccessExpression(callee) && + callee.getName() === 'param' && + receiver !== undefined && + known.has(receiver) && + call.getArguments().length === 1 + ); + }) + .map((call) => siteAt(source, call, 'param(fn)')); +} + +/** + * The locals that are an Express response in this file: parameters and variables typed + * `Response` (or `express.Response`) imported from express, and the second parameter of a + * handler passed inline to a routing call on a known app or router. + */ +function responses(source: SourceFile): Set { + const names = expressNames(source); + const local = new Set(); + for (const declaration of source.getImportDeclarations()) + if (declaration.getModuleSpecifierValue() === 'express') + for (const element of declaration.getNamedImports()) + if (element.getName() === 'Response') + local.add((element.getAliasNode() ?? element.getNameNode()).getText()); + const typed = (type: string) => + local.has(type.replace(/<.*$/s, '')) || + [...names.module].some((m) => type.replace(/<.*$/s, '') === `${m}.Response`); + const found = new Set(); + for (const parameter of source.getDescendantsOfKind(SyntaxKind.Parameter)) { + const type = parameter.getTypeNode()?.getText(); + if (type && typed(type)) found.add(parameter.getName()); + } + const known = receivers(source, names); + for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || !ROUTING.has(callee.getName())) continue; + const receiver = receiverOf(callee); + if (!receiver || !known.has(receiver)) continue; + for (const handler of call.getArguments()) + if (Node.isArrowFunction(handler) || Node.isFunctionExpression(handler)) { + const res = handler.getParameters()[1]?.getNameNode(); + if (res && Node.isIdentifier(res)) found.add(res.getText()); + } + } + return found; +} + +function responseCalls( + text: string, + method: RegExp, +): { source: SourceFile; calls: CallExpression[] } { + const source = parse(text); + const known = responses(source); + const calls = source.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || !method.test(callee.getName())) return false; + let target: Expression = callee.getExpression(); + // `res.status(200).redirect(…)` is still the response. + for (;;) { + const inner = Node.isCallExpression(target) ? target.getExpression() : undefined; + if (!inner || !Node.isPropertyAccessExpression(inner)) break; + target = inner.getExpression(); + } + return Node.isIdentifier(target) && known.has(target.getText()); + }); + return { source, calls }; +} + +const numeric = (node: Node | undefined) => node !== undefined && Node.isNumericLiteral(node); + +/** + * `res.redirect(url, 301)`: Express 5 reads `redirect(status, url)` only, so the URL and status + * swap at runtime. Reported where the second argument is a status number and the first is not. + */ +export function redirectOrderSites(text: string): SourceSite[] { + if (!/\.redirect\(/.test(text) || !/['"]express['"]/.test(text)) return []; + const { source, calls } = responseCalls(text, /^redirect$/); + return calls + .filter((call) => { + const [first, second] = call.getArguments(); + return numeric(second) && !numeric(first); + }) + .map((call) => siteAt(source, call, 'redirect(url, status)')); +} + +/** `res.redirect('back')` and `res.location('back')`: Express 5 redirects to a page named "back". */ +export function redirectBackSites(text: string): SourceSite[] { + if (!/['"`]back['"`]/.test(text) || !/['"]express['"]/.test(text)) return []; + const { source, calls } = responseCalls(text, /^(?:redirect|location)$/); + return calls + .filter((call) => + (call as Node & { getArguments(): Node[] }) + .getArguments() + .some( + (a) => + (Node.isStringLiteral(a) || Node.isNoSubstitutionTemplateLiteral(a)) && + a.getLiteralValue() === 'back', + ), + ) + .map((call) => siteAt(source, call, "'back'")); +} + +/** + * `res.send(404)`: Express 4 sent the status; Express 5 sends the number as a JSON body with + * status 200. The compiler accepts both (`send(body?: any)`). + */ +export function sendStatusSites(text: string): SourceSite[] { + if (!/\.send\(\s*\d/.test(text) || !/['"]express['"]/.test(text)) return []; + const { source, calls } = responseCalls(text, /^send$/); + return calls + .filter((call) => { + const args = call.getArguments(); + return args.length === 1 && numeric(args[0]); + }) + .map((call) => siteAt(source, call, 'send(status)')); +} diff --git a/packages/core/src/packs/express/express.test.ts b/packages/core/src/packs/express/express.test.ts new file mode 100644 index 0000000..5161e2f --- /dev/null +++ b/packages/core/src/packs/express/express.test.ts @@ -0,0 +1,115 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { Finding } from '../../domain/report.js'; +import { ruleFor } from '../contract.js'; +import { runFixtures } from '../tooling/fixtures.js'; +import { incompatible } from './detect.js'; +import { expressPack } from './index.js'; + +it('express: every fixture rewrites and detects exactly its marked sites', () => { + const result = runFixtures(expressPack, fileURLToPath(new URL('.', import.meta.url))); + expect(result.cases.length).toBeGreaterThan(0); + expect(result.unknown).toEqual([]); + expect(result.rewriteFailures).toEqual([]); + expect(result.falsePositives).toEqual([]); + expect(result.falseNegatives).toEqual([]); +}); + +/** A compiler-only finding as `check` reports it on an Express 4 consumer compiled against 5. */ +function compiled(code: number, message: string): Finding { + const path = `TS${code}`; + return { + change: { + package: 'express', + from: '4.22.1', + to: '5.2.1', + path, + kind: 'type', + severity: 'breaking', + source: 'types', + confidence: 1, + }, + usage: { + file: 'src/routes.ts', + line: 1, + column: 1, + endLine: 1, + endColumn: 1, + symbolPath: path, + access: 'read', + snippet: '', + via: 'inferred', + compileError: message, + compileCode: code, + }, + severity: 'breaking', + confidence: 1, + fixability: 'manual', + reason: '', + }; +} + +const claimed = (code: number, message: string) => + ruleFor(expressPack.rules, compiled(code, message))?.id; + +describe('which rule claims what the compiler reports', () => { + it('claims a route parameter widened to string | string[]', () => { + expect(claimed(2322, "Type 'string | string[]' is not assignable to type 'string'.")).toBe( + 'params-string-array', + ); + expect( + claimed( + 2345, + "Argument of type 'string | string[]' is not assignable to parameter of type 'string'.", + ), + ).toBe('params-string-array'); + }); + + it('claims removed members by name, and leaves other errors to the generic tier', () => { + expect( + claimed( + 2551, + "Property 'param' does not exist on type 'Request'. Did you mean 'params'?", + ), + ).toBe('req-param-removed'); + expect( + claimed( + 2551, + "Property 'sendfile' does not exist on type 'Response'. Did you mean 'sendFile'?", + ), + ).toBe('sendfile-renamed'); + expect( + claimed( + 2339, + "Property 'mime' does not exist on type 'RequestHandlerConstructor'.", + ), + ).toBe('static-mime-removed'); + expect(claimed(2339, "Property 'query' does not exist on type 'typeof e'.")).toBe( + 'query-middleware-removed', + ); + expect( + claimed(2345, "Argument of type 'string' is not assignable to parameter of type 'number'."), + ).toBe(undefined); + expect(claimed(2339, "Property 'params' does not exist on type 'Foo'.")).toBe(undefined); + }); +}); + +describe('route strings path-to-regexp 8 reads differently', () => { + it.each([ + ['*'], + ['/assets/*'], + ['/users/:id?'], + ['/files/:name.:ext?'], + ['/ab+cd'], + ['/docs/(.*)'], + ['/:type(a|b)/:id'], + ['/[a|b]/:slug'], + ['/{literal}'], + ['/a/:'], + ])('%s', (path) => expect(incompatible(path)).toBeDefined()); + + it.each([['/'], ['/users/:id'], ['/files/:name.:ext'], ['/a-b_c.json'], ['/:id/edit']])( + 'leaves %s alone', + (path) => expect(incompatible(path)).toBeUndefined(), + ); +}); diff --git a/packages/core/src/packs/express/fixtures/responses/after.ts b/packages/core/src/packs/express/fixtures/responses/after.ts new file mode 100644 index 0000000..5f0ea0c --- /dev/null +++ b/packages/core/src/packs/express/fixtures/responses/after.ts @@ -0,0 +1,26 @@ +import express, { type Request, type Response } from 'express'; +import { WebSocket } from 'ws'; + +const app = express(); +const router = express.Router(); +declare const socket: WebSocket; + +app.param((name: string, fn: unknown) => fn); // @uptide param-callback-removed at:app.param +router.param('id', (req, res, next) => next()); // @uptide param-callback-removed keep + +app.get('/old', (req, res) => { + res.redirect('/new', 301); // @uptide redirect-order at:res.redirect + res.redirect(302, '/new'); // @uptide redirect-order keep + res.redirect('back'); // @uptide redirect-back at:res.redirect + res.status(404).send(404); // @uptide send-status-number at:res.status + res.send('404'); // @uptide send-status-number keep +}); + +export function gone(req: Request, response: Response) { + response.location('back'); // @uptide redirect-back at:response.location + response.send(410); // @uptide send-status-number at:response.send +} + +socket.send(1); // @uptide send-status-number keep +const history = { redirect: (to: string, code: number) => [to, code] }; +history.redirect('/new', 301); // @uptide redirect-order keep diff --git a/packages/core/src/packs/express/fixtures/responses/before.ts b/packages/core/src/packs/express/fixtures/responses/before.ts new file mode 100644 index 0000000..5f0ea0c --- /dev/null +++ b/packages/core/src/packs/express/fixtures/responses/before.ts @@ -0,0 +1,26 @@ +import express, { type Request, type Response } from 'express'; +import { WebSocket } from 'ws'; + +const app = express(); +const router = express.Router(); +declare const socket: WebSocket; + +app.param((name: string, fn: unknown) => fn); // @uptide param-callback-removed at:app.param +router.param('id', (req, res, next) => next()); // @uptide param-callback-removed keep + +app.get('/old', (req, res) => { + res.redirect('/new', 301); // @uptide redirect-order at:res.redirect + res.redirect(302, '/new'); // @uptide redirect-order keep + res.redirect('back'); // @uptide redirect-back at:res.redirect + res.status(404).send(404); // @uptide send-status-number at:res.status + res.send('404'); // @uptide send-status-number keep +}); + +export function gone(req: Request, response: Response) { + response.location('back'); // @uptide redirect-back at:response.location + response.send(410); // @uptide send-status-number at:response.send +} + +socket.send(1); // @uptide send-status-number keep +const history = { redirect: (to: string, code: number) => [to, code] }; +history.redirect('/new', 301); // @uptide redirect-order keep diff --git a/packages/core/src/packs/express/fixtures/route-syntax/after.ts b/packages/core/src/packs/express/fixtures/route-syntax/after.ts new file mode 100644 index 0000000..c6e76b5 --- /dev/null +++ b/packages/core/src/packs/express/fixtures/route-syntax/after.ts @@ -0,0 +1,54 @@ +import express, { Router } from 'express'; +import type { Express } from 'express'; + +const app = express(); +const api = Router(); +const cache = new Map(); + +app.get('*', serve); // @uptide route-syntax +app.use('/assets/*', serve); // @uptide route-syntax +api.get('/users/:id?', serve); // @uptide route-syntax +api.post('/files/:name.:ext?', serve); // @uptide route-syntax +app.get('/ab+cd', serve); // @uptide route-syntax +app.get(['/', '/docs/(.*)'], serve); // @uptide route-syntax +app + .route('/book/:id?') // @uptide route-syntax + .get(serve); +app.get('/users/:id', serve); // @uptide route-syntax keep +app.get(/.*fly$/, serve); // @uptide route-syntax keep +cache.get('*'); // @uptide route-syntax keep +api.delete(`/items/${'x'}/:id`, serve); // @uptide route-syntax keep + +export function mount(server: Express) { + server.all('/proxy/*', serve); // @uptide route-syntax +} + +class Server { + private readonly app = express(); + start() { + this.app.get('/health/*', serve); // @uptide route-syntax + } +} + +const proxied = [ + '/rest', // @uptide route-syntax keep + '/images/*', // @uptide route-syntax + '/static/*', // @uptide route-syntax keep +]; +for (const path of proxied) app.all(path, serve); +let mutable = '/later/*'; // @uptide route-syntax keep +app.get(mutable, serve); +mutable = '/x'; + +export function mountProxies(router = Router()) { + const forwarded = [ + '/api', // @uptide route-syntax keep + '/rest*', // @uptide route-syntax + ]; + for (const path of forwarded) { + router.all(path, serve); + } +} + +function serve() {} +export { Server }; diff --git a/packages/core/src/packs/express/fixtures/route-syntax/before.ts b/packages/core/src/packs/express/fixtures/route-syntax/before.ts new file mode 100644 index 0000000..c6e76b5 --- /dev/null +++ b/packages/core/src/packs/express/fixtures/route-syntax/before.ts @@ -0,0 +1,54 @@ +import express, { Router } from 'express'; +import type { Express } from 'express'; + +const app = express(); +const api = Router(); +const cache = new Map(); + +app.get('*', serve); // @uptide route-syntax +app.use('/assets/*', serve); // @uptide route-syntax +api.get('/users/:id?', serve); // @uptide route-syntax +api.post('/files/:name.:ext?', serve); // @uptide route-syntax +app.get('/ab+cd', serve); // @uptide route-syntax +app.get(['/', '/docs/(.*)'], serve); // @uptide route-syntax +app + .route('/book/:id?') // @uptide route-syntax + .get(serve); +app.get('/users/:id', serve); // @uptide route-syntax keep +app.get(/.*fly$/, serve); // @uptide route-syntax keep +cache.get('*'); // @uptide route-syntax keep +api.delete(`/items/${'x'}/:id`, serve); // @uptide route-syntax keep + +export function mount(server: Express) { + server.all('/proxy/*', serve); // @uptide route-syntax +} + +class Server { + private readonly app = express(); + start() { + this.app.get('/health/*', serve); // @uptide route-syntax + } +} + +const proxied = [ + '/rest', // @uptide route-syntax keep + '/images/*', // @uptide route-syntax + '/static/*', // @uptide route-syntax keep +]; +for (const path of proxied) app.all(path, serve); +let mutable = '/later/*'; // @uptide route-syntax keep +app.get(mutable, serve); +mutable = '/x'; + +export function mountProxies(router = Router()) { + const forwarded = [ + '/api', // @uptide route-syntax keep + '/rest*', // @uptide route-syntax + ]; + for (const path of forwarded) { + router.all(path, serve); + } +} + +function serve() {} +export { Server }; diff --git a/packages/core/src/packs/express/index.ts b/packages/core/src/packs/express/index.ts new file mode 100644 index 0000000..721f605 --- /dev/null +++ b/packages/core/src/packs/express/index.ts @@ -0,0 +1,141 @@ +import { definePack } from '../contract.js'; +import { expressBehavior } from './behavior.js'; +import { paramCallbackSites, redirectOrderSites, routeSites } from './detect.js'; + +const guide = + 'https://github.com/expressjs/expressjs.com/blob/472119366bbffc206e0deba32277d607cf9f6e13/src/content/pages/en/guide/migrating-5.mdx'; +const history = + 'https://github.com/expressjs/express/blob/cf6722bf89a86905281d9160b6482e08166b5ef0/History.md'; + +/** + * express >=4 <5 → >=5 <6. Sources pinned (guide at expressjs.com 4721193, History.md at + * v5.3.0, path-to-regexp v8.4.2, codemods at expressjs/codemod 2f7f1e5) and read 2026-10-14; + * every change, its class and its evidence are in RESEARCH.md. The compiler sees the type changes + * of @types/express 5 (`req.params` values widen to `string | string[]`, removed `req.param`, + * `res.sendfile`, `express.static.mime`, `express.query`); route strings and most runtime + * defaults it cannot see. No rule rewrites: every codemod transform that would is either unsafe + * whatever the surrounding code, or never met a real site in the ground truth (RESEARCH.md). + */ +export const expressPack = definePack({ + meta: { + package: 'express', + from: '>=4 <5', + to: '>=5 <6', + sources: [ + { title: 'Migrating to Express 5 (expressjs.com 4721193, read 2026-10-14)', url: guide }, + { title: 'Express History.md, 5.0.0 to 5.3.0 (v5.3.0, read 2026-10-14)', url: history }, + { + title: 'path-to-regexp 8 errors and syntax (v8.4.2, read 2026-10-14)', + url: 'https://github.com/pillarjs/path-to-regexp/blob/cbf30259e6d34d6135f9e7dbaa3371e7188f9936/Readme.md', + }, + { + title: 'Express codemods, read not run (expressjs/codemod 2f7f1e5, read 2026-10-14)', + url: 'https://github.com/expressjs/codemod/tree/2f7f1e5f31ebab6e840957b0cf171a014ad7a3b9/codemods', + }, + { + title: 'body-parser 2 changes (HISTORY.md at 2.3.0, read 2026-10-14)', + url: 'https://github.com/expressjs/body-parser/blob/d0f2ace6c74769da7d19b8661b9a01c01bdb0bf7/HISTORY.md', + }, + { + title: + 'Published declarations, @types/express 4.17.25 and 5.0.6, @types/express-serve-static-core 4.19.9 and 5.1.3 (read 2026-10-14)', + url: 'https://registry.npmjs.org/@types/express-serve-static-core', + }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '5.3.0', + inventory: ['package.json'], + rules: [ + { + id: 'route-syntax', + summary: + 'route strings use path-to-regexp 8: an unnamed `*`, `:name?`, `+`, `(`, `)`, `[`, `]` and inline regex parameters throw at registration or match differently', + severity: 'breaking', + kinds: [], + symbols: /$^/, + detect: (text) => routeSites(text), + guide: + 'Express 5 route strings use path-to-regexp 8 and throw at startup ("Missing parameter name", "Unexpected ?") on Express 4 syntax. The guide maps: `/*` → `/*splat` (one segment or more) or `/{*splat}` (also matches the bare prefix, as `/*` did); `/:file.:ext?` → `/:file{.:ext}`; `:name?` → `{/:name}`; `:name*` → `{*name}`; `:name+` → `*name`. A wildcard is now a named parameter holding an array: a handler that read `req.params[0]` reads `req.params.splat` (string[]; `.join("/")` for the old string). Inline regex (`:id(\\d+)`, `/user(s?)`) has no string form: use a `RegExp` route or validate in the handler, and keep what the route matched. Change only the reported path; the name you give a wildcard is read by the handlers, so check them first.', + }, + { + id: 'params-string-array', + summary: + '`req.params` values are typed `string | string[]` (@types/express-serve-static-core 5.1): code that passes them on as `string` stops compiling', + severity: 'breaking', + kinds: ['type', 'signature', 'removed'], + symbols: /./, + message: /'string \| string\[\]' is not assignable/, + guide: + "@types/express 5 widens `ParamsDictionary` to `string | string[]` because a wildcard parameter (`*splat`) is an array. For a route whose parameters are plain `:name` segments, type the handler with them: `(req: Request<{ id: string }>, res) => …`, or let the route string infer them (`router.get('/:id', (req, res) => …)` with an inline handler), and pass the type parameter through shared wrappers (`asyncHandler

`). Never cast to `string` or `any`: an array reaching code that expects a string is the bug the type now reports.", + }, + { + id: 'req-param-removed', + summary: + '`req.param(name)` is removed: it read `req.params`, then `req.body`, then `req.query`', + severity: 'breaking', + kinds: ['removed', 'type', 'signature'], + symbols: /./, + message: /'param' does not exist on type 'Request/, + guide: + '`req.param(name)` looked the name up in `req.params`, then `req.body`, then `req.query`. Read it from where this route actually gets it (`req.params.name` for a route parameter, `req.query.name` or `req.body.name` otherwise); where more than one source is possible, keep the same order explicitly. The codemod `explicit-request-params` reads only `req.params`, which drops body and query values: do not copy it.', + }, + { + id: 'sendfile-renamed', + summary: + '`res.sendfile()` is removed; `res.sendFile()` takes an absolute path or a `root` option', + severity: 'breaking', + kinds: ['removed', 'type', 'signature'], + symbols: /./, + message: /'sendfile' does not exist on type 'Response/, + guide: + '`res.sendfile(path, options, fn)` is `res.sendFile(path, options, fn)`, which requires an absolute path or `options.root`: resolve a relative path against the directory it was relative to (the process cwd in Express 4), and keep the options (`hidden` is `dotfiles`, `from` is `root`).', + }, + { + id: 'redirect-order', + summary: + '`res.redirect(url, status)` is removed: Express 5 reads `redirect(status, url)` only, and the compiler rejects the old order', + severity: 'breaking', + kinds: [], + symbols: /$^/, + detect: (text) => redirectOrderSites(text), + guide: + 'Swap the arguments: `res.redirect(url, 301)` is `res.redirect(301, url)`. Keep the URL and the status as they are.', + }, + { + id: 'param-callback-removed', + summary: + "`app.param(fn)` and `router.param(fn)`, which customized `param()` itself, are removed: Express 5 throws 'argument name must be a string' at startup", + severity: 'breaking', + kinds: [], + symbols: /$^/, + detect: (text) => paramCallbackSites(text), + guide: + 'The one-argument form changed how every later `app.param(name, …)` call behaved. Remove it and write what it did into each `app.param(name, fn)` it affected (a shared validator called from each callback), or into the routes, so every parameter keeps the same check.', + }, + { + id: 'static-mime-removed', + summary: '`express.static.mime` is removed; MIME lookups come from the `mime-types` package', + severity: 'breaking', + kinds: ['removed', 'type', 'signature'], + symbols: /./, + message: /'mime' does not exist on type/, + guide: + 'Import `mime-types` (the package Express 5 uses) and call `lookup(path)` for `mime.lookup`, `charset(type)` for `mime.charsets.lookup`, `extension(type)` for `mime.extension`. `define()`, `load()` and `default_type` have no equivalent: set the header (`res.type()`, `setHeaders` of `express.static`) where the custom type was used. Adding `mime-types` adds a dependency: say so in decisions.', + }, + { + id: 'query-middleware-removed', + summary: + "`express.query()` middleware is removed; `req.query` is parsed by the app's `query parser` setting", + severity: 'breaking', + kinds: ['removed', 'type', 'signature'], + symbols: /./, + message: /'query' does not exist on type 'typeof /, + guide: + 'Remove `app.use(express.query(options))`; set the parser on the app instead: `app.set("query parser", "extended")` for qs parsing, or a function `(str) => qs.parse(str, options)` to keep the options it was given.', + }, + ], + behavior: expressBehavior, + instructions: + 'Sources pinned at the Express 5 migration guide (expressjs.com 4721193) and express v5.3.0, read 2026-10-14; see RESEARCH.md. Migrate only the reported site. Keep routes matching the same requests, handlers returning the same responses and status codes, and middleware order as it is. Never cast `req.params`, `req.query` or `req.body` to `string` or `any` to make the compiler pass, and never copy a codemod rewrite without checking the receiver is an Express app, request or response.', +}); diff --git a/packages/core/src/packs/express/verification.json b/packages/core/src/packs/express/verification.json new file mode 100644 index 0000000..60cd29a --- /dev/null +++ b/packages/core/src/packs/express/verification.json @@ -0,0 +1,19 @@ +{ + "status": "candidate", + "truth": "sha256-d07b7e14c0c525dae477ce9085f51713a46a89ecebdb260f8f9c4d48eef88e99", + "repos": 4, + "breaking": { + "precision": 1, + "recall": 0.8488, + "falsePositives": 0, + "truePositives": 73, + "falseNegatives": 13 + }, + "behavior": { + "precision": 1, + "recall": 0, + "falsePositives": 0, + "truePositives": 0, + "falseNegatives": 2 + } +} diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index b6b66e9..bba6c8c 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -4,6 +4,8 @@ import { aiPack } from './ai/index.js'; import aiVerification from './ai/verification.json' with { type: 'json' }; import type { PackVerification, RegisteredPack } from './contract.js'; +import { expressPack } from './express/index.js'; +import expressVerification from './express/verification.json' with { type: 'json' }; import { ioredisPack } from './ioredis/index.js'; import ioredisVerification from './ioredis/verification.json' with { type: 'json' }; import { joiPack } from './joi/index.js'; @@ -37,6 +39,7 @@ import zodVerification from './zod/verification.json' with { type: 'json' }; export const REGISTRY: readonly RegisteredPack[] = [ { dir: 'ai', pack: aiPack, verification: aiVerification as PackVerification }, + { dir: 'express', pack: expressPack, verification: expressVerification as PackVerification }, { dir: 'ioredis', pack: ioredisPack, verification: ioredisVerification as PackVerification }, { dir: 'joi', pack: joiPack, verification: joiVerification as PackVerification }, { From 392b38a91abd32fe1e54cbb958f4b6af0eb5f321 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Thu, 15 Oct 2026 11:35:44 -0700 Subject: [PATCH 3/3] chore(packs): re-score express on the UPT-48 gate and UPT-63 moves (UPT-55) Rebased on main. pack test passes and measures verified (86 breaking sites, 73 TP, 0 FP, 85% recall, no unexercised rewrite); the record stays candidate by choice. The netlify and openhab moves are now explained (nested verdaccio copy, a peer conflict, the repository's own choice). Ground truth unchanged. README table regenerated with docs:packs. The route-syntax fixture declares its non-Express Map instead of creating one at module level, which the shared-state scan rejects. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- README.md | 1 + packages/core/src/packs/express/RESEARCH.md | 39 +++++++++---------- .../express/fixtures/route-syntax/after.ts | 2 +- .../express/fixtures/route-syntax/before.ts | 2 +- .../core/src/packs/express/verification.json | 6 ++- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 59eb5de..1a990fe 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The contract: [migration packs](docs/packs.md). | Package | Range | Precision | Recall | Breaking sites | Ground-truth repositories | Status | | --- | --- | ---: | ---: | ---: | --- | --- | | `ai` | 6.x → 7.x | 100% | 82% | 11 | vercel/chatbot, miurla/morphic | verified | +| `express` | 4.x → 5.x | 100% | 85% | 86 | netlify/cli, bcgov/tenant-management-system, amiantos/lurker, openhab/openhab-cloud | candidate | | `ioredis` | 5.x → 6.x | n/a | 0% | 2 | ferni-ai/voiceai, syzygyhack/open-foundry, simondietschi/dokunc, safe-global/safe-client-gateway, kazumadev619-dev/FishingConditionsApp | candidate | | `joi` | 17.x → 18.x | n/a | n/a | — | edtech4good/edtech-lms-api, senacor/azure-function-middleware | candidate | | `lucide-react` | 0.x → 1.x | 100% | 100% | 11 | tradeblocks-org/tradeblocks, spizeck/SFPCA, cubicsteak/keep | verified | diff --git a/packages/core/src/packs/express/RESEARCH.md b/packages/core/src/packs/express/RESEARCH.md index f91faa1..eed6040 100644 --- a/packages/core/src/packs/express/RESEARCH.md +++ b/packages/core/src/packs/express/RESEARCH.md @@ -130,7 +130,9 @@ Searched read-only (blobless clones, `git log`/`show`, `gh search`): ## Measured pack test `pnpm uptide pack test express --write` with per-pack caches (`UPTIDE_GROUND_TRUTH_CACHE`, -`UPTIDE_CACHE_DIR`): **candidate**, 4 public repositories, and **the run fails** (below). +`UPTIDE_CACHE_DIR`), rebased on UPT-48 (#155) and UPT-63 (#157), 2026-10-15: **passes, measures +verified; recorded candidate by choice** (below). 4 public repositories, 86 expected breaking sites, +no unexercised rewrite. | Rule / scoring group | Precision | Recall | TP | FP | FN | | --- | ---: | ---: | ---: | ---: | ---: | @@ -142,20 +144,18 @@ Searched read-only (blobless clones, `git log`/`show`, `gh search`): | Behavior-note sites | n/a | 0% | 0 | 0 | 2 | | Breaking sites | 100% | 85% | 73 | 0 | 13 | -**Why it fails, and why that is not fixed here.** `pack test` fails a repository whose upgrade -moved a package `check` leaves in place: +**Moves around the lead.** Before UPT-63 the run failed on them; it now explains each, and the +ground truth was not changed: -- netlify: http-proxy-middleware (moved to 3.0.5; `check` treats the 2.x optional peer on - `@types/express ^4` as a conflict and leaves it, as the engine does for every peer conflict), and - `blocked: verdaccio 6.2.4 depends on express 4.21.2` (verdaccio is a dev tool with its own - nested express; npm kept it at 4.21.2 in the real upgrade, so blocking on it is wrong for a nested - exact dependency). -- openhab: body-parser and serve-static, which the repository chose to move. +- netlify: `verdaccio 6.2.4 keeps its own express 4.21.2` (a dependent's plain dependency gets a + nested copy, as npm did in the real upgrade); http-proxy-middleware moved to 3.0.5 by the upgrade, + left by `check` as a peer conflict (2.0.9 declares `@types/express ^4.17.13`). +- openhab: body-parser and serve-static moved to 2.x, the repository's choice (neither depends on + express). -Changing `with` or dropping these repositories after scoring would make the pack pass by editing -the evidence. Both are engine questions: move peer-conflicted dependents with the lead, do not block -on a dependency's exact nested pin, and decide whether a repository's own extra moves belong in -`with`. +**Why candidate.** Kept a candidate on purpose until a person reviews it: the http-proxy-middleware +peer conflict means `fix` would leave an install the netlify upgrade never had, and every rule +outside `route-syntax` and `params-string-array` is measured by fixtures only. **Recall misses, all kept.** Lurker's handler annotations and bcgov's destructure are where the upgrade fixed a params type, not where the compiler reports it. netlify keeps three route paths as @@ -175,12 +175,11 @@ Not measured by public ground truth (fixtures and unit tests only): `req-param-r `query-middleware-removed`, `redirect-back`, `send-status-number`, and every decision note. None of them rewrites, so under UPT-48 none is an unexercised rewrite. -## UPT-48 readiness +## UPT-48 gate -Written to the stricter gate as proposed (it had not landed at the read date): 86 expected breaking -sites across 4 repositories, per-rule TP/FP/FN above, and no mechanical rewrite to demote to -assisted. To rebase on UPT-48 before claiming verified; the companion failures above block verified -on either gate. +Scored under the stricter gate (#155): 86 expected breaking sites across 4 repositories, per-rule +TP/FP/FN above, `unexercised: []` (no rule rewrites). It measures verified; the record says +candidate by choice. ## What the engine cannot express @@ -188,7 +187,7 @@ on either gate. another file is not followed; `check` has no data flow. - **Where the params fix goes.** The compiler reports the use; the right fix is often the handler's type parameter, which `check` does not report as a site. -- **Moves around the lead** (above): peer-conflicted dependents, nested exact pins, and packages a - repository moves by choice. +- **Peer-conflicted dependents** (http-proxy-middleware above) stay where they are; `check` names + them, it does not move them. - The curated express runtime list in `check/runtime-changes.ts` (text patterns, `unverified`) still serves the generic tier; with the pack in use, the pack owns those sites. diff --git a/packages/core/src/packs/express/fixtures/route-syntax/after.ts b/packages/core/src/packs/express/fixtures/route-syntax/after.ts index c6e76b5..733eb98 100644 --- a/packages/core/src/packs/express/fixtures/route-syntax/after.ts +++ b/packages/core/src/packs/express/fixtures/route-syntax/after.ts @@ -3,7 +3,7 @@ import type { Express } from 'express'; const app = express(); const api = Router(); -const cache = new Map(); +declare const cache: Map; app.get('*', serve); // @uptide route-syntax app.use('/assets/*', serve); // @uptide route-syntax diff --git a/packages/core/src/packs/express/fixtures/route-syntax/before.ts b/packages/core/src/packs/express/fixtures/route-syntax/before.ts index c6e76b5..733eb98 100644 --- a/packages/core/src/packs/express/fixtures/route-syntax/before.ts +++ b/packages/core/src/packs/express/fixtures/route-syntax/before.ts @@ -3,7 +3,7 @@ import type { Express } from 'express'; const app = express(); const api = Router(); -const cache = new Map(); +declare const cache: Map; app.get('*', serve); // @uptide route-syntax app.use('/assets/*', serve); // @uptide route-syntax diff --git a/packages/core/src/packs/express/verification.json b/packages/core/src/packs/express/verification.json index 60cd29a..07b4c2f 100644 --- a/packages/core/src/packs/express/verification.json +++ b/packages/core/src/packs/express/verification.json @@ -15,5 +15,9 @@ "falsePositives": 0, "truePositives": 0, "falseNegatives": 2 - } + }, + "class": "breaking", + "breakingSites": 86, + "behaviorSites": 2, + "unexercised": [] }