From 9e6ed3f0fd8d7c848d5c5438725d8e4005b0f3d3 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 11:50:13 +0800 Subject: [PATCH 01/14] build: update @types/node and type-fest Co-Authored-By: LLM --- package.json | 4 +- pnpm-lock.yaml | 244 ++++++++++++++++++++++++------------------------- 2 files changed, 124 insertions(+), 124 deletions(-) diff --git a/package.json b/package.json index 10a47e3..f24f487 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@standard-schema/spec": "^1.1.0", "@tsconfig/node24": "^24.0.5", "@tsconfig/strictest": "^2.0.8", - "@types/node": "^26.4.0", + "@types/node": "^26.6.1", "@types/toposort": "^2.0.7", "@types/yargs": "^17.0.35", "hono": "^4.13.8", @@ -43,7 +43,7 @@ "oxfmt": "^0.68.0", "oxlint": "^1.83.0", "oxlint-tsgolint": "^7.0.2001", - "type-fest": "^5.9.0", + "type-fest": "^5.10.0", "typescript": "^7.0.2", "undici": "^8.10.2", "valibot": "^1.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c732de5..1461ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,8 +50,8 @@ importers: specifier: ^2.0.8 version: 2.0.8 '@types/node': - specifier: ^26.4.0 - version: 26.4.0 + specifier: ^26.6.1 + version: 26.6.1 '@types/toposort': specifier: ^2.0.7 version: 2.0.7 @@ -77,8 +77,8 @@ importers: specifier: npm:@block65/oxlint-tsgolint@>=7.0.2001 version: '@block65/oxlint-tsgolint@7.0.2001' type-fest: - specifier: ^5.9.0 - version: 5.9.0 + specifier: ^5.10.0 + version: 5.10.0 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -90,10 +90,10 @@ importers: version: 1.5.0(typescript@7.0.2) vite: specifier: ^8.3.0 - version: 8.3.0(@types/node@26.4.0)(yaml@2.9.1) + version: 8.3.0(@types/node@26.6.1)(yaml@2.9.1) vitest: specifier: ^5.0.1 - version: 5.0.1(@types/node@26.4.0)(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1)) + version: 5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1)) packages: @@ -207,8 +207,8 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@4.1.0': - resolution: {integrity: sha512-cg0ohyrAG3swyGqt8t1K/OK97DqBw/ftDvlvyY1fmEst5B40UOmsimwLENq74z2dyw5CDM+3zJIW+CV2nFNDdA==} + '@eslint/css-tree@4.1.1': + resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/object-schema@3.0.5': @@ -258,8 +258,8 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@oxc-project/types@0.149.0': - resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} '@oxfmt/binding-android-arm-eabi@0.68.0': resolution: {integrity: sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg==} @@ -383,98 +383,98 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-android-arm-eabi@1.2.8': - resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.8': - resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.8': - resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.8': - resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.8': - resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': - resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.8': - resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.8': - resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.8': - resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.8': - resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.8': - resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.8': - resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.8': - resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.8': - resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.8': - resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -509,8 +509,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.4.0': - resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/node@26.6.1': + resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==} '@types/toposort@2.0.7': resolution: {integrity: sha512-sQNk65vbC36+UixCkcky+dCr7MlflHcVILg1FVGqlUntsLFv9xd9ToWIVko/gTuin+cVe16t+2YubEFkhnSuPQ==} @@ -687,8 +687,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.24: - resolution: {integrity: sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -701,8 +701,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - builtin-modules@5.3.0: - resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} + builtin-modules@5.4.0: + resolution: {integrity: sha512-JCWSCdun+4Ovd9BhM/Vtlmwb7oD6Wl4YiPRXXwhAuwlPhW1un/MKgXn7GZoFm53ginnoNzus69V8JWx0K393jg==} engines: {node: '>=18.20'} cacheable@2.5.0: @@ -765,8 +765,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - electron-to-chromium@1.5.430: - resolution: {integrity: sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==} + electron-to-chromium@1.5.431: + resolution: {integrity: sha512-AAVihz2YwJeOdAynX8MUtqpvjY0gaiARp/7+r4kwqgzqXMrfG1qDN7vwT80uxAoOQRPcZH8t73EI+CA/1OJN8A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -885,8 +885,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} glob-parent@6.0.2: @@ -1052,8 +1052,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - mdn-data@2.34.0: - resolution: {integrity: sha512-OgIlLv0NxJKVW4GTSAoEgpRGd4F2XCqGinK0MsMlBCCS/Zcm2/LsbercNWNA7PeMMcjl75NnI97eqyo7zkdxWA==} + mdn-data@2.35.0: + resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} @@ -1070,8 +1070,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-releases@2.0.55: - resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} engines: {node: '>=18'} non-error@0.1.0: @@ -1164,8 +1164,8 @@ packages: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} - rolldown@1.2.8: - resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1245,8 +1245,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.9.0: - resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} typescript@7.0.2: @@ -1254,8 +1254,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} undici@8.10.2: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} @@ -1497,9 +1497,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@4.1.0': + '@eslint/css-tree@4.1.1': dependencies: - mdn-data: 2.34.0 + mdn-data: 2.35.0 source-map-js: 1.2.1 '@eslint/object-schema@3.0.5': {} @@ -1542,7 +1542,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.150.0': {} '@oxfmt/binding-android-arm-eabi@0.68.0': optional: true @@ -1601,49 +1601,49 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.68.0': optional: true - '@rolldown/binding-android-arm-eabi@1.2.8': + '@rolldown/binding-android-arm-eabi@1.2.9': optional: true - '@rolldown/binding-android-arm64@1.2.8': + '@rolldown/binding-android-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-arm64@1.2.8': + '@rolldown/binding-darwin-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-x64@1.2.8': + '@rolldown/binding-darwin-x64@1.2.9': optional: true - '@rolldown/binding-freebsd-x64@1.2.8': + '@rolldown/binding-freebsd-x64@1.2.9': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.8': + '@rolldown/binding-linux-arm64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.8': + '@rolldown/binding-linux-arm64-musl@1.2.9': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.8': + '@rolldown/binding-linux-ppc64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.8': + '@rolldown/binding-linux-s390x-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.8': + '@rolldown/binding-linux-x64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-musl@1.2.8': + '@rolldown/binding-linux-x64-musl@1.2.9': optional: true - '@rolldown/binding-openharmony-arm64@1.2.8': + '@rolldown/binding-openharmony-arm64@1.2.9': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.8': + '@rolldown/binding-win32-arm64-msvc@1.2.9': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.8': + '@rolldown/binding-win32-x64-msvc@1.2.9': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1673,9 +1673,9 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.4.0': + '@types/node@26.6.1': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/toposort@2.0.7': {} @@ -1745,14 +1745,14 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1))': + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.4.1 optionalDependencies: - vite: 8.3.0(@types/node@26.4.0)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.1)(yaml@2.9.1) '@vitest/spy@5.0.1': {} @@ -1779,7 +1779,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.24: {} + baseline-browser-mapping@2.11.25: {} brace-expansion@5.0.12: dependencies: @@ -1787,13 +1787,13 @@ snapshots: browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.11.24 + baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.430 - node-releases: 2.0.55 + electron-to-chromium: 1.5.431 + node-releases: 2.0.56 update-browserslist-db: 1.3.3(browserslist@4.29.0) - builtin-modules@5.3.0: {} + builtin-modules@5.4.0: {} cacheable@2.5.0: dependencies: @@ -1846,7 +1846,7 @@ snapshots: detect-libc@2.1.2: {} - electron-to-chromium@1.5.430: {} + electron-to-chromium@1.5.431: {} emoji-regex@10.6.0: {} @@ -1861,7 +1861,7 @@ snapshots: eslint-plugin-unicorn@75.0.0(eslint@10.10.0): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) - '@eslint/css-tree': 4.1.0 + '@eslint/css-tree': 4.1.1 browserslist: 4.29.0 change-case: 5.4.4 ci-info: 4.4.0 @@ -1986,7 +1986,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} + get-east-asian-width@1.7.0: {} glob-parent@6.0.2: dependencies: @@ -2016,7 +2016,7 @@ snapshots: is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.3.0 + builtin-modules: 5.4.0 is-extglob@2.1.1: {} @@ -2106,7 +2106,7 @@ snapshots: mdn-data@2.27.1: {} - mdn-data@2.34.0: {} + mdn-data@2.35.0: {} minimatch@10.2.6: dependencies: @@ -2118,7 +2118,7 @@ snapshots: natural-compare@1.4.0: {} - node-releases@2.0.55: {} + node-releases@2.0.56: {} non-error@0.1.0: {} @@ -2207,33 +2207,33 @@ snapshots: reserved-identifiers@1.2.0: {} - rolldown@1.2.8: + rolldown@1.2.9: dependencies: - '@oxc-project/types': 0.149.0 + '@oxc-project/types': 0.150.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.8 - '@rolldown/binding-android-arm64': 1.2.8 - '@rolldown/binding-darwin-arm64': 1.2.8 - '@rolldown/binding-darwin-x64': 1.2.8 - '@rolldown/binding-freebsd-x64': 1.2.8 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 - '@rolldown/binding-linux-arm64-gnu': 1.2.8 - '@rolldown/binding-linux-arm64-musl': 1.2.8 - '@rolldown/binding-linux-ppc64-gnu': 1.2.8 - '@rolldown/binding-linux-s390x-gnu': 1.2.8 - '@rolldown/binding-linux-x64-gnu': 1.2.8 - '@rolldown/binding-linux-x64-musl': 1.2.8 - '@rolldown/binding-openharmony-arm64': 1.2.8 - '@rolldown/binding-win32-arm64-msvc': 1.2.8 - '@rolldown/binding-win32-x64-msvc': 1.2.8 + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 semver@7.8.5: {} serialize-error@13.0.1: dependencies: non-error: 0.1.0 - type-fest: 5.9.0 + type-fest: 5.10.0 shebang-command@2.0.0: dependencies: @@ -2252,12 +2252,12 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 string-width@8.2.2: dependencies: - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 strip-ansi@7.2.0: @@ -2290,7 +2290,7 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.9.0: + type-fest@5.10.0: dependencies: tagged-tag: 1.0.0 @@ -2317,7 +2317,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.3.0: {} + undici-types@8.9.0: {} undici@8.10.2: {} @@ -2335,22 +2335,22 @@ snapshots: optionalDependencies: typescript: 7.0.2 - vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1): + vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.28 - rolldown: 1.2.8 + rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.1 fsevents: 2.3.3 yaml: 2.9.1 - vitest@5.0.1(@types/node@26.4.0)(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1)): + vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1)) + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2361,10 +2361,10 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.3.0(@types/node@26.4.0)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.1)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.1 transitivePeerDependencies: - msw From 87efde66a4ecda7897a0ab1a5c47f44678a489a6 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 11:54:50 +0800 Subject: [PATCH 02/14] style: apply oxfmt and oxlint autofixes Co-Authored-By: LLM --- __tests__/nullables.test.ts | 4 +++- lib/hono.ts | 2 +- lib/process-document.ts | 2 +- pnpm-workspace.yaml | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index 81df40c..bb268c4 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -355,7 +355,9 @@ test("header parameters", async () => { expect(result.typesFile.getText()).toMatchSnapshot("types"); expect(result.commandsFile.getText()).toMatchSnapshot("commands"); - expect(result.commandsValidatedFile.getText()).toMatchSnapshot("commands-validated"); + expect(result.commandsValidatedFile.getText()).toMatchSnapshot( + "commands-validated", + ); expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); expect(result.honoFile.getText()).toMatchSnapshot("hono"); }); diff --git a/lib/hono.ts b/lib/hono.ts index 8da125b..5ba2f9e 100644 --- a/lib/hono.ts +++ b/lib/hono.ts @@ -6,7 +6,7 @@ import { VariableDeclarationKind } from "ts-morph"; /** * `hasQueryValidator` gates the query decoder rather than leaving it to * `fixUnusedIdentifiers`, which removes the unused entry point but strands the - * helpers it called. + * helpers it called */ export function createHonoFile( project: Project, diff --git a/lib/process-document.ts b/lib/process-document.ts index b58a4ca..9ff24d6 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -41,7 +41,7 @@ export type CodegenOptions = { /** * Emit only `input*` variants (TS-side schemas: `v.optional`, no wire * coercion). Skips the `*Schema` (wire) variants used by hono middleware - * and response parsing. For non-HTTP / in-memory-only consumers. + * and response parsing. For non-HTTP / in-memory-only consumers */ inputOnly?: boolean; }; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce2fa27..5512e39 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,7 @@ minimumReleaseAgeExclude: - "@block65/rest-client" - - '@block65/oxlint-plugin@0.6.0' - - '@block65/shared-config@0.3.1' + - "@block65/oxlint-plugin@0.6.0" + - "@block65/shared-config@0.3.1" overrides: oxlint: npm:@block65/oxlint@>=1.82.0 From 28039451e7afb8ed4134a90a56193a603d9eb924 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 11:56:35 +0800 Subject: [PATCH 03/14] refactor: delete commented-out code These blocks were jsdoc and enum experiments left behind in comments. The lint rules for comment prose fire on them, and nothing reads them. Co-Authored-By: LLM --- lib/process-document.ts | 90 +---------------------------------------- lib/process-schema.ts | 21 +--------- 2 files changed, 2 insertions(+), 109 deletions(-) diff --git a/lib/process-document.ts b/lib/process-document.ts index 9ff24d6..bdb209b 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -176,10 +176,6 @@ function isUnspecifiedKeyword(type: TypeAliasDeclaration) { const emptyKeyword = "undefined" as const; -// function isEmptyKeyword(type: TypeAliasDeclaration) { -// return type?.getTypeNode()?.getKindName() === emptyKeyword; -// } - // the union/intersect helpers keep TypeScript happy due to ts-morph typings function createIntersection(...types: (string | undefined)[]) { // create a type of all the inputs @@ -542,15 +538,6 @@ export async function processOpenApiDocument( if (resolvedParameter.in === "path") { pathParameters.push(resolvedParameter); - - // jsdoc.addTag({ - // tagName: 'param', - // text: wordWrap( - // `${parameterName} {String} ${ - // resolvedParameter.description || '' - // }`, - // ).trim(), - // }); } if ( @@ -767,13 +754,6 @@ export async function processOpenApiDocument( pascalCase(operationObject.operationId || "", "JsonBody"), ); - // if (!requestBodySchema) { - // return { - // name, - // hasQuestionToken: !requestBodyObjectJson.schema.required, - // }; - // } - const type = schemaToType( typesAndInterfaces, jsonRequestBodyObject.schema.required @@ -791,10 +771,6 @@ export async function processOpenApiDocument( type: typeof type.type === "function" ? type.type : String(type.type), }); - - // console.warn("Couldn't find a body type for", requestBodyObjectJson.schema); - - // return undefined; }); const nonJsonBodyEntries = requestBodyObject?.content @@ -835,19 +811,6 @@ export async function processOpenApiDocument( type: "NonNullable", }); - // nonJsonBody.addJsDoc({ - // description: `The body of the request, encoded as ${contentType}`, - // tags: [ - // { - // tagName: 'param', - // text: wordWrap( - // `body {${nonJsonBody.getName()}} ${mediaTypeObj.schema?.description || '' - // }`, - // ).trim(), - // }, - // ], - // }); - return nonJsonBody.getName(); }, ), @@ -858,18 +821,6 @@ export async function processOpenApiDocument( }) : undefined; - // ensureImport(bodyType); - - // const paramsParamName = 'parameters'; - // if (bodyType) { - // jsdoc.addTag({ - // tagName: 'param', - // text: wordWrap( - // `${paramsParamName}.body {${bodyType.getName()}} ${maybeJsDocDescription()}`, - // ).trim(), - // }); - // } - const paramsType = pathParameters.length > 0 ? typesFile.addTypeAlias({ @@ -1052,30 +1003,6 @@ export async function processOpenApiDocument( commandClassDeclaration.getExtends()?.addTypeArgument(inputTypeArg); - // if (queryType && !isVoidKeyword(queryType)) { - // ctor.addParameter({ - // name: 'query', - // type: queryType.getName(), - // }); - // } - - // for (const queryParam of queryParameters) { - // const queryParameterName = camelcase(queryParam.name); - - // jsdoc.addTag({ - // tagName: 'param', - // text: wordWrap( - // `${paramsParamName}.query.${queryParameterName}${ - // queryParam.required ? '' : '?' - // } {String} ${maybeJsDocDescription( - // queryParam.deprecated && 'DEPRECATED', - // queryParam.description, - // String(queryParam.example || ''), - // )}`, - // ).trim(), - // }); - // } - // this is just like a 204 response let hasOutputType = false; @@ -1169,11 +1096,6 @@ export async function processOpenApiDocument( type: `UndefinedOnPartialDeep<${outputTypeName}>`, isExported: true, }); - - // jsdoc.addTag({ - // tagName: 'returns', - // text: `{${retVal}} HTTP ${statusCode}`, - // }); } else if (jsonResponse.schema) { const outputType = schemaToType( typesAndInterfaces, @@ -1521,12 +1443,6 @@ export async function processOpenApiDocument( }')`, }); - // const fetcherParam = ctor.addParameter({ - // name: 'fetcher', - // // type: fetcherMethodType, - // initializer: `${fetcherName}()`, - // }); - const configParam = ctor.addParameter({ name: "config", type: configType, @@ -1542,11 +1458,7 @@ export async function processOpenApiDocument( // type narrowing if (Node.isCallExpression(callExpr)) { - callExpr?.addArguments([ - baseUrl.getName(), - // fetcherParam.getName() - configParam.getName(), - ]); + callExpr?.addArguments([baseUrl.getName(), configParam.getName()]); } // Build commands-validated.ts: subclasses attach `static responseSchema`, diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 4634d5b..a33a959 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -840,33 +840,14 @@ export function registerTypesFromSchema( ] : []; - // bonus enum interface for the same set of strings - // handy for looping over the enum values - // const enumDeclaration = typesFile.addEnum({ - // name: pascalCase(schemaName, 'Enum'), - // isExported: true, - // members: schemaObject.enum.map((e: unknown) => ({ - // name: typeof e === 'string' ? pascalCase(e) : String(e), - // value: String(e), - // })), - // }); - const stringUnion = typesFile.addTypeAlias({ name: pascalCase(schemaName), isExported: true, - type: maybeUnion( - // enumDeclaration.getName() - ...schemaObject.enum.map((e) => JSON.stringify(e)), - ), + type: maybeUnion(...schemaObject.enum.map((e) => JSON.stringify(e))), docs, }); typesAndInterfaces.set(`#/components/schemas/${schemaName}`, stringUnion); - - // typesAndInterfaces.set( - // `#/components/schemas/${enumDeclaration.getName()}`, - // enumDeclaration, - // ); } // deal with non-enum strings From edc27d38f322f0690c76778aa86bb5392eca0027 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:01:49 +0800 Subject: [PATCH 04/14] docs: reword comments to the shared prose rules Collapses the comment above each private declaration to one line, drops colons and semicolons, and replaces the narrative phrasing the block65 rules flag. Two more commented-out throws go with it. Co-Authored-By: LLM --- lib/build.ts | 10 +-- lib/hono.ts | 22 +++--- lib/process-document.ts | 147 +++++++++++++++++----------------------- lib/process-schema.ts | 42 ++++-------- lib/valibot.ts | 91 +++++++++---------------- vitest.config.ts | 4 +- 6 files changed, 120 insertions(+), 196 deletions(-) diff --git a/lib/build.ts b/lib/build.ts index 4e33bd7..1ebb629 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -7,20 +7,14 @@ import { processOpenApiDocument, } from "./process-document.ts"; -// `@generated` is the marker review tools collapse on, and the one the block65 -// comment rules read to tell generated files from hand-written ones. It carries -// no date or revision: those belong to the commit, and a timestamp in a comment -// is the history version control already holds +// `@generated` marks the file for review tools and the block65 comment rules const BANNER = `/** * @generated by @block65/openapi-codegen * * Do not edit directly */`; -// Generated files are usually reformatted after emission (oxfmt etc.), so their -// bytes never match the raw emitter output and comparing them would rewrite -// every file every run. The manifest records what the emitter produced, which -// makes an unchanged file detectable without a stamp inside it +// Records emitter output so a file reformatted on disk still compares equal const MANIFEST = ".openapi-codegen-manifest.json"; function revision(text: string): string { diff --git a/lib/hono.ts b/lib/hono.ts index 5ba2f9e..ce94e06 100644 --- a/lib/hono.ts +++ b/lib/hono.ts @@ -4,8 +4,8 @@ import type { Project, SourceFile } from "ts-morph"; import { VariableDeclarationKind } from "ts-morph"; /** - * `hasQueryValidator` gates the query decoder rather than leaving it to - * `fixUnusedIdentifiers`, which removes the unused entry point but strands the + * `hasQueryValidator` controls whether the query decoder is emitted. + * `fixUnusedIdentifiers` would drop the unused entry point and strand the * helpers it called */ export function createHonoFile( @@ -57,13 +57,7 @@ export function createHonoFile( return file; } -// Hono hands a query validator a flat map of literal query keys. How those keys -// relate to the schema's shape is the document's decision: OpenAPI's `style` -// and `explode` say whether an object arrived hoisted to the top level, joined -// into one value, or bracketed under its own name. The decoder below inverts -// the Style Examples table (OAS 3.2 §4.12.6) and is driven by the same -// per-parameter entries the client encodes from, so the two agree by -// specification rather than by convention +// Inverts the OAS 3.2 §4.12.6 style table to decode Hono's flat query map function addQueryDecoder(file: SourceFile): void { file.addStatements(` type QueryParamSpec = { @@ -311,8 +305,8 @@ export function createHonoMiddleware( ): void { const name = camelcase(exportName); - // Only a parameter that is not a plain scalar needs a spec: a scalar reaches - // the validator as the string it was sent as, whatever its style + // A parameter beyond a plain scalar needs a spec. A scalar reaches the + // validator as the string it was sent as, under every style if (schemas.query && queryParams.length > 0) { honoFile.addVariableStatement({ declarationKind: VariableDeclarationKind.Const, @@ -334,9 +328,9 @@ export function createHonoMiddleware( initializer: (writer) => { writer.write("["); writer.indent(() => { - // Hono validators only run on inbound request data; response - // schemas are emitted for client-side consumption only, and - // `header` is intentionally skipped (extra HTTP headers ok) + // Hono validators run on inbound request data alone. Response + // schemas are emitted for client-side consumption, and `header` + // is skipped so extra HTTP headers pass for (const [target, schemaName] of Object.entries(schemas).filter( ([t]) => t !== "header" && t !== "response", )) { diff --git a/lib/process-document.ts b/lib/process-document.ts index bdb209b..6d594cb 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -39,9 +39,9 @@ import { export type CodegenOptions = { /** - * Emit only `input*` variants (TS-side schemas: `v.optional`, no wire - * coercion). Skips the `*Schema` (wire) variants used by hono middleware - * and response parsing. For non-HTTP / in-memory-only consumers + * Emit only `input*` variants, which use `v.optional` and skip wire + * coercion. Suits consumers that stay in memory and never reach hono + * middleware or response parsing */ inputOnly?: boolean; }; @@ -58,15 +58,7 @@ type OperationMiddlewareInfo = { queryParams: QueryParamSpec[]; }; -// A query parameter's `style` and `explode` decide how it is written into a -// query string and, therefore, how it has to be read back out. Both sides of -// the generated code work from this table (OAS 3.2 §4.12.6), so neither has to -// guess. -// -// OpenAPI's defaults are `form` and, for `form` only, `explode: true`; -// `explode` defaults to false for every other style. 3.2 drops `explode` from -// `deepObject` altogether — the table's cell for it reads n/a — so it is -// normalised here rather than recorded, and both sides ignore it +// Resolves the OAS 3.2 §4.12.6 style and explode defaults for a parameter function queryParameterEncoding(parameter: oas30.ParameterObject) { const style = (parameter.style ?? "form") as QueryParamSpec["style"]; @@ -98,14 +90,11 @@ function queryParameterSpec( }; } - // a scalar arrives as the string it was sent as, whatever its style + // a scalar arrives as the string it was sent as, under every style return undefined; } -// The Style Examples table (OAS 3.2 §4.12.6) marks some style/explode/type -// combinations n/a and says their behaviour is undefined. Both sides of the -// generated code would still produce *something* for them, so the document is -// told off here rather than the disagreement being discovered in production +// Warns on style and explode combinations that OAS 3.2 §4.12.6 marks n/a function warnOnUndefinedCombination(operationId: string, spec: QueryParamSpec) { const undefinedCombination = (spec.style === "spaceDelimited" || spec.style === "pipeDelimited") && @@ -124,12 +113,7 @@ function warnOnUndefinedCombination(operationId: string, spec: QueryParamSpec) { } } -// An object-valued query parameter whose document states no style gets -// OpenAPI's default of form/explode, which drops the parent name. That is -// nearly always an oversight rather than a decision — it makes two such -// parameters sharing a member name indistinguishable, and it is not what the -// servers these documents describe tend to expect — so it is called out where -// someone can still fix the document +// Warns when an object query parameter omits style and falls back to form function warnOnUnderspecifiedQuery( operationId: string, parameters: oas30.ParameterObject[], @@ -215,10 +199,10 @@ export async function processOpenApiDocument( }, ); - // Parallel file: subclasses that attach `static responseSchema`. Consumers - // import from this file (or alias `./commands` → `./commands-validated` in - // dev) to opt into runtime response validation. Lean `commands.ts` carries - // zero schema imports, so prod bundles stay small + // Subclasses that attach `static responseSchema`. Consumers import from + // this module to opt into runtime response validation, or alias + // `./commands` to it in dev. The base command module imports zero + // schemas, so prod bundles stay small const commandsValidatedFile = project.createSourceFile( join(outputDir, "commands-validated.ts"), "", @@ -258,13 +242,13 @@ export async function processOpenApiDocument( InterfaceDeclaration | TypeAliasDeclaration | string >(); - // The exact Input type-arg expressions used in `Command` per - // operation. The client's `` union is built from these so - // commands from other generated clients fail the constraint on `.json()` + // Input type-arg expressions used in `Command` per operation. The + // client's `` union is built from these so commands from + // other generated clients fail the constraint on `.json()` const inputTypeArgs = new Set(); - // Bare type names referenced by `inputTypeArgs` expressions — collected at - // the source so we don't have to re-extract them from wrapped strings + // Bare type names referenced by `inputTypeArgs` expressions, collected at + // the source where they are still separate from the wrapped strings const inputTypeNames = new Set(); const refs = await $RefParser.resolve(schema); @@ -321,9 +305,9 @@ export async function processOpenApiDocument( const valibotModuleSpecifier = `./${valibotFile.getBaseNameWithoutExtension()}.js`; - // Commands with a response schema → emit a subclass in commands-validated.ts. - // Commands without one → re-export the base. Both keep the same exported name - // so consumers can swap files (or alias) without changing import sites + // Commands with a response schema get a subclass in the validated module. + // The rest re-export the base. Both keep the same exported name so + // consumers can swap modules and leave import sites alone const validatedSubclasses: { commandName: string; responseSchema: string }[] = []; const validatedReExports: string[] = []; @@ -568,10 +552,10 @@ export async function processOpenApiDocument( } // OpenAPI 3.2's `in: "querystring"` hands over the whole query - // string as one content-typed value, which this generator has no - // way to express. Without this it matches no branch at all and the - // operation quietly loses its query entirely, from a document that - // is perfectly valid + // string as one content-typed value, which this generator lacks a + // way to express. A warning is all that is left, since a valid + // document would otherwise generate an operation with its query + // silently dropped if (resolvedParameter.in === ("querystring" as string)) { console.warn( `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support — the operation is generated with no query at all. Declare the members as \`in: query\` parameters instead.`, @@ -596,9 +580,9 @@ export async function processOpenApiDocument( } } - // Only where the document departs from OpenAPI's default does the - // client need telling; an absent entry means that default rather - // than a generator's guess + // Entries here mark where the document departs from OpenAPI's + // default. An absent entry means that default, and never a + // generator's guess const styledQueryParameters = queryParameters.filter((parameter) => { const { style, explode } = queryParameterEncoding(parameter); return style !== "form" || !explode; @@ -879,9 +863,9 @@ export async function processOpenApiDocument( ensureImport(bodyType); } - // An array body cannot be intersected with the parameters and still - // be read as either: the array wins and the parameters vanish. It - // goes under `body`, the same way a non-JSON body already does + // An array body intersected with the parameters reads as the array + // alone, and the parameters vanish. It goes under `body`, the same + // way a non-JSON body already does const jsonBodySchema = jsonRequestBodyObject?.schema; const jsonBodyIsArray = !!jsonBodySchema && @@ -937,7 +921,7 @@ export async function processOpenApiDocument( return response.content?.["application/json"]?.schema; }); - // Hook: Generate Valibot validator for operation input + // Generate the valibot validator for the operation input const operationSchemas = createValidatorForOperationInput( validators, valibotFile, @@ -975,15 +959,12 @@ export async function processOpenApiDocument( .filter((spec) => spec !== undefined), }); - // CommandInput — widen optional fields with `| undefined` at the - // serialization boundary. Outbound payloads are about to be - // JSON.stringified (which drops `undefined`), so callers can pass - // `{ field: undefined }` even under exactOptionalPropertyTypes. - // For non-JSON bodies (octet-stream, multipart, …) the `body` - // field carries a BodyInit class instance (Blob, URLSearchParams, - // ReadableStream, …). UndefinedOnPartialDeep traverses class - // instances and mangles them, so split the input: widen everything - // except `body`, then re-intersect the raw body field + // Widen optional fields with `| undefined` at the serialization + // boundary. Outbound payloads are JSON.stringified, which drops + // `undefined`, so callers can pass `{ field: undefined }` even + // under exactOptionalPropertyTypes. A non-JSON `body` field holds + // a BodyInit class instance, which UndefinedOnPartialDeep would + // mangle, so widen everything else and re-intersect `body` const inputTypeArg = ((): string => { if (!inputType) { return unspecifiedKeyword; @@ -1019,10 +1000,10 @@ export async function processOpenApiDocument( for (const [statusCode, response] of Object.entries({ ...operationObject.responses, }).filter(([s]) => s.startsWith("2"))) { - // The output is one type argument, so the first usable 2xx - // response settles it. Without this an operation documenting both - // a 200 and a 204 adds a second argument, which lands in the - // query slot and is not a query type + // Output is one type argument, so the first usable 2xx response + // settles it. An operation documenting both a 200 and a 204 would + // otherwise add a second argument, which lands in the query slot + // and is not a query type if (hasOutputType) { break; } @@ -1083,10 +1064,10 @@ export async function processOpenApiDocument( ?.addTypeArgument(outputTypeName); hasOutputType = true; - // Handler-return alias for `c.json(...)` on the server side: - // about to be JSON.stringified (drops `undefined`), so the - // optional fields can carry `undefined`. Mirrors the `input*` - // prefix used for the lax variant in valibot.ts + // Handler-return alias for `c.json(...)` on the server side. + // The value is JSON.stringified, which drops `undefined`, so + // optional fields may hold `undefined`. Mirrors the `input*` + // prefix used for the lax variant in the valibot module typesFile.addTypeAlias({ name: pascalCase( "Input", @@ -1142,13 +1123,12 @@ export async function processOpenApiDocument( ?.addTypeArgument(unspecifiedKeyword); } - // Defer static schema attachment to commands-validated.ts. The lean - // commands.ts file carries no schema imports — body/param/query - // schemas aren't read by rest-client anyway (hono.ts imports - // directly from valibot.ts for server middleware), and the response - // schema lives on the validated subclass. - // Wire variant is what rest-client + hono consume; falls back to - // the input variant under --input-only + // Static schema attachment is deferred to the validated module, so + // the base command module imports zero schemas. rest-client reads + // the response schema from the validated subclass, and the server + // middleware imports body, param and query schemas directly. The + // wire variant is what rest-client and hono consume, falling back + // to the input variant under --input-only const wireSchemas = options?.inputOnly ? operationSchemas.input : operationSchemas.wire; @@ -1373,15 +1353,15 @@ export async function processOpenApiDocument( ], }); - // Re-export the runtime error consumers need for `instanceof` narrowing so - // they don't have to add a direct @block65/rest-client dependency just for it + // Re-export the runtime error consumers need for `instanceof` narrowing, + // sparing them a direct @block65/rest-client dependency for it alone mainFile.addExportDeclaration({ moduleSpecifier: "@block65/rest-client", namedExports: ["ResponseValidationError"], }); - // Cross-client guard: commands from another generated client fail the - // `` constraint on `.json()` + // Commands from another generated client fail the `` + // constraint on `.json()`, which guards against mixing clients const outputUnionMembers = [...outputTypes] .map((t) => (typeof t === "string" ? t : t.getName())) .filter((name): name is string => !!name && name !== unspecifiedKeyword); @@ -1461,17 +1441,16 @@ export async function processOpenApiDocument( callExpr?.addArguments([baseUrl.getName(), configParam.getName()]); } - // Build commands-validated.ts: subclasses attach `static responseSchema`, - // commands with no response schema are re-exported unchanged so the file - // keeps export parity with commands.ts (alias-safe) + // Build the validated module. Subclasses attach `static responseSchema`, + // and commands lacking one are re-exported unchanged so the module keeps + // export parity with the base and stays alias-safe const commandsModuleSpecifier = `./${commandsFile.getBaseNameWithoutExtension()}.js`; if (validatedSubclasses.length > 0) { // Namespace imports keep the generated file compact and stable across - // regenerations: adding or removing a single command leaves the import + // regenerations. Adding or removing a single command leaves the import // list alone. Modern bundlers tree-shake namespace imports correctly - // when source modules are side-effect-free (which valibot.ts and - // commands.ts both are) + // when the source modules are pure, which holds for both const commandsNs = "commands"; const schemasNs = "schemas"; @@ -1534,7 +1513,7 @@ export async function processOpenApiDocument( } } - // Add imports from valibot.ts + // Add the schema imports addSchemaImportsToHonoFile(honoFile, [...schemaImports]); // Generate middleware exports for each operation @@ -1544,9 +1523,9 @@ export async function processOpenApiDocument( honoFile.fixUnusedIdentifiers(); - // `Command` defaults its output to `unknown`, so spelling it out says - // nothing. Only a trailing one can go: an earlier argument still holds the - // position of whatever follows it + // `Command` defaults its output to `unknown`, so an explicit `unknown` + // repeats the default. A trailing argument can go, while an earlier one + // holds the position of the arguments after it for (const commandClass of commandsFile.getClasses()) { const base = commandClass.getExtends(); const typeArguments = base?.getTypeArguments() ?? []; diff --git a/lib/process-schema.ts b/lib/process-schema.ts index a33a959..f338e6f 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -24,10 +24,7 @@ function maybeWithNullUnion(type: string | WriterFunction, withNull = false) { return withNull && type !== "null" ? Writers.unionType(type, "null") : type; } -// RFC 3339 temporal `format`s as template-literal TypeScript types, so the -// digit shape is visible in the type (strictly narrower than `string`). These -// are shape hints — the runtime valibot schema does the real RFC 3339 -// validation. Kept in lock-step with valibot's `temporalTypeHint` +// Template-literal type per RFC 3339 temporal format, mirrored in valibot function temporalStringType(format: string | undefined): string | undefined { switch (format) { case "date": @@ -47,9 +44,7 @@ function temporalStringType(format: string | undefined): string | undefined { } } -// int64 carries values beyond Number.MAX_SAFE_INTEGER, so it maps to `bigint` -// as the domain type and `${bigint}` on the wire (query/header/path/body values -// arrive as strings). Every other integer/number stays `number` / `${number}` +// int64 maps to bigint, and every other integer or number maps to number function numericType(isInt64: boolean, stringish: boolean | undefined): string { if (isInt64) { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type @@ -71,9 +66,7 @@ function schemaTypeIsNull(schema: oas30.SchemaObject | oas31.SchemaObject) { ); } -// `unknown` swallows a union, `never` adds nothing to one, and the same -// constituent twice is still one type. Only string members can be compared: -// a writer's text is not available until it runs +// Drops `unknown`, `never` and duplicate string members from a union function collapseUnion(types: (string | WriterFunction)[]) { const seen = new Set(); const deduped = types.filter((type) => { @@ -125,10 +118,7 @@ function recordType(value: string | WriterFunction): WriterFunction { }; } -// OpenAPI spells "one of these known values, or any other string" as an anyOf -// of the bare type and an enum of it. TypeScript collapses that union to the -// bare type, so the known values disappear from completion. LiteralUnion keeps -// them offered without narrowing what the property accepts +// LiteralUnion keeps known values in completion and accepts any other string function literalUnionType( schemaItems: ( | oas31.SchemaObject @@ -201,7 +191,6 @@ export function schemaToType( const existingSchema = typesAndInterfaces.get(schemaObject.$ref); if (!existingSchema) { - // throw new Error(`ref used before available: ${schemaObject.$ref}`); console.warn("ref used before available: schema=%j", schemaObject); return { @@ -444,7 +433,7 @@ export function schemaToType( const isNullable = schemaTypeIsNull(schemaObject); if (intersect) { - // For allOf: intersect non-null types, wrap in union with null if nullable + // For allOf, intersect the non-null types and add null when nullable const nonNullTypes = filteredTypes.filter((t) => t !== "null"); const intersectionType = maybeIntersection(...nonNullTypes); @@ -458,7 +447,7 @@ export function schemaToType( }; } - // For oneOf/anyOf: union all types (include null if present or if nullable) + // For oneOf and anyOf, union every type, adding null when nullable return { name, hasQuestionToken, @@ -470,8 +459,8 @@ export function schemaToType( }; } - // A document often leaves `type` off a schema that plainly describes an - // object, so the keys that only an object can carry stand in for it + // A document often omits `type` from a schema that plainly describes an + // object, so keys unique to objects stand in for it const describesObject = schemaObject.type === "object" || (schemaObject.type === undefined && @@ -521,8 +510,8 @@ export function schemaToType( typeof schemaObject.additionalProperties === "object" && schemaObject.additionalProperties !== null ) { - // The parent is empty because the value schema keys off nothing in it: - // a record value is always present, so it never takes a question token + // A record value is always present, so it stays required and the + // parent contributes an empty set of keys const value = schemaToType( typesAndInterfaces, {}, @@ -742,7 +731,7 @@ export function registerTypesFromSchema( ...objectTypesFromNonRefSchemas, ...nonObjectTypesFromNonRefSchemas .map((t) => - // a writer's text is not available here, so it cannot be wrapped + // a writer's text is unavailable here, so wrapping applies to strings t.isReadonly && typeof t.type === "string" ? `Readonly<${t.type}>` : t.type, @@ -768,7 +757,7 @@ export function registerTypesFromSchema( typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); } - // deal with type arrays (OpenAPI 3.1: type: ["string", "null"]) + // deal with type arrays, added in OpenAPI 3.1 else if (Array.isArray(schemaObject.type)) { const prop = schemaToType(typesAndInterfaces, {}, schemaName, schemaObject); @@ -812,10 +801,9 @@ export function registerTypesFromSchema( const newIf = typesFile.addTypeAlias({ name: pascalCase(schemaName), isExported: true, - // The same walk an inline schema takes, so a named schema and an + // Reuses the walk an inline schema takes, so a named schema and an // inline one of the same shape agree. It also spells the value type - // in TypeScript: the JSON Schema name for it is not always one, and - // `integer` is not + // in TypeScript, since JSON Schema names such as `integer` differ type: schemaToType(typesAndInterfaces, {}, schemaName, schemaObject).type ?? "Record", @@ -960,7 +948,5 @@ export function registerTypesFromSchema( `unsupported ${schemaObject.type} schema object: %j`, schemaObject, ); - - // throw new Error(`unsupported type "${schemaObject.type}"`); } } diff --git a/lib/valibot.ts b/lib/valibot.ts index 3643aaf..cacf0c3 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -13,16 +13,7 @@ import type { Primitive } from "type-fest"; import type * as v from "valibot"; import { wordWrap } from "./utils.ts"; -// Two variants per type, split by direction of data flow: -// -// input — for outgoing/TS-side values. Uses `v.optional(...)`, so callers can -// pass `{ foo: undefined }` (common in destructure-with-default patterns). -// No wire coercion since TS types are already native. -// -// wire — for incoming JSON-parsed values (server middleware, response -// parsing). Uses `v.exactOptional(...)` — undefined can't appear on the -// wire, so a field is either present-with-a-value or absent. Includes -// bigint / number coercion since JSON & HTTP carry those as strings +// input uses `v.optional` and skips coercion, wire uses `v.exactOptional` type SchemaMode = "input" | "wire"; type ValidatorEntry = { @@ -107,17 +98,7 @@ function minMaxProperties(schema: oas30.SchemaObject | oas31.SchemaObject) { const noTrimFormats = new Set(["uuid", "byte", "binary", "password"]); -// RFC 3339 temporal formats. The regex does the real runtime validation; the -// matching template-literal *type* is carried separately by `temporalHintSchema` -// (a `v.custom<...>`) so the schema's InferOutput equals the template-literal -// type emitted into types.ts by process-schema's `temporalStringType`. Without -// it the schema would infer bare `string`, diverging from the consumer-facing -// type — visible to `v.parse` / hono `c.req.valid()` callers. -// - date-time / time require an offset (`Z` or `±hh:mm`) — RFC 3339 has no -// bare-local form, unlike ISO 8601. -// - the seconds field permits a leap second (`60`). -// - `T`/`Z` may be lower-case and the date/time separator may be a space. -// - `duration` is the ISO 8601 grammar from RFC 3339 Appendix A +// RFC 3339 temporal formats, validated by regex at runtime function temporalRegexConstraint( format: string | undefined, ): WriterFunction | undefined { @@ -151,10 +132,7 @@ function temporalRegexConstraint( } } -// Template-literal type for each temporal format. Kept in lock-step with -// process-schema's `temporalStringType` (types.ts is the source of truth for -// consumer-facing types); duplicated rather than shared so neither generator -// has to import the other +// Template-literal type per temporal format, mirrored in process-schema function temporalTypeHint(format: string | undefined): string | undefined { switch (format) { case "date": @@ -174,9 +152,7 @@ function temporalTypeHint(format: string | undefined): string | undefined { } } -// `v.custom(() => true)` narrows the schema's inferred output type -// (the regex already validates), so a direct `v.parse(schema, x)` yields the -// template-literal type rather than bare `string` +// Narrows the inferred output to the template-literal type, past the regex function temporalHintSchema(format: string | undefined): string | undefined { const type = temporalTypeHint(format); return type ? `v.custom<${type}>(() => true)` : undefined; @@ -311,7 +287,7 @@ function schemaToValidator( ? `v.custom<${typescriptHint}>(() => true)` : undefined; - // Handle const values (OpenAPI 3.1: const: "value") + // Handle const values, added in OpenAPI 3.1 if ("const" in schema) { return schema.const === null ? vcall("null") @@ -321,11 +297,10 @@ function schemaToValidator( ); } - // Enums short-circuit every type-specific constraint. Whatever the declared - // type / format / minLength, the only valid values are the enum members, so - // emit a bare picklist — layering string()/minLength()/regex() on top yields - // a misleading "expected string" / "minLength" error when the real contract - // is simply "must be one of [...]" + // Enums short-circuit every type-specific constraint. Valid values are + // exactly the enum members, under any declared type, format or minLength. + // Layering string() or minLength() on top yields a misleading error about + // the wrong contract, so emit a bare picklist if (schema.enum) { const hasNull = schema.enum.some((value) => value === null); const members = schema.enum.filter((value) => value !== null); @@ -350,8 +325,8 @@ function schemaToValidator( ); } - // Boolean (and any other) literals go through `literal()` instead — or a - // `union` of them when there's more than one + // Boolean and other literals use `literal()` instead, or a `union` of + // them when there is more than one const base = rest.length === 0 ? vcall("literal", JSON.stringify(first)) @@ -363,7 +338,7 @@ function schemaToValidator( return maybeNullable(base, isNullable || hasNull); } - // Handle type arrays (OpenAPI 3.1: type: ["string", "null"]) + // Handle type arrays, added in OpenAPI 3.1 if (Array.isArray(schema.type)) { const nonNullTypes = schema.type.filter((t) => t !== "null"); const [singleType] = nonNullTypes; @@ -422,7 +397,7 @@ function schemaToValidator( schema.pattern ? vcall("regex", `new RegExp(${JSON.stringify(schema.pattern)})`) : undefined, - // An explicit x-typescript-hint wins over the format-derived hint + // A hint set by the `x-typescript-hint` extension wins over the format !typescriptHint ? temporalHintSchema(schema.format) : undefined, typescriptHintSchema, ), @@ -513,11 +488,11 @@ function schemaToValidator( const combinator = schema.oneOf || schema.anyOf || schema.allOf; if (combinator) { - // allOf of object schemas: compose into a single v.strictObject. Inline - // object members contribute their properties directly; $ref members are - // spread via `.entries`. v.intersect of strictObjects is - // unsatisfiable when member property sets differ (each strictObject - // independently rejects keys the others contribute) + // allOf of object schemas composes into a single v.strictObject. Inline + // object members contribute their properties directly, and $ref members + // are spread via `.entries`. v.intersect of strictObjects is + // unsatisfiable when member property sets differ, because each + // strictObject independently rejects keys the others contribute const allOfMembers = schema.allOf; if (allOfMembers) { @@ -559,9 +534,9 @@ function schemaToValidator( return; } - // Nested combinators / unusual shapes: recurse and spread the - // result's entries (valid as long as the recursion yields an - // object-like schema; otherwise GIGO) + // Nested combinators and unusual shapes recurse, spreading the + // result's entries. Valid as long as the recursion yields an + // object-like schema, and GIGO otherwise const validator = schemaToValidator(validators, member, mode); writer.write("..."); @@ -734,10 +709,10 @@ export function registerValidatorFromSchema( ], }); - // Wire schema — unless --input-only (parses incoming JSON: no undefined, - // with bigint/number coercion). Aliases to the input schema when the type - // has no coercion concerns and the only difference would be optional vs - // exactOptional — both are equivalent on JSON-parsed data anyway + // Wire schema, skipped under --input-only. It parses incoming JSON with + // bigint and number coercion. Aliases to the input schema when the type + // lacks coercion concerns and the difference would be optional versus + // exactOptional, which are equivalent on JSON-parsed data if (!inputOnly) { if (schemaNeedsCoercion(schemaObject)) { valibotFile.addVariableStatement({ @@ -765,11 +740,7 @@ export function registerValidatorFromSchema( } } -/** - * Wraps a validator with string-to-native coercion for HTTP params (query/header). - * These always arrive as strings on the wire, so coercion is justified. - * For non-numeric types, returns the validator unchanged - */ +/** Coerces HTTP param strings to native values, leaving other types alone */ function asHttpParamValidator( validatorSchemas: Map, schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, @@ -778,12 +749,12 @@ function asHttpParamValidator( return resolveRef(validatorSchemas, schema.$ref, "wire"); } - // The members of an object-valued query parameter reach the validator as - // strings for the same reason its scalar siblings do — they are bracket- - // encoded into the query string — so the coercion below has to reach them + // Members of an object-valued query parameter reach the validator as + // strings, for the same reason its scalar siblings do. They are bracket- + // encoded into the query string, so the coercion below has to reach them // too. Recursion stops at a `$ref`, which resolves to the one named schema - // emitted for the whole document and so cannot carry a query-only variant, - // and at shapes whose emission carries more than members and items + // emitted for the whole document and therefore lacks a query-only variant. + // It also stops at shapes that emit more than members and items if ( schema.type === "object" && schema.properties && diff --git a/vitest.config.ts b/vitest.config.ts index e4095b3..d6e59b9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - // no timeouts: a wrong mock should fail loudly on assertion or hang - // visibly, not get masked by a timer racing retry backoff + // a wrong mock should fail loudly on assertion or hang visibly, where a + // timer racing retry backoff would mask it testTimeout: 0, hookTimeout: 0, }, From 797814f6b2ea7a1368a458ce08293fae966879d9 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:03:12 +0800 Subject: [PATCH 05/14] test: reword comments to the shared prose rules Co-Authored-By: LLM --- __tests__/codegen-regressions.test.ts | 4 +-- __tests__/nullables.test.ts | 6 ++-- __tests__/openai.test.ts | 2 +- __tests__/petstore.test.ts | 2 +- __tests__/query-roundtrip.test.ts | 48 ++++++++++----------------- __tests__/test1.test.ts | 2 +- 6 files changed, 25 insertions(+), 39 deletions(-) diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index 5272276..d8dbba2 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -508,8 +508,8 @@ test("a oneOf query param keeps the stringish wire types in every branch", async ?.getTypeNode() ?.getText() ?? ""; - // The composition branch has to forward the codegen options the same way the - // array and object branches do, or a oneOf collapses back to the JSON types + // Composition has to forward the codegen options the same way the array + // and object branches do, or a oneOf collapses back to the JSON types expect(queryBlock).toContain("`${number}`"); expect(queryBlock).toContain('"true" | "false"'); expect(queryBlock).not.toMatch(/size\?: number/); diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index bb268c4..bf03270 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -139,7 +139,7 @@ test("RFC 3339 temporal formats", async () => { expect(valibot.match(/v\.regex\(/g)?.length).toBe(8); expect(valibot.match(/v\.custom(() => true)", ); @@ -152,14 +152,14 @@ test("enums short-circuit type constraints (picklist only)", async () => { paths: {}, components: { schemas: { - // integer enum with a range constraint: must NOT emit minValue/integer + // integer enum with a range constraint, which skips minValue and integer IntegerEnum: { type: "integer", enum: [0, 1, 2], minimum: 0, maximum: 9, }, - // string enum carrying minLength/format: must NOT emit minLength/regex + // string enum with minLength and format, which skips minLength and regex StringEnum: { type: "string", format: "email", diff --git a/__tests__/openai.test.ts b/__tests__/openai.test.ts index 59bea52..0489373 100644 --- a/__tests__/openai.test.ts +++ b/__tests__/openai.test.ts @@ -33,7 +33,7 @@ describe("OpenAI", () => { undiciFetch( // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Fix: remove when @types/node ships undici-types@8 + // Remove when @types/node ships undici-types@8 input, { ...init, dispatcher: mockAgent }, ), diff --git a/__tests__/petstore.test.ts b/__tests__/petstore.test.ts index 3afd016..52eef94 100644 --- a/__tests__/petstore.test.ts +++ b/__tests__/petstore.test.ts @@ -38,7 +38,7 @@ describe("Petstore", () => { undiciFetch( // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Fix: remove when @types/node ships undici-types@8 + // Remove when @types/node ships undici-types@8 input, { ...init, dispatcher: mockAgent }, ), diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index 96d35fc..8085061 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -8,16 +8,7 @@ import { processOpenApiDocument } from "../lib/process-document.ts"; import { listauditlogs } from "./fixtures/openai/hono.ts"; import { findPets } from "./fixtures/petstore/hono.ts"; -// A query parameter's `style` and `explode` decide how a client writes it into -// a query string, so they also decide how the server has to read it back. These -// take the exact query string a client sends for an operation, hand it to that -// same operation's generated middleware through a real Hono app, and check that -// what comes out of validation is what went in. -// -// The client half of each contract is pinned on the other side, by the -// "query string building" tests in @block65/rest-client — this repo installs a -// released copy of that package, which predates the style work, so the wire -// strings here are written out rather than generated +// Runs a real query string through the generated middleware and back out async function validatedQuery( middleware: readonly MiddlewareHandler[], search: string, @@ -25,17 +16,16 @@ async function validatedQuery( const res = await appFor(middleware).request(`/target?${search}`); const body = await res.clone().text(); - // the body says why a route rejected the query, so it rides along into the - // failure output + // the body says why a route rejected the query, so the failure output + // includes it expect({ status: res.status, body }).toMatchObject({ status: 200 }); return res.json(); } -// OpenAI's ListAuditLogs `effective_at` is an object and its document states no -// style, so OpenAPI's default applies: the members go out on their own, without -// the parent name, and only the member list the document declares can put them -// back together +// OpenAI's ListAuditLogs `effective_at` is an object and its document states +// no style. OpenAPI's default sends the members without the parent name, and +// the declared member list is what puts them back together test("an object query parameter under the default style survives the round trip", async () => { const query = { effective_at: { gt: 1700000000, lte: 1700000100 }, @@ -59,7 +49,7 @@ test("an object query parameter under the default style survives the round trip" ); }); -// the parent stays absent rather than arriving as an empty object +// an absent parent stays absent, and never arrives as an empty object test("an absent object query parameter does not materialise", async () => { await expect(validatedQuery(listauditlogs, "limit=5")).resolves.toStrictEqual( { @@ -82,14 +72,10 @@ test("an array query parameter with one value is still an array", async () => { }); }); -// Nothing in the corpus declares `deepObject` or puts `explode: false` on an -// object, so those shapes have no fixture to borrow: the server is generated -// from a document written here, written to disk and imported, so the round trip -// runs real generated code rather than matching against its text +// Root for documents generated, written to disk and imported by these tests const generatedRoot = join(import.meta.dirname, ".generated"); -// The validated data's key is not visible through a spread of middleware, so -// the handler reads it untyped +// Mounts the middleware on a Hono app, with an untyped handler reading it function appFor(middleware: readonly MiddlewareHandler[]) { const app = new Hono(); @@ -179,7 +165,7 @@ test("a deepObject parameter survives the round trip as bracket keys", async () }); }); -// the collision the default style cannot express, which deepObject can +// deepObject expresses a collision that the default style flattens away test("two deepObject parameters sharing a member name stay apart", async () => { const middleware = await serverFor("deep-object-pair", [ { name: "created", in: "query", style: "deepObject", schema: rangeSchema }, @@ -250,8 +236,8 @@ test("a malformed bracket key is rejected by name rather than reinterpreted", as await expect(res.text()).resolves.toContain("at[gt"); }); -// The document is the only thing that says where a member belongs, so a -// parameter that declares no style is worth saying out loud +// Member placement comes from the document alone, so an undeclared style is +// worth saying out loud test("an object query parameter with no declared style is warned about", async () => { const warnings: string[] = []; const original = console.warn; @@ -270,8 +256,8 @@ test("an object query parameter with no declared style is warned about", async ( ); }); -// Two such parameters cannot be told apart once their members lose the parent -// name, and no encoding this generator could pick would change that +// Two such parameters read alike once their members lose the parent name, and +// every encoding this generator could pick keeps them alike test("two default-style object parameters sharing a member name are warned about", async () => { const warnings: string[] = []; const original = console.warn; @@ -325,7 +311,7 @@ async function warningsFrom(parameters: oas31.ParameterObject[]) { } // OpenAPI marks these n/a and leaves them undefined, so the generator says so -// rather than both sides confidently producing something different +// before both sides confidently produce something different test("style and explode combinations the spec leaves undefined are warned about", async () => { const arrayOfStrings = { type: "array", @@ -356,8 +342,8 @@ test("style and explode combinations the spec leaves undefined are warned about" ).resolves.toContain("is an array with `style: deepObject`"); }); -// `in: "querystring"` matches no branch in the parameter loop, so without a -// word from the generator the operation silently loses its query entirely +// `in: "querystring"` matches no branch in the parameter loop, so a warning +// is what keeps the operation from silently losing its query test("an in: querystring parameter is warned about rather than dropped in silence", async () => { const warning = await warningsFrom([ { diff --git a/__tests__/test1.test.ts b/__tests__/test1.test.ts index d909d13..b00aac4 100644 --- a/__tests__/test1.test.ts +++ b/__tests__/test1.test.ts @@ -45,7 +45,7 @@ describe("Test1", () => { undiciFetch( // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Fix: remove when @types/node ships undici-types@8 + // Remove when @types/node ships undici-types@8 input, { ...init, dispatcher: mockAgent }, ), From b1bf22f29c9db55cb96514030daaf09a406476a0 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:05:13 +0800 Subject: [PATCH 06/14] refactor: drop return types that inference already gives Three stay: `QueryParamSpec | undefined`, `[string, string]` and `WriterFunction` supply the contextual type over an object literal, and removing them widens the literal properties. Co-Authored-By: LLM --- __tests__/codegen-regressions.test.ts | 5 +---- __tests__/query-roundtrip.test.ts | 7 ++----- bin/index.ts | 2 +- lib/build.ts | 4 ++-- lib/hono.ts | 2 +- lib/process-document.ts | 2 +- lib/process-schema.ts | 8 ++++---- lib/utils.ts | 2 +- lib/valibot.ts | 16 ++++++---------- 9 files changed, 19 insertions(+), 29 deletions(-) diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index d8dbba2..8e6b422 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -151,10 +151,7 @@ test("AllInputs union includes every command's Input (no silent drops)", async ( expect(missing).toEqual([]); }); -function docWithSchema( - name: string, - schema: oas31.SchemaObject, -): oas31.OpenAPIObject { +function docWithSchema(name: string, schema: oas31.SchemaObject) { return { openapi: "3.1.0", info: { title: "Test", version: "1.0.0" }, diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index 8085061..e4fb0f2 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -12,7 +12,7 @@ import { findPets } from "./fixtures/petstore/hono.ts"; async function validatedQuery( middleware: readonly MiddlewareHandler[], search: string, -): Promise { +) { const res = await appFor(middleware).request(`/target?${search}`); const body = await res.clone().text(); @@ -88,10 +88,7 @@ function appFor(middleware: readonly MiddlewareHandler[]) { return app; } -async function serverFor( - name: string, - parameters: oas31.ParameterObject[], -): Promise { +async function serverFor(name: string, parameters: oas31.ParameterObject[]) { const document: oas31.OpenAPIObject = { openapi: "3.1.0", info: { title: "Test", version: "1.0.0" }, diff --git a/bin/index.ts b/bin/index.ts index 30aab91..79d864f 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -22,7 +22,7 @@ const cliArgs = await yargs(hideBin(process.argv)) alias: "t", type: "array", description: "tags", - coerce(arg: string[] | string): string[] { + coerce(arg: string[] | string) { return Array.isArray(arg) ? arg : [arg]; }, }) diff --git a/lib/build.ts b/lib/build.ts index 1ebb629..ea595f9 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -17,11 +17,11 @@ const BANNER = `/** // Records emitter output so a file reformatted on disk still compares equal const MANIFEST = ".openapi-codegen-manifest.json"; -function revision(text: string): string { +function revision(text: string) { return createHash("sha256").update(text).digest("hex").slice(0, 32); } -async function readManifest(path: string): Promise> { +async function readManifest(path: string) { const text = await readFile(path, "utf8").catch(() => {}); if (text === undefined) { diff --git a/lib/hono.ts b/lib/hono.ts index ce94e06..2dc2dc0 100644 --- a/lib/hono.ts +++ b/lib/hono.ts @@ -58,7 +58,7 @@ export function createHonoFile( } // Inverts the OAS 3.2 §4.12.6 style table to decode Hono's flat query map -function addQueryDecoder(file: SourceFile): void { +function addQueryDecoder(file: SourceFile) { file.addStatements(` type QueryParamSpec = { readonly name: string; diff --git a/lib/process-document.ts b/lib/process-document.ts index 6d594cb..47c203f 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -965,7 +965,7 @@ export async function processOpenApiDocument( // under exactOptionalPropertyTypes. A non-JSON `body` field holds // a BodyInit class instance, which UndefinedOnPartialDeep would // mangle, so widen everything else and re-intersect `body` - const inputTypeArg = ((): string => { + const inputTypeArg = (() => { if (!inputType) { return unspecifiedKeyword; } diff --git a/lib/process-schema.ts b/lib/process-schema.ts index f338e6f..c42ab4d 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -25,7 +25,7 @@ function maybeWithNullUnion(type: string | WriterFunction, withNull = false) { } // Template-literal type per RFC 3339 temporal format, mirrored in valibot -function temporalStringType(format: string | undefined): string | undefined { +function temporalStringType(format: string | undefined) { switch (format) { case "date": // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type @@ -45,7 +45,7 @@ function temporalStringType(format: string | undefined): string | undefined { } // int64 maps to bigint, and every other integer or number maps to number -function numericType(isInt64: boolean, stringish: boolean | undefined): string { +function numericType(isInt64: boolean, stringish: boolean | undefined) { if (isInt64) { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return stringish ? "`${bigint}`" : "bigint"; @@ -104,7 +104,7 @@ function maybeUnion(...types: (string | WriterFunction)[]) { : Writers.unionType(first, second, ...rest); } -function recordType(value: string | WriterFunction): WriterFunction { +function recordType(value: string | WriterFunction) { return (writer: CodeBlockWriter) => { writer.write("Record(() => true)` : undefined; } -function stringNeedsCoercion( - schema: oas30.SchemaObject | oas31.SchemaObject, -): boolean { +function stringNeedsCoercion(schema: oas30.SchemaObject | oas31.SchemaObject) { return ( !schema.enum && !schema.pattern && @@ -170,7 +166,7 @@ function stringNeedsCoercion( function propertiesNeedCoercion( schema: oas30.SchemaObject | oas31.SchemaObject, -): boolean { +) { const properties = schema.properties ?? {}; const required = new Set(schema.required ?? []); const hasOptional = Object.keys(properties).some((k) => !required.has(k)); @@ -212,7 +208,7 @@ function resolveRef( validators: Map, ref: string, mode: SchemaMode, -): string | WriterFunction { +) { const entry = validators.get(ref); if (!entry) { return vcall("unknown"); From e68612bd0cdc532d42376d01624fdfb2a8081c0f Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:08:38 +0800 Subject: [PATCH 07/14] refactor: narrow where a cast was standing in for a guard Query style goes through a type predicate, a parameter schema through a `$ref` check, `in: "querystring"` through a widened local, and the CLI tags through `Array.isArray`. The casts left at a boundary the checker cannot see now carry a TYPESAFETY reason. Co-Authored-By: LLM --- __tests__/query-roundtrip.test.ts | 27 +++++++++++++++++++++++---- bin/index.ts | 6 +++++- lib/build.ts | 5 +++++ lib/process-document.ts | 28 +++++++++++++++++++++++++--- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index e4fb0f2..fb53c9f 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -83,12 +83,24 @@ function appFor(middleware: readonly MiddlewareHandler[]) { app.use("/target", handler); } + // TYPESAFETY: `c.req.valid` keys off the validator types a route was built + // with, and these middleware arrive as an opaque array, so the key is + // unreachable through the spread. app.get("/target", (c) => c.json(c.req.valid("query" as never))); return app; } -async function serverFor(name: string, parameters: oas31.ParameterObject[]) { +// OAS 3.2 added `in: "querystring"`, which the 3.1 types predate +type TestParameter = + | oas31.ParameterObject + | { + name: string; + in: "querystring"; + content: oas31.ParameterObject["content"]; + }; + +async function serverFor(name: string, parameters: readonly TestParameter[]) { const document: oas31.OpenAPIObject = { openapi: "3.1.0", info: { title: "Test", version: "1.0.0" }, @@ -96,7 +108,10 @@ async function serverFor(name: string, parameters: oas31.ParameterObject[]) { "/things": { get: { operationId: "listThingsCommand", - parameters, + // TYPESAFETY: `TestParameter` widens the 3.1 union by the one 3.2 + // location these tests exercise, and the generator reads `in` as a + // string. + parameters: parameters as oas31.ParameterObject[], responses: { "200": { description: "OK", @@ -115,6 +130,8 @@ async function serverFor(name: string, parameters: oas31.ParameterObject[]) { await mkdir(outputDir, { recursive: true }); await Promise.all([result.honoFile.save(), result.valibotFile.save()]); + // TYPESAFETY: a dynamic import is typed `any`, and the exports are read by + // name below rather than trusted as a shape. const module = (await import(join(outputDir, "hono.ts"))) as Record< string, unknown @@ -128,6 +145,8 @@ async function serverFor(name: string, parameters: oas31.ParameterObject[]) { expect(middleware).toBeDefined(); + // TYPESAFETY: the generator emits one array export per operation, and the + // `toBeDefined` above fails the test before this runs if it is missing. return middleware as readonly MiddlewareHandler[]; } @@ -293,7 +312,7 @@ test("two default-style object parameters sharing a member name are warned about ); }); -async function warningsFrom(parameters: oas31.ParameterObject[]) { +async function warningsFrom(parameters: readonly TestParameter[]) { const warnings: string[] = []; const original = console.warn; console.warn = (message: string) => warnings.push(message); @@ -347,7 +366,7 @@ test("an in: querystring parameter is warned about rather than dropped in silenc name: "whole", in: "querystring", content: { "application/json": { schema: { type: "object" } } }, - } as unknown as oas31.ParameterObject, + }, ]); expect(warning).toContain("uses `in: querystring`"); diff --git a/bin/index.ts b/bin/index.ts index 79d864f..3e0250c 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -35,9 +35,13 @@ const cliArgs = await yargs(hideBin(process.argv)) }) .help().argv; +const tags = Array.isArray(cliArgs.tags) + ? cliArgs.tags.map((tag) => String(tag)) + : undefined; + await build( join(process.cwd(), String(cliArgs.i)), join(process.cwd(), String(cliArgs.o)), - cliArgs.t as Array, + tags, { inputOnly: Boolean(cliArgs.inputOnly) }, ); diff --git a/lib/build.ts b/lib/build.ts index ea595f9..7ccba5b 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -30,6 +30,9 @@ async function readManifest(path: string) { try { const parsed: unknown = JSON.parse(text); + // TYPESAFETY: this file is only ever written by `writeManifest` below, + // which stores a string revision per path. A hand-edited one degrades to + // a rewrite of every file, which is what an absent manifest already does. return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; @@ -44,6 +47,8 @@ export async function build( tags?: string[], options?: CodegenOptions, ) { + // TYPESAFETY: a JSON import is typed `any`, and the document is validated + // by `$RefParser` downstream rather than here. const apischema = (await import(inputFile, { with: { type: "json" }, })) as { default: oas31.OpenAPIObject }; diff --git a/lib/process-document.ts b/lib/process-document.ts index 47c203f..c269ce4 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -58,9 +58,23 @@ type OperationMiddlewareInfo = { queryParams: QueryParamSpec[]; }; +const queryStyles = [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject", +] as const; + +function isQueryStyle(style: string): style is QueryParamSpec["style"] { + return queryStyles.some((candidate) => candidate === style); +} + // Resolves the OAS 3.2 §4.12.6 style and explode defaults for a parameter function queryParameterEncoding(parameter: oas30.ParameterObject) { - const style = (parameter.style ?? "form") as QueryParamSpec["style"]; + const style = + parameter.style !== undefined && isQueryStyle(parameter.style) + ? parameter.style + : "form"; return { style, @@ -73,7 +87,10 @@ function queryParameterEncoding(parameter: oas30.ParameterObject) { function queryParameterSpec( parameter: oas30.ParameterObject, ): QueryParamSpec | undefined { - const schema = (parameter.schema ?? {}) as oas30.SchemaObject; + const schema: oas30.SchemaObject = + parameter.schema !== undefined && !("$ref" in parameter.schema) + ? parameter.schema + : {}; const { style, explode } = queryParameterEncoding(parameter); if (schema.type === "array") { @@ -516,6 +533,9 @@ export async function processOpenApiDocument( ...(operationObject.parameters || []), ...(pathItemObject.parameters || []), ]) { + // TYPESAFETY: `$RefParser.resolve` types every target as + // `unknown`, and this pointer came from a parameter list, so the + // document declares it as a parameter. const resolvedParameter = ( "$ref" in parameter ? refs.get(parameter.$ref) : parameter ) as oas30.ParameterObject; @@ -556,7 +576,9 @@ export async function processOpenApiDocument( // way to express. A warning is all that is left, since a valid // document would otherwise generate an operation with its query // silently dropped - if (resolvedParameter.in === ("querystring" as string)) { + const parameterIn: string = resolvedParameter.in; + + if (parameterIn === "querystring") { console.warn( `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support — the operation is generated with no query at all. Declare the members as \`in: query\` parameters instead.`, ); From c03c956a0d4eca7a415b7e13af98c38861b9130d Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:09:37 +0800 Subject: [PATCH 08/14] docs: reword the TYPESAFETY comments to the prose rules Co-Authored-By: LLM --- __tests__/query-roundtrip.test.ts | 14 +++++++------- lib/build.ts | 11 ++++++----- lib/process-document.ts | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index fb53c9f..19dd8a9 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -83,9 +83,9 @@ function appFor(middleware: readonly MiddlewareHandler[]) { app.use("/target", handler); } - // TYPESAFETY: `c.req.valid` keys off the validator types a route was built - // with, and these middleware arrive as an opaque array, so the key is - // unreachable through the spread. + // TYPESAFETY: `c.req.valid` reads the key from the validator types a route + // was built with, and these middleware arrive as an opaque array, so the + // key is unreachable through the spread app.get("/target", (c) => c.json(c.req.valid("query" as never))); return app; @@ -110,7 +110,7 @@ async function serverFor(name: string, parameters: readonly TestParameter[]) { operationId: "listThingsCommand", // TYPESAFETY: `TestParameter` widens the 3.1 union by the one 3.2 // location these tests exercise, and the generator reads `in` as a - // string. + // string parameters: parameters as oas31.ParameterObject[], responses: { "200": { @@ -130,8 +130,8 @@ async function serverFor(name: string, parameters: readonly TestParameter[]) { await mkdir(outputDir, { recursive: true }); await Promise.all([result.honoFile.save(), result.valibotFile.save()]); - // TYPESAFETY: a dynamic import is typed `any`, and the exports are read by - // name below rather than trusted as a shape. + // TYPESAFETY: a dynamic import is typed `any`, and the code below picks the + // one array export by inspection const module = (await import(join(outputDir, "hono.ts"))) as Record< string, unknown @@ -146,7 +146,7 @@ async function serverFor(name: string, parameters: readonly TestParameter[]) { expect(middleware).toBeDefined(); // TYPESAFETY: the generator emits one array export per operation, and the - // `toBeDefined` above fails the test before this runs if it is missing. + // `toBeDefined` above fails the test before this runs if it is missing return middleware as readonly MiddlewareHandler[]; } diff --git a/lib/build.ts b/lib/build.ts index 7ccba5b..8d92a5e 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -30,9 +30,10 @@ async function readManifest(path: string) { try { const parsed: unknown = JSON.parse(text); - // TYPESAFETY: this file is only ever written by `writeManifest` below, - // which stores a string revision per path. A hand-edited one degrades to - // a rewrite of every file, which is what an absent manifest already does. + + // TYPESAFETY: `writeManifest` below writes this file, storing a string + // revision per path. A hand-edited file degrades to a rewrite of every + // file, the same as an absent manifest return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; @@ -47,8 +48,8 @@ export async function build( tags?: string[], options?: CodegenOptions, ) { - // TYPESAFETY: a JSON import is typed `any`, and the document is validated - // by `$RefParser` downstream rather than here. + // TYPESAFETY: a JSON import is typed `any`, and `$RefParser` validates the + // document before the generator reads it const apischema = (await import(inputFile, { with: { type: "json" }, })) as { default: oas31.OpenAPIObject }; diff --git a/lib/process-document.ts b/lib/process-document.ts index c269ce4..8ba9c02 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -535,7 +535,7 @@ export async function processOpenApiDocument( ]) { // TYPESAFETY: `$RefParser.resolve` types every target as // `unknown`, and this pointer came from a parameter list, so the - // document declares it as a parameter. + // document declares it as a parameter const resolvedParameter = ( "$ref" in parameter ? refs.get(parameter.$ref) : parameter ) as oas30.ParameterObject; From 12b371b86da1b7f05caf5f25011fcfe3e56688a0 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:13:46 +0800 Subject: [PATCH 09/14] refactor: clear the remaining small lint warnings Manifest revisions build from one pass over the files, so the writes no longer await in sequence and the record is complete where it is made. `typedEntries` keeps the middleware target keys through `Object.entries`. The regression tests fail on an absent match instead of falling back to `""`, which had been letting a later assertion pass for the wrong reason. Co-Authored-By: LLM --- __tests__/codegen-regressions.test.ts | 47 ++++++++++++++++------ lib/build.ts | 58 +++++++++++++-------------- lib/hono.ts | 5 ++- lib/process-document.ts | 9 +++-- lib/utils.ts | 13 +++++- 5 files changed, 84 insertions(+), 48 deletions(-) diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index 8e6b422..d52e7f4 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -2,6 +2,15 @@ import type { oas31 } from "openapi3-ts"; import { expect, test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +// Fails the test on an absent value, where `""` would pass a later assertion +function required(value: T | undefined, what: string) { + if (value === undefined) { + throw new Error(`the generator emitted no ${what}`); + } + + return value; +} + const respOk = { "200": { description: "OK", @@ -78,9 +87,10 @@ test("optional query params do not carry `| undefined` in their property type", const result = await processOpenApiDocument("/tmp/whatever", schema); const typesText = result.typesFile.getText(); - const queryBlock = - typesText.match(/export type ListFilesCommandQuery = \{[\s\S]*?\};/)?.[0] ?? - ""; + const queryBlock = required( + typesText.match(/export type ListFilesCommandQuery = \{[\s\S]*?\};/)?.[0], + "ListFilesCommandQuery", + ); expect(queryBlock).toContain("purpose?: string"); expect(queryBlock).toContain("limit?: `${number}`"); @@ -138,11 +148,14 @@ test("AllInputs union includes every command's Input (no silent drops)", async ( const mainText = result.mainFile.getText(); const commandsText = result.commandsFile.getText(); - const allInputsBlock = mainText.match(/type AllInputs =[\s\S]*?;/)?.[0] ?? ""; + const allInputsBlock = required( + mainText.match(/type AllInputs =[\s\S]*?;/)?.[0], + "AllInputs", + ); const commandNames = [ ...commandsText.matchAll(/^export class (\w+Command) extends Command m[1] ?? ""); + ].map((m) => required(m[1], "command name")); expect(commandNames.length).toBeGreaterThan(0); const missing = commandNames.filter( @@ -332,11 +345,13 @@ test("an array request body with parameters stays readable as both", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const inputBlock = + const inputBlock = required( result.typesFile .getTypeAlias("PluginPullCommandInput") ?.getTypeNode() - ?.getText() ?? ""; + ?.getText(), + "PluginPullCommandInput", + ); expect(inputBlock).toContain("PluginPullCommandBodyWrapper"); expect(result.commandsFile.getText()).toMatch( @@ -387,11 +402,13 @@ test("nested query param members get the same stringish treatment as top-level o }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = + const queryBlock = required( result.typesFile .getTypeAlias("SearchCommandQuery") ?.getTypeNode() - ?.getText() ?? ""; + ?.getText(), + "SearchCommandQuery", + ); expect(queryBlock).toMatch(/limit\?: `\$\{number\}`/); expect(queryBlock).toMatch(/"age"\?: `\$\{number\}`/); @@ -438,11 +455,13 @@ test("json request body members keep their real JSON types, nested included", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const bodyBlock = + const bodyBlock = required( result.typesFile .getTypeAlias("CreateContainerCommandJsonBody") ?.getTypeNode() - ?.getText() ?? ""; + ?.getText(), + "CreateContainerCommandJsonBody", + ); expect(bodyBlock).toMatch(/"tty"\?: boolean/); expect(bodyBlock).toMatch(/"retries"\?: number/); @@ -499,11 +518,13 @@ test("a oneOf query param keeps the stringish wire types in every branch", async }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = + const queryBlock = required( result.typesFile .getTypeAlias("ListThingsCommandQuery") ?.getTypeNode() - ?.getText() ?? ""; + ?.getText(), + "ListThingsCommandQuery", + ); // Composition has to forward the codegen options the same way the array // and object branches do, or a oneOf collapses back to the JSON types diff --git a/lib/build.ts b/lib/build.ts index 8d92a5e..b8ef21a 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -17,10 +17,6 @@ const BANNER = `/** // Records emitter output so a file reformatted on disk still compares equal const MANIFEST = ".openapi-codegen-manifest.json"; -function revision(text: string) { - return createHash("sha256").update(text).digest("hex").slice(0, 32); -} - async function readManifest(path: string) { const text = await readFile(path, "utf8").catch(() => {}); @@ -80,33 +76,37 @@ export async function build( const manifestPath = join(outputDir, MANIFEST); const previous = await readManifest(manifestPath); - const next: Record = {}; - - for (const file of files) { - try { - file.formatText(); - } catch (err) { - console.warn(err); - } - - const contents = `${BANNER}\n${file.getFullText()}`; - const name = file.getBaseName(); - const rev = revision(contents); - - next[name] = rev; - - const unchanged = - previous[name] === rev && - (await readFile(file.getFilePath(), "utf8") + const revisions = await Promise.all( + files.map(async (file) => { + try { + file.formatText(); + } catch (err) { + console.warn(err); + } + + const contents = `${BANNER}\n${file.getFullText()}`; + const name = file.getBaseName(); + const rev = createHash("sha256") + .update(contents) + .digest("hex") + .slice(0, 32); + + const present = await readFile(file.getFilePath(), "utf8") .then(() => true) - .catch(() => false)); + .catch(() => false); - if (unchanged) { - continue; - } + if (previous[name] !== rev || !present) { + await writeFile(file.getFilePath(), contents); + } - await writeFile(file.getFilePath(), contents); - } + return { name, rev }; + }), + ); + + const next = new Map(revisions.map(({ name, rev }) => [name, rev])); - await writeFile(manifestPath, `${JSON.stringify(next, null, "\t")}\n`); + await writeFile( + manifestPath, + `${JSON.stringify(Object.fromEntries(next), null, "\t")}\n`, + ); } diff --git a/lib/hono.ts b/lib/hono.ts index 2dc2dc0..af126a8 100644 --- a/lib/hono.ts +++ b/lib/hono.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import camelcase from "camelcase"; import type { Project, SourceFile } from "ts-morph"; import { VariableDeclarationKind } from "ts-morph"; +import { typedEntries } from "./utils.ts"; /** * `hasQueryValidator` controls whether the query decoder is emitted. @@ -331,7 +332,7 @@ export function createHonoMiddleware( // Hono validators run on inbound request data alone. Response // schemas are emitted for client-side consumption, and `header` // is skipped so extra HTTP headers pass - for (const [target, schemaName] of Object.entries(schemas).filter( + for (const [target, schemaName] of typedEntries(schemas).filter( ([t]) => t !== "header" && t !== "response", )) { const value = @@ -353,7 +354,7 @@ export function createHonoMiddleware( export function addSchemaImportsToHonoFile( honoFile: SourceFile, schemaNames: string[], -): void { +) { if (schemaNames.length === 0) { return; } diff --git a/lib/process-document.ts b/lib/process-document.ts index 8ba9c02..b6501fc 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -69,6 +69,11 @@ function isQueryStyle(style: string): style is QueryParamSpec["style"] { return queryStyles.some((candidate) => candidate === style); } +// OAS 3.2 added this location, so the 3.0 union this generator reads omits it +function isQuerystringLocation(location: string) { + return location === "querystring"; +} + // Resolves the OAS 3.2 §4.12.6 style and explode defaults for a parameter function queryParameterEncoding(parameter: oas30.ParameterObject) { const style = @@ -576,9 +581,7 @@ export async function processOpenApiDocument( // way to express. A warning is all that is left, since a valid // document would otherwise generate an operation with its query // silently dropped - const parameterIn: string = resolvedParameter.in; - - if (parameterIn === "querystring") { + if (isQuerystringLocation(resolvedParameter.in)) { console.warn( `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support — the operation is generated with no query at all. Declare the members as \`in: query\` parameters instead.`, ); diff --git a/lib/utils.ts b/lib/utils.ts index 07f2fe8..3bb1512 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,7 +20,9 @@ export function isNotNullOrUndefined(obj: T | null | undefined): obj is T { return obj !== null && obj !== undefined; } -const strOnly = (x: string | undefined): x is string => typeof x === "string"; +function strOnly(x: string | undefined): x is string { + return typeof x === "string"; +} export function getDependents( obj: oas31.ReferenceObject | oas31.SchemaObject, @@ -79,3 +81,12 @@ export function castToValidJsIdentifier(name: string) { export function iife(fn: () => T): T { return fn(); } + +export function typedEntries( + obj: T, +): [keyof T & string, T[keyof T]][] { + // TYPESAFETY: `Object.entries` types every key as `string`, and this puts + // back what `T` declares. An index signature on `T` would widen them again, + // and every caller passes a closed object type + return Object.entries(obj) as [keyof T & string, T[keyof T]][]; +} From c5cfc9d6b33691c2df94e2d47a22c6b03935dd01 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:16:02 +0800 Subject: [PATCH 10/14] test: drop the undici-types @ts-expect-error workarounds @types/node 26.6.1 depends on undici-types ~8.9.0, which is the condition the comments named, and tsc now reports all three directives as unused. Co-Authored-By: LLM --- __tests__/openai.test.ts | 3 --- __tests__/petstore.test.ts | 3 --- __tests__/test1.test.ts | 3 --- 3 files changed, 9 deletions(-) diff --git a/__tests__/openai.test.ts b/__tests__/openai.test.ts index 0489373..7193d96 100644 --- a/__tests__/openai.test.ts +++ b/__tests__/openai.test.ts @@ -31,9 +31,6 @@ describe("OpenAI", () => { retry: { retries: 0 }, fetch: (input, init) => undiciFetch( - // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we - // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Remove when @types/node ships undici-types@8 input, { ...init, dispatcher: mockAgent }, ), diff --git a/__tests__/petstore.test.ts b/__tests__/petstore.test.ts index 52eef94..280cff3 100644 --- a/__tests__/petstore.test.ts +++ b/__tests__/petstore.test.ts @@ -36,9 +36,6 @@ describe("Petstore", () => { retry: { retries: 0 }, fetch: (input, init) => undiciFetch( - // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we - // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Remove when @types/node ships undici-types@8 input, { ...init, dispatcher: mockAgent }, ), diff --git a/__tests__/test1.test.ts b/__tests__/test1.test.ts index b00aac4..455f8e5 100644 --- a/__tests__/test1.test.ts +++ b/__tests__/test1.test.ts @@ -43,9 +43,6 @@ describe("Test1", () => { retry: { retries: 0 }, fetch: (input, init) => undiciFetch( - // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we - // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Remove when @types/node ships undici-types@8 input, { ...init, dispatcher: mockAgent }, ), From 217b13025be9369eb5dfc7a7d661b5f659b454b7 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 12:19:54 +0800 Subject: [PATCH 11/14] fix: restore the reasons that collapsing the comments dropped The one-line budget for a comment above a private declaration left no room for why `deepObject` normalises its explode, why int64 maps to bigint, what RFC 3339 requires beyond ISO 8601, or why these tests write their wire strings out by hand. Those move into the function bodies, where the budget does not apply. `return undefined` became a lint error once the return annotations came off, so the six sites return bare. Co-Authored-By: LLM --- __tests__/openai.test.ts | 5 +---- __tests__/petstore.test.ts | 5 +---- __tests__/query-roundtrip.test.ts | 12 ++++++++++-- __tests__/test1.test.ts | 5 +---- lib/process-document.ts | 3 +++ lib/process-schema.ts | 10 ++++++---- lib/valibot.ts | 11 +++++++++-- 7 files changed, 31 insertions(+), 20 deletions(-) diff --git a/__tests__/openai.test.ts b/__tests__/openai.test.ts index 7193d96..355c761 100644 --- a/__tests__/openai.test.ts +++ b/__tests__/openai.test.ts @@ -30,10 +30,7 @@ describe("OpenAI", () => { fetcher: createIsomorphicNativeFetcher({ retry: { retries: 0 }, fetch: (input, init) => - undiciFetch( - input, - { ...init, dispatcher: mockAgent }, - ), + undiciFetch(input, { ...init, dispatcher: mockAgent }), }), }); diff --git a/__tests__/petstore.test.ts b/__tests__/petstore.test.ts index 280cff3..4456f59 100644 --- a/__tests__/petstore.test.ts +++ b/__tests__/petstore.test.ts @@ -35,10 +35,7 @@ describe("Petstore", () => { fetcher: createIsomorphicNativeFetcher({ retry: { retries: 0 }, fetch: (input, init) => - undiciFetch( - input, - { ...init, dispatcher: mockAgent }, - ), + undiciFetch(input, { ...init, dispatcher: mockAgent }), }), }); const command = new FindPetsCommand({ diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index 19dd8a9..1a3d16b 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -13,6 +13,10 @@ async function validatedQuery( middleware: readonly MiddlewareHandler[], search: string, ) { + // Coverage of the client half lives with the "query string building" tests + // in @block65/rest-client. This repo installs a released + // copy of that package, which predates the style work, so the wire strings + // here are written out by hand const res = await appFor(middleware).request(`/target?${search}`); const body = await res.clone().text(); @@ -101,6 +105,10 @@ type TestParameter = }; async function serverFor(name: string, parameters: readonly TestParameter[]) { + // Nowhere in the corpus does `deepObject` or `explode: false` appear on an + // object, so those shapes need a document of their own. Generating it and importing + // the result exercises the emitted code, where a text match would only read + // it const document: oas31.OpenAPIObject = { openapi: "3.1.0", info: { title: "Test", version: "1.0.0" }, @@ -145,8 +153,8 @@ async function serverFor(name: string, parameters: readonly TestParameter[]) { expect(middleware).toBeDefined(); - // TYPESAFETY: the generator emits one array export per operation, and the - // `toBeDefined` above fails the test before this runs if it is missing + // TYPESAFETY: the generator emits one array export per operation, and + // `toBeDefined` fails the test on a missing one return middleware as readonly MiddlewareHandler[]; } diff --git a/__tests__/test1.test.ts b/__tests__/test1.test.ts index 455f8e5..35b19ae 100644 --- a/__tests__/test1.test.ts +++ b/__tests__/test1.test.ts @@ -42,10 +42,7 @@ describe("Test1", () => { fetcher: createIsomorphicNativeFetcher({ retry: { retries: 0 }, fetch: (input, init) => - undiciFetch( - input, - { ...init, dispatcher: mockAgent }, - ), + undiciFetch(input, { ...init, dispatcher: mockAgent }), }), }); const command = new GetBillingAccountCommand({ diff --git a/lib/process-document.ts b/lib/process-document.ts index b6501fc..4925339 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -76,6 +76,9 @@ function isQuerystringLocation(location: string) { // Resolves the OAS 3.2 §4.12.6 style and explode defaults for a parameter function queryParameterEncoding(parameter: oas30.ParameterObject) { + // OpenAPI defaults `style` to `form`, and `explode` to true for `form` and + // false elsewhere. OAS 3.2 marks `explode` n/a for `deepObject`, so it is + // normalised to true here and both sides of the generated code ignore it const style = parameter.style !== undefined && isQueryStyle(parameter.style) ? parameter.style diff --git a/lib/process-schema.ts b/lib/process-schema.ts index c42ab4d..db0c0b6 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -40,12 +40,14 @@ function temporalStringType(format: string | undefined) { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return "`P${string}`"; default: - return undefined; + return; } } // int64 maps to bigint, and every other integer or number maps to number function numericType(isInt64: boolean, stringish: boolean | undefined) { + // int64 reaches past Number.MAX_SAFE_INTEGER, and query, header, path and + // body values arrive as strings, hence the `${bigint}` wire form if (isInt64) { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return stringish ? "`${bigint}`" : "bigint"; @@ -129,7 +131,7 @@ function literalUnionType( const objects = schemaItems.filter(isNotReferenceObject); if (objects.length !== schemaItems.length || objects.length < 2) { - return undefined; + return; } const bare = objects.filter((schema) => !schema.enum); @@ -137,11 +139,11 @@ function literalUnionType( const [base] = bare; if (bare.length !== 1 || enums.length === 0 || base?.type !== "string") { - return undefined; + return; } if (!enums.every((schema) => schema.type === "string")) { - return undefined; + return; } const values = [ diff --git a/lib/valibot.ts b/lib/valibot.ts index d2683e1..2dc0d9d 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -100,6 +100,10 @@ const noTrimFormats = new Set(["uuid", "byte", "binary", "password"]); // RFC 3339 temporal formats, validated by regex at runtime function temporalRegexConstraint(format: string | undefined) { + // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` + // and `time` require an offset. The seconds field admits a leap second of + // `60`. `T` and `Z` may be lower case, and a space may separate the date + // from the time. `duration` follows the ISO 8601 grammar in Appendix A switch (format) { case "date": return vcall( @@ -126,7 +130,7 @@ function temporalRegexConstraint(format: string | undefined) { JSON.stringify(format), ); default: - return undefined; + return; } } @@ -146,7 +150,7 @@ function temporalTypeHint(format: string | undefined) { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return "`P${string}`"; default: - return undefined; + return; } } @@ -234,6 +238,9 @@ function writeStrictObjectEntries( mode: SchemaMode, ) => WriterFunction | string = schemaToValidator, ) { + // input schemas face TS callers, so `v.optional` lets them pass + // `{ foo: undefined }`. wire schemas face JSON-parsed payloads, where + // `undefined` is absent by construction const optionalWrapper = mode === "input" ? "optional" : "exactOptional"; Object.entries(properties).forEach(([name, s]) => { const isRequired = requiredProps.has(name); From f10ff5a90ca5c23eb7ca1358b9228d25818fe55f Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 14:59:17 +0800 Subject: [PATCH 12/14] build: prune the release-age exclusions that expired An exclusion exempts one version from the cooling-off period, so it is spent once that version ages past it. `@block65/rest-client` was listed without a version, which exempted every future release, and the entry was 16 days stale: the tree resolves 14.0.1 while the list named 14.0.0. `minimumReleaseAgeExcludePrune` drops an entry once its version ages out, so the list stays honest without being swept by hand. Regenerating the lockfile without the entry reproduces it byte for byte, so no resolution depended on it. Co-Authored-By: LLM --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5512e39..a64964d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,5 @@ +minimumReleaseAgeExcludePrune: true minimumReleaseAgeExclude: - - "@block65/rest-client" - "@block65/oxlint-plugin@0.6.0" - "@block65/shared-config@0.3.1" From f3f7f8da6ea28686482b50d2971106a9d34aeb82 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 15:30:40 +0800 Subject: [PATCH 13/14] ci: give pnpm/action-setup a version to install `pnpm/action-setup` takes the version from its own `version` key or from `packageManager` in package.json, and `e2da29f` removed the latter, so every run failed during setup. The workflow only triggers on a pull request and none was opened since, so nothing surfaced it. Setting it here rather than restoring `packageManager`: under pnpm 12 that field makes pnpm manage itself, which writes its own per-platform binaries into the lockfile. rest-client has the same break from the same change. Co-Authored-By: LLM --- .github/workflows/deploy.yml | 2 ++ .github/workflows/pr.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3531709..03cff99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,8 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 + with: + version: 12.4.0 - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6976b13..ba48e6d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,6 +20,8 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + with: + version: 12.4.0 - uses: actions/setup-node@v4 with: From f3ea0efd4f8bbafff1c033c7dc05d12709a60f48 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 19 Sep 2026 16:06:03 +0800 Subject: [PATCH 14/14] ci: pin actions to a commit and raise pr.yml to v6 A tag is mutable, so `@v6` resolves to whatever the publisher last moved it to. The comment carries the version a human reads. pr.yml was still on v4 while deploy.yml was on v6. Co-Authored-By: LLM --- .github/workflows/deploy.yml | 6 +- .github/workflows/pr.yml | 6 +- __tests__/codegen-regressions.test.ts | 79 +++++++++++---------------- lib/utils.ts | 16 +++--- 4 files changed, 47 insertions(+), 60 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 03cff99..f43571b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,13 +13,13 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: version: 12.4.0 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: 26 registry-url: https://registry.npmjs.org/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba48e6d..07418a2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,13 +17,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: version: 12.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: ${{ matrix.version }} cache: "pnpm" diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index d52e7f4..915a439 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -1,16 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { expect, test } from "vitest"; +import { assert, expect, test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; -// Fails the test on an absent value, where `""` would pass a later assertion -function required(value: T | undefined, what: string) { - if (value === undefined) { - throw new Error(`the generator emitted no ${what}`); - } - - return value; -} - const respOk = { "200": { description: "OK", @@ -87,10 +78,10 @@ test("optional query params do not carry `| undefined` in their property type", const result = await processOpenApiDocument("/tmp/whatever", schema); const typesText = result.typesFile.getText(); - const queryBlock = required( - typesText.match(/export type ListFilesCommandQuery = \{[\s\S]*?\};/)?.[0], - "ListFilesCommandQuery", - ); + const queryBlock = typesText.match( + /export type ListFilesCommandQuery = \{[\s\S]*?\};/, + )?.[0]; + assert.isDefined(queryBlock, "ListFilesCommandQuery"); expect(queryBlock).toContain("purpose?: string"); expect(queryBlock).toContain("limit?: `${number}`"); @@ -148,14 +139,16 @@ test("AllInputs union includes every command's Input (no silent drops)", async ( const mainText = result.mainFile.getText(); const commandsText = result.commandsFile.getText(); - const allInputsBlock = required( - mainText.match(/type AllInputs =[\s\S]*?;/)?.[0], - "AllInputs", - ); + const allInputsBlock = mainText.match(/type AllInputs =[\s\S]*?;/)?.[0]; + assert.isDefined(allInputsBlock, "AllInputs"); const commandNames = [ ...commandsText.matchAll(/^export class (\w+Command) extends Command required(m[1], "command name")); + ].map(([, name]) => { + assert.isDefined(name, "command name"); + + return name; + }); expect(commandNames.length).toBeGreaterThan(0); const missing = commandNames.filter( @@ -345,13 +338,11 @@ test("an array request body with parameters stays readable as both", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const inputBlock = required( - result.typesFile - .getTypeAlias("PluginPullCommandInput") - ?.getTypeNode() - ?.getText(), - "PluginPullCommandInput", - ); + const inputBlock = result.typesFile + .getTypeAlias("PluginPullCommandInput") + ?.getTypeNode() + ?.getText(); + assert.isDefined(inputBlock, "PluginPullCommandInput"); expect(inputBlock).toContain("PluginPullCommandBodyWrapper"); expect(result.commandsFile.getText()).toMatch( @@ -402,13 +393,11 @@ test("nested query param members get the same stringish treatment as top-level o }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = required( - result.typesFile - .getTypeAlias("SearchCommandQuery") - ?.getTypeNode() - ?.getText(), - "SearchCommandQuery", - ); + const queryBlock = result.typesFile + .getTypeAlias("SearchCommandQuery") + ?.getTypeNode() + ?.getText(); + assert.isDefined(queryBlock, "SearchCommandQuery"); expect(queryBlock).toMatch(/limit\?: `\$\{number\}`/); expect(queryBlock).toMatch(/"age"\?: `\$\{number\}`/); @@ -455,13 +444,11 @@ test("json request body members keep their real JSON types, nested included", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const bodyBlock = required( - result.typesFile - .getTypeAlias("CreateContainerCommandJsonBody") - ?.getTypeNode() - ?.getText(), - "CreateContainerCommandJsonBody", - ); + const bodyBlock = result.typesFile + .getTypeAlias("CreateContainerCommandJsonBody") + ?.getTypeNode() + ?.getText(); + assert.isDefined(bodyBlock, "CreateContainerCommandJsonBody"); expect(bodyBlock).toMatch(/"tty"\?: boolean/); expect(bodyBlock).toMatch(/"retries"\?: number/); @@ -518,13 +505,11 @@ test("a oneOf query param keeps the stringish wire types in every branch", async }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = required( - result.typesFile - .getTypeAlias("ListThingsCommandQuery") - ?.getTypeNode() - ?.getText(), - "ListThingsCommandQuery", - ); + const queryBlock = result.typesFile + .getTypeAlias("ListThingsCommandQuery") + ?.getTypeNode() + ?.getText(); + assert.isDefined(queryBlock, "ListThingsCommandQuery"); // Composition has to forward the codegen options the same way the array // and object branches do, or a oneOf collapses back to the JSON types diff --git a/lib/utils.ts b/lib/utils.ts index 3bb1512..bb37c46 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -82,11 +82,13 @@ export function iife(fn: () => T): T { return fn(); } -export function typedEntries( - obj: T, -): [keyof T & string, T[keyof T]][] { - // TYPESAFETY: `Object.entries` types every key as `string`, and this puts - // back what `T` declares. An index signature on `T` would widen them again, - // and every caller passes a closed object type - return Object.entries(obj) as [keyof T & string, T[keyof T]][]; +/** + * `Object.entries` types every key as `string`, and this puts back what `T` + * declares. An index signature on `T` would widen them again, and every caller + * passes a closed object type + */ +export function typedEntries(obj: T) { + // TYPESAFETY: this is the etire point of the function + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return Object.entries(obj) as [keyof T, T[keyof T]][]; }