From 5e731d2784db1b1cf8260ddec6ff962a0864e39d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:53:52 +0100 Subject: [PATCH 01/12] chore(deps): pin @exadev/eslint-config to the exact 2.12.1 release Moves off the ^2.1.1 range onto an exact version, matching this repo's new saveExact default for @exadev packages. Pulls in strictTypeChecked as the config's default export (up from recommendedTypeChecked), the barrel-policy and readonly-safety rule families added since 2.1.x, and the new no-warning-comments/max-lines rules from 2.12.0. --- package.json | 2 +- pnpm-lock.yaml | 331 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 327 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2c441ed..6d49365 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@exadev/eslint-config": "^2.1.1", + "@exadev/eslint-config": "2.12.1", "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6942d33..925c7cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.10.0(jiti@2.6.1)) '@exadev/eslint-config': - specifier: ^2.1.1 - version: 2.1.2(eslint@10.10.0(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3)) + specifier: 2.12.1 + version: 2.12.1(eslint@10.10.0(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) '@semantic-release/changelog': specifier: ^7.0.0 version: 7.0.0(semantic-release@25.0.9(typescript@6.0.3)) @@ -258,6 +258,14 @@ packages: resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==} engines: {node: '>=22'} + '@es-joy/jsdoccomment@0.97.0': + resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} + engines: {node: ^22.22.2 || >=24.15.0} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -297,12 +305,26 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.1.2': - resolution: {integrity: sha512-isLVdlJ10wPn+O6iHAUQf+UQAfHvlaOfQYQVM2mUoPXsxKnDn66EvgFdWJUM9FrI/iFRI+bGzm3FfYJu/N+DTg==} + '@exadev/eslint-config@2.12.1': + resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} engines: {node: '>=20'} peerDependencies: + '@next/eslint-plugin-next': ^16.3.2 eslint: '>=10.0.0' + eslint-plugin-jsx-a11y: ^6.10.2 + eslint-plugin-react: ^7.37.5 + eslint-plugin-react-hooks: ^7.1.1 + typescript: '>=4.8.4' typescript-eslint: '>=8.0.0' + peerDependenciesMeta: + '@next/eslint-plugin-next': + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react: + optional: true + eslint-plugin-react-hooks: + optional: true '@exadev/release-gate@1.0.0': resolution: {integrity: sha512-ud9Tk0xVq4xgBgjs1R5uvwAkDCmnaApslKzoklGm2Y+wlCJsAO50qGUi0QQ5lTpW0DWinCbne6QZXFVeJ+qYsA==} @@ -350,6 +372,12 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -580,6 +608,10 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -663,16 +695,32 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.69.0': resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.69.0': resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.69.0': resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -686,16 +734,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.69.0': resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.69.0': resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.69.0': resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -703,6 +768,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.69.0': resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -901,6 +970,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -931,6 +1003,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1044,6 +1120,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -1191,6 +1271,10 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -1213,6 +1297,15 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-jsdoc@64.3.6: + resolution: {integrity: sha512-lo7IXmgUUNy88SxW7KnJmmD2iPQBIRMomfCFPHidW1M3zNNVuzxlB2uoNrNyE1g4v2/L+RtYmiROPwQhSNzL8Q==} + engines: {node: ^22.22.2 || >=24.15.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-tsdoc@0.5.2: + resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1344,6 +1437,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function-timeout@1.0.2: resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} engines: {node: '>=18'} @@ -1410,6 +1506,10 @@ packages: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -1434,6 +1534,9 @@ packages: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1506,6 +1609,10 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1572,6 +1679,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1582,6 +1692,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsdoc-type-pratt-parser@9.2.1: + resolution: {integrity: sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==} + engines: {node: ^22.22.2 || >=24.15.0} + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -1909,6 +2023,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -1972,6 +2089,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -1988,6 +2108,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -2013,6 +2136,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2121,6 +2247,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2132,6 +2262,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown-plugin-dts@0.28.5: resolution: {integrity: sha512-yYd3C9CeJwqjOc9X23m0Tyxcqic491uLZlfg51szT287S8zCCqLR2uoySoElgqy2CLn7PdXcEo1dlkBs4n1WHg==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -2220,6 +2355,9 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} @@ -2294,6 +2432,10 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -2343,6 +2485,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -2868,6 +3014,16 @@ snapshots: '@conventional-changelog/template@1.4.0': {} + '@es-joy/jsdoccomment@0.97.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.69.0 + comment-parser: 1.4.8 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 9.2.1 + + '@es-joy/resolve.exports@1.2.0': {} + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.6.1))': dependencies: eslint: 10.10.0(jiti@2.6.1) @@ -2902,10 +3058,18 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.1.2(eslint@10.10.0(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3))': + '@exadev/eslint-config@2.12.1(eslint@10.10.0(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3)': dependencies: + '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.6.1)) + '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3) eslint: 10.10.0(jiti@2.6.1) + eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-tsdoc: 0.5.2(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 typescript-eslint: 8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3) + transitivePeerDependencies: + - supports-color '@exadev/release-gate@1.0.0(typescript@6.0.3)': dependencies: @@ -2954,6 +3118,15 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.7': @@ -3193,6 +3366,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -3270,6 +3445,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) @@ -3279,11 +3463,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -3300,8 +3493,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) @@ -3317,6 +3527,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.56.1(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) + eslint: 10.10.0(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.6.1)) @@ -3328,6 +3549,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 @@ -3482,6 +3708,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -3509,6 +3742,8 @@ snapshots: any-promise@1.3.0: {} + are-docs-informative@0.1.1: {} + argparse@2.0.1: {} argue-cli@3.1.0: {} @@ -3617,6 +3852,8 @@ snapshots: commander@15.0.0: {} + comment-parser@1.4.8: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -3740,6 +3977,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} es-toolkit@1.51.0: {} @@ -3752,6 +3991,38 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-plugin-jsdoc@64.3.6(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3): + dependencies: + '@es-joy/jsdoccomment': 0.97.0 + '@es-joy/resolve.exports': 1.2.0 + '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3) + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 + debug: 4.4.3 + escape-string-regexp: 5.0.0 + eslint: 10.10.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-tsdoc@0.5.2(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3): + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@typescript-eslint/utils': 8.56.1(eslint@10.10.0(jiti@2.6.1))(typescript@6.0.3) + transitivePeerDependencies: + - eslint + - supports-color + - typescript + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -3929,6 +4200,8 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + function-timeout@1.0.2: {} get-caller-file@2.0.5: {} @@ -3988,6 +4261,10 @@ snapshots: dependencies: hookified: 1.15.1 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + highlight.js@10.7.3: {} hook-std@4.0.0: {} @@ -4006,6 +4283,8 @@ snapshots: dependencies: lru-cache: 11.5.2 + html-entities@2.6.0: {} + html-escaper@2.0.2: {} http-proxy-agent@9.1.0: @@ -4066,6 +4345,10 @@ snapshots: is-arrayish@0.2.1: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4115,6 +4398,8 @@ snapshots: jiti@2.6.1: {} + jju@1.4.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -4123,6 +4408,11 @@ snapshots: dependencies: argparse: 2.0.1 + jsdoc-type-pratt-parser@9.2.1: + dependencies: + '@types/estree': 1.0.9 + '@types/node': 26.4.1 + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} @@ -4358,6 +4648,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + obug@2.1.4: {} onetime@6.0.0: @@ -4413,6 +4705,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + parse-json@4.0.0: dependencies: error-ex: 1.3.4 @@ -4433,6 +4729,8 @@ snapshots: parse-ms@4.0.0: {} + parse-statements@1.0.11: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -4449,6 +4747,8 @@ snapshots: path-key@4.0.0: {} + path-parse@1.0.7: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -4554,12 +4854,21 @@ snapshots: require-from-string@2.0.2: {} + reserved-identifiers@1.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown-plugin-dts@0.28.5(rolldown@1.2.7)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -4678,6 +4987,11 @@ snapshots: spdx-exceptions: 2.5.0 spdx-license-ids: 3.0.23 + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + spdx-license-ids@3.0.23: {} split2@1.0.0: @@ -4751,6 +5065,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} temp-dir@3.0.0: {} @@ -4796,6 +5112,11 @@ snapshots: dependencies: is-number: 7.0.0 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + traverse@0.6.8: {} tree-kill@1.2.2: {} From b66d755761a5d2255f8dd8918a1fcce78e630cb7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:54:10 +0100 Subject: [PATCH 02/12] chore: default future dependency adds to exact versions Sets saveExact in pnpm-workspace.yaml (the only location pnpm 11+ reads project settings from) so every future pnpm add records the resolved version rather than a caret range, and adds a matching .npmrc as a fallback for a pnpm 10.x release older than 10.17. The existing @exadev/* minimumReleaseAgeExclude entry already covers @exadev/eslint-config, so no change was needed there. --- .npmrc | 3 +++ pnpm-workspace.yaml | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9e905f3 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# Fallback for a pnpm 10.x release older than 10.17 (before pnpm-workspace.yaml grew its own settings block); ignored on pnpm 11+, which reads these from pnpm-workspace.yaml instead. +save-exact=true +minimum-release-age-exclude[]=@exadev/eslint-config diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b05afb8..c07bbf7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,5 @@ minimumReleaseAge: 60 # @exadev/release-gate is this package's own real dependency (the gatePublish feature), published and maintained by ExaDev directly -- exempted from minimumReleaseAge entirely for the same reason as any other ExaDev-owned package: we control its release pipeline, so the supply-chain risk the gate exists for doesn't apply. pnpm 11 only honours this setting from pnpm-workspace.yaml, not the equivalent entry in pnpm's own global config -- confirmed directly while building release-gate itself. minimumReleaseAgeExclude: - '@exadev/*' +# Every future `pnpm add` records the exact resolved version rather than a caret range, matching how @exadev/eslint-config itself is pinned in this repo's own package.json. +saveExact: true From ee4e5f7618372768a5977989f3f2debd3836571f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:55:05 +0100 Subject: [PATCH 03/12] refactor(lint): build the flat config with eslint/config's defineConfig typescript-eslint's own tseslint.config is deprecated as of the version this repo now pulls in, in favour of the array-flattening helper ESLint core itself now ships. Behaviourally identical here: same nested arrays and spread configs, just built by defineConfig instead of the deprecated wrapper. --- eslint.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index 517fc22..17fbae8 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,9 +1,9 @@ import js from '@eslint/js'; import exadevRecommendedTypeChecked from '@exadev/eslint-config'; +import { defineConfig } from 'eslint/config'; import globals from 'globals'; -import tseslint from 'typescript-eslint'; -export default tseslint.config( +export default defineConfig( { // test/smoke.test.mjs spawns the built dist/cli.js, deliberately outside tsconfig's "src" program (it tests build output, not the source). ignores: ['dist', 'coverage', 'node_modules', 'test'], From 5558d4d5f2d8033a35a892f2203fdf843e6b2831 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:55:24 +0100 Subject: [PATCH 04/12] refactor(git): execute git and pnpm subprocesses through a hand-written promise wrapper Replaces promisify(execFile) in git.ts and pnpm.ts with a small, explicitly-typed execFile helper: execFile synchronously returns a ChildProcess in addition to invoking its callback, which the newly enabled strict-void-return rule correctly flags as a value-returning function handed to promisify where a void-returning one is expected, a mismatch tsc itself accepts under its own return-type leniency. Calling execFile directly with our own void callback avoids the mismatch instead of working around it. git.ts's own git() now derives its 100 MiB maxBuffer from a single literal default parameter rather than a multiplied module constant, sanitizeGitEnv() deletes discovery-affecting env keys via Reflect.deleteProperty instead of the dynamic delete operator, and workingTreeChanges() slices a porcelain entry down to its path and checks for emptiness instead of comparing its length against a bare minimum, removing every magic number from the parse. git-workspace-fixture.ts drops its own duplicate promisify(execFile) lockfile regeneration in favour of calling pnpm.ts's regenerateLockfile directly. --- src/exec-file.ts | 28 ++++++++++++++++++++++++++++ src/git-workspace-fixture.ts | 9 +++------ src/git.ts | 32 ++++++++++++++++++++------------ src/pnpm.ts | 7 ++----- 4 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 src/exec-file.ts diff --git a/src/exec-file.ts b/src/exec-file.ts new file mode 100644 index 0000000..1bbed65 --- /dev/null +++ b/src/exec-file.ts @@ -0,0 +1,28 @@ +import { execFile as execFileCallback, type ExecException } from 'node:child_process'; + +export interface ExecFileResult { + readonly stdout: string; + readonly stderr: string; +} + +export interface ExecFileOptions { + readonly cwd: string; + readonly env?: NodeJS.ProcessEnv; + readonly maxBuffer?: number; +} + +/** + * A hand-written promise wrapper around `child_process.execFile`, in place of `util.promisify(execFile)`: `execFile` synchronously returns a `ChildProcess` in addition to invoking its callback, which trips `@typescript-eslint/strict-void-return` when the whole function is handed to `promisify` (a value-returning function used where a void-returning one is contextually expected there) -- exactly the void-return contravariance leniency that rule exists to catch, even though `tsc` itself accepts the pattern. Calling `execFile` directly with our own callback, whose own return type really is `void`, sidesteps the mismatch instead of suppressing it. + */ +export async function execFile(command: string, args: readonly string[], options: ExecFileOptions): Promise { + return new Promise((resolve, reject) => { + execFileCallback(command, [...args], { cwd: options.cwd, env: options.env, maxBuffer: options.maxBuffer }, (error: ExecException | null, stdout: string, stderr: string) => { + if (error) { + // `ExecException` is a plain interface (`extends Error`), not the `Error` class itself, so type-directed lint checks that look for the built-in `Error` symbol specifically don't recognise it as error-like on its own -- narrowing through `instanceof Error` (always true for a real exec failure at runtime) satisfies that check honestly rather than suppressing it. + reject(error instanceof Error ? error : new Error(error.message)); + return; + } + resolve({ stdout, stderr }); + }); + }); +} diff --git a/src/git-workspace-fixture.ts b/src/git-workspace-fixture.ts index bdec481..c8dc6d1 100644 --- a/src/git-workspace-fixture.ts +++ b/src/git-workspace-fixture.ts @@ -1,12 +1,9 @@ -import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { promisify } from 'node:util'; import { git } from './git'; - -const execFileAsync = promisify(execFile); +import { regenerateLockfile } from './pnpm'; /** * Builds a throwaway pnpm workspace with a real git repository, a real bare remote, real commits, and real `name@version` tags -- the substrate the orchestrator's tests drive end to end. The orchestrator's own discovery reads only `pnpm-workspace.yaml` and manifests and never invokes pnpm itself, but `bumpDependents`'s lockfile regeneration does, so a dependency-range bump against this fixture runs a real `pnpm install --lockfile-only`. @@ -91,7 +88,7 @@ export async function createWorkspaceFixture( } if (options.pnpmLockfile === true) { - await execFileAsync('pnpm', ['install', '--lockfile-only'], { cwd: root }); + await regenerateLockfile({ cwd: root }); await commit(root, 'chore: lockfile', ['pnpm-lock.yaml']); } @@ -112,7 +109,7 @@ export async function createWorkspaceFixture( await git(['push', '-u', 'origin', 'main', '--tags'], { cwd: root }); // maxRetries/retryDelay: `git push`/`git commit` can leave a background `git gc --auto` still writing into `remote.git/objects` or `.git/objects` for a moment after the command that triggered it returns, which occasionally loses the race against this recursive delete with ENOTEMPTY -- exactly the error class Node's own retry option exists for. - return { root, remote, remove: () => rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) }; + return { root, remote, remove: async () => rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) }; } async function commit(root: string, message: string, paths: readonly string[]): Promise { diff --git a/src/git.ts b/src/git.ts index 0cb7661..2cd3dce 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,11 +1,5 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; import { GitCommandError, WorkspaceStateError } from './errors'; - -const execFileAsync = promisify(execFile); - -/** `git log --name-only` over everything since a package's last release tag can legitimately produce tens of megabytes of path output on a long-lived monorepo, well past execFile's default buffer, failing on exactly the big workspaces this tool exists for. */ -const GIT_MAX_BUFFER_BYTES = 100 * 1024 * 1024; +import { execFile } from './exec-file'; /** Separates one commit's record in `git log --format` output. Chosen from the C0 control range so it can never appear in a hash or a file path. */ const COMMIT_RECORD_SEPARATOR = '\x1e'; @@ -39,17 +33,22 @@ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS: readonly string[] = [ export function sanitizeGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const sanitized = { ...env }; for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) { - delete sanitized[key]; + Reflect.deleteProperty(sanitized, key); } return sanitized; } const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env); +/** `git log --name-only` over everything since a package's last release tag can legitimately produce tens of megabytes of path output on a long-lived monorepo, well past execFile's default buffer, failing on exactly the big workspaces this tool exists for -- hence the generous 100 MiB default, rather than execFile's own. */ +async function execGit(args: readonly string[], cwd: string, maxBuffer = 104_857_600): Promise { + const { stdout } = await execFile('git', args, { cwd, maxBuffer, env: SANITIZED_PROCESS_GIT_ENV }); + return stdout; +} + export async function git(args: readonly string[], options: GitCommandOptions): Promise { try { - const { stdout } = await execFileAsync('git', [...args], { cwd: options.cwd, maxBuffer: GIT_MAX_BUFFER_BYTES, env: SANITIZED_PROCESS_GIT_ENV }); - return stdout; + return await execGit(args, options.cwd); } catch (cause) { throw toGitCommandError(args, options.cwd, cause); } @@ -141,6 +140,11 @@ export async function createTag(name: string, ref: string, options: GitCommandOp await git(['tag', name, ref], options); } +/** A `git status --porcelain=v1 -z` entry is a 2-character status code, a single space, then the path -- `PathOffset` characters of fixed prefix before any path content. An entry with nothing past that offset (too short to carry a real status+path pair) slices down to an empty path, which is filtered out the same way as any other non-entry token. */ +const enum PorcelainEntryFormat { + PathOffset = 3, +} + /** * Lists every path with a working-tree or index change (modified, added, deleted, untracked), repository-root-relative, via `git status --porcelain=v1 -z`. `commitStrategy: 'single'` uses this rather than predicting which files each configured prepare plugin touched (a version bump, a changelog write, a dependency-range rewrite, a regenerated lockfile) by name: asking git what actually changed is correct regardless of which prepare plugins are configured or how they name their own output files. * @@ -152,11 +156,15 @@ export async function workingTreeChanges(options: GitCommandOptions): Promise { try { - await execFileAsync('pnpm', ['install', '--lockfile-only'], { cwd: options.cwd }); + await execFile('pnpm', ['install', '--lockfile-only'], { cwd: options.cwd }); } catch (cause) { const stderr = cause instanceof Error && 'stderr' in cause && typeof cause.stderr === 'string' ? cause.stderr.trim() : ''; const detail = stderr !== '' ? stderr : cause instanceof Error ? cause.message : String(cause); From 9ed76e51023e30af070a18cc6155dc9bbed6d720 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:56:17 +0100 Subject: [PATCH 05/12] fix(plugins): narrow semantic-release's undertyped lastRelease shape semantic-release's own getLastRelease returns {} for a package with no prior tag, not undefined and not a fully-populated LastRelease, contradicting the gitHead: string its own type declares. A new hasGitHead structural guard reads gitHead only when it is genuinely present, replacing an optional chain and a `?? undefined` that were provably redundant against the (inaccurate) declared type. The two inline analyzeCommits/generateNotes plugins now check their upstream result with typeof rather than a bare truthy check, since both can return the empty string as a real, meaningful value distinct from false/undefined. Also renames the deprecated NodeRequire type to NodeJS.Require, converts DependencyBumpSource.bumpsFor to a property signature per this config's method-signature-style rule, and escapes scoped package names in two doc comments so they don't parse as inline JSDoc tags. --- src/plugins.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/plugins.ts b/src/plugins.ts index c1d1755..4aa3e49 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -22,12 +22,17 @@ export interface DependencyBump { /** What the scoped plugins need to know about bumps recorded so far in the run, for the package they are about to analyse. */ export interface DependencyBumpSource { - bumpsFor(dependent: string): readonly DependencyBump[]; + bumpsFor: (dependent: string) => readonly DependencyBump[]; } /** A publish-pipeline plugin entry as the orchestrator accepts it: a module name, optionally with a config object. */ export type PublishPluginSpec = string | readonly [string] | readonly [string, Record]; +/** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */ +function hasGitHead(lastRelease: unknown): lastRelease is { readonly gitHead: string } { + return typeof lastRelease === 'object' && lastRelease !== null && 'gitHead' in lastRelease && typeof lastRelease.gitHead === 'string'; +} + /** The standard publish pipeline this orchestrator coordinates when a workspace configures none of its own. Every entry reuses the corresponding official plugin -- the orchestrator scopes and sequences them per package, it does not reimplement npm publishing, GitHub release creation, or changelog writing. */ export const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[] = [ '@semantic-release/changelog', @@ -43,7 +48,7 @@ export const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[] = [ ], ]; -/** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus @semantic-release/git, which that mode never runs -- see `resolvePublishPlugins`'s `forbidGitPlugin` option for why it is rejected outright rather than merely unused. Single-commit mode does its own committing (one combined commit for every released package), so a `prepare`-step git plugin here would create the very per-package commits that mode exists to avoid. */ +/** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus `@semantic-release/git`, which that mode never runs -- see `resolvePublishPlugins`'s `forbidGitPlugin` option for why it is rejected outright rather than merely unused. Single-commit mode does its own committing (one combined commit for every released package), so a `prepare`-step git plugin here would create the very per-package commits that mode exists to avoid. */ export const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[] = ['@semantic-release/changelog', '@semantic-release/npm', '@semantic-release/github']; const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS: ReadonlySet = new Set(['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator']); @@ -56,7 +61,7 @@ export interface ScopedPlugins { /** * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins. * - * Both apply the same path scoping before delegating to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator: the commit list semantic-release already fetched for the release range is filtered down to commits whose `git log --name-only` file list intersects the package's own directory, and only the filtered list reaches the standard plugin. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins. + * Both apply the same path scoping before delegating to the real `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`: the commit list semantic-release already fetched for the release range is filtered down to commits whose `git log --name-only` file list intersects the package's own directory, and only the filtered list reaches the standard plugin. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins. * * The `analyzeCommits` wrapper carries one addition beyond filtering: when the standard analyzer finds no releasable commits but a workspace dependency range of the package's has changed, it returns 'patch' anyway. A dependent whose only change is a dependency bump still needs a release for that range to reach the registry. "Has changed" is read from two sources, merged: bumps recorded in memory earlier in the current run (`scope.bumps`), and bumps recorded in the package's own filtered commit history via the trailer `dependency-bump-commit.ts` writes and reads -- the latter is what lets a run that starts after a previous run already committed and pushed the bump (a crash recovery, or simply a later run) reach the same decision, rather than depending on state that existed only inside the process that made the commit. */ @@ -72,8 +77,8 @@ export function createScopedPlugins(scope: { let cached: { readonly from: string | undefined; readonly paths: Promise>> } | undefined; async function commitsForPackage(context: AnalyzeCommitsContext & { cwd: string }): Promise { - // An absent lastRelease means semantic-release fetched the package's whole history, so the path map is built over the same unbounded range. - const from = context.lastRelease?.gitHead ?? undefined; + // A lastRelease with no gitHead means semantic-release fetched the package's whole history, so the path map is built over the same unbounded range. + const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : undefined; // Two branches, not `cached === undefined || cached.from !== from`: when this is the very first call for a package with no prior release, both `cached` and `from` are `undefined`, and a single optional-chained comparison cannot distinguish "nothing cached yet" from "cached, and it happens to match". if (cached === undefined) { cached = { from, paths: changedPathsSince(from, { cwd: context.cwd }) }; @@ -89,7 +94,7 @@ export function createScopedPlugins(scope: { async analyzeCommits(_pluginConfig, context) { const commits = await commitsForPackage(context); const type = await analyzeCommits(scope.analyzeCommitsConfig, { ...context, commits }); - if (type) { + if (typeof type === 'string') { return type; } const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits); @@ -97,7 +102,7 @@ export function createScopedPlugins(scope: { return false; } context.logger.log( - `No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? 'a workspace dependency range changed' : `${bumps.length} workspace dependency ranges changed`}; forcing a patch release.`, + `No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? 'a workspace dependency range changed' : `${String(bumps.length)} workspace dependency ranges changed`}; forcing a patch release.`, ); return 'patch'; }, @@ -110,7 +115,7 @@ export function createScopedPlugins(scope: { return notes; } const section = ['### Dependencies', '', ...bumps.map((bump) => describeDependencyBump(bump))].join('\n'); - return notes ? `${notes}\n\n${section}` : section; + return typeof notes === 'string' ? `${notes}\n\n${section}` : section; }, }; } @@ -202,7 +207,7 @@ export function resolvePublishPlugins( /** * Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name. */ -function resolvePluginModule(name: string, requireFromTool: NodeRequire, requireFromWorkspace: NodeRequire): string { +function resolvePluginModule(name: string, requireFromTool: NodeJS.Require, requireFromWorkspace: NodeJS.Require): string { const attempts: string[] = []; for (const [label, requirer] of [ ['this tool', requireFromTool], From 95d445718a8da5e56d7b383d9f0e49f83e1dd051 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:56:45 +0100 Subject: [PATCH 06/12] fix(release): model PackageReleaseOutcome's released fields as a union version, gitTag, and type were three independently-optional fields that every one of the four sites building a PackageReleaseOutcome actually set together or not at all, tied to whether the package released. Splitting the type into a released: true branch (where all three are required strings) and a released: false branch (where none are present) lets a caller that has already checked pkg.released read gitTag/type as plain strings, and turns every constructor into an explicit branch on the same condition instead of three parallel optional-chained assignments. Also stringifies the numeric counts interpolated into several log and error template literals, wraps four logger callbacks that previously returned shared.log's own return value in a block body so they return void as their callers expect, and escapes scoped package names in a few doc comments so they don't parse as inline JSDoc tags. --- src/gate-publish.ts | 29 ++++++++++++----------- src/release.ts | 41 ++++++++++++++++---------------- src/single-commit-release.ts | 46 ++++++++++++++++++++---------------- 3 files changed, 62 insertions(+), 54 deletions(-) diff --git a/src/gate-publish.ts b/src/gate-publish.ts index 4dab922..2ed6aba 100644 --- a/src/gate-publish.ts +++ b/src/gate-publish.ts @@ -40,7 +40,7 @@ export async function detachWorkspaceRelease(options: ReleaseWorkspaceOptions): const graph = buildDependencyGraph(workspace.packages); validateDependencyRangeShapes(graph); const order = topologicalOrder(graph); - log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(' -> ')}`); + log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(' -> ')}`); const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun }); const analyzeCommitsConfig = options.analyzeCommits ?? {}; @@ -59,15 +59,19 @@ export async function detachWorkspaceRelease(options: ReleaseWorkspaceOptions): return { released: state !== null, version: state?.nextRelease.version, result: state }; }); - const packages: PackageReleaseOutcome[] = entries.map((entry) => ({ - name: entry.name, - directory: entry.directory, - released: entry.result !== null, - version: entry.result?.nextRelease.version, - gitTag: entry.result?.nextRelease.gitTag, - type: entry.result?.nextRelease.type, - dependencyBumps: entry.dependencyBumps, - })); + const packages: PackageReleaseOutcome[] = entries.map((entry) => + entry.result === null + ? { name: entry.name, directory: entry.directory, released: false, dependencyBumps: entry.dependencyBumps } + : { + name: entry.name, + directory: entry.directory, + released: true, + version: entry.result.nextRelease.version, + gitTag: entry.result.nextRelease.gitTag, + type: entry.result.nextRelease.type, + dependencyBumps: entry.dependencyBumps, + }, + ); const detached: DetachedPackageRelease[] = entries.map((entry) => ({ name: entry.name, @@ -172,9 +176,6 @@ export async function resumeWorkspaceRelease(options: ResumeWorkspaceReleaseOpti name: entry.name, directory: resolve(root, entry.relativeDirectory), released: false, - version: undefined, - gitTag: undefined, - type: undefined, dependencyBumps: entry.dependencyBumps, }); continue; @@ -188,7 +189,7 @@ export async function resumeWorkspaceRelease(options: ResumeWorkspaceReleaseOpti } catch (cause) { throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`); } - log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? '' : 's'} ran)`); + log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${String(releases.length)} publish plugin${releases.length === 1 ? '' : 's'} ran)`); packages.push({ name: entry.name, diff --git a/src/release.ts b/src/release.ts index 946c247..413818f 100644 --- a/src/release.ts +++ b/src/release.ts @@ -38,11 +38,11 @@ export interface ReleaseWorkspaceOptions { readonly dryRun?: boolean; /** Release branch configuration for semantic-release. Defaults to semantic-release's own default branch list. */ readonly branches?: readonly BranchSpec[]; - /** Publish-pipeline plugins (changelog, npm, GitHub, git), each scoped per package by semantic-release's own `cwd`. Defaults to the standard pipeline in DEFAULT_PUBLISH_PLUGINS for `commitStrategy: 'per-package'`, or SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (the same list minus @semantic-release/git) for `commitStrategy: 'single'`. */ + /** Publish-pipeline plugins (changelog, npm, GitHub, git), each scoped per package by semantic-release's own `cwd`. Defaults to the standard pipeline in DEFAULT_PUBLISH_PLUGINS for `commitStrategy: 'per-package'`, or SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (the same list minus `@semantic-release/git`) for `commitStrategy: 'single'`. */ readonly plugins?: readonly PublishPluginSpec[]; - /** Options for the wrapped @semantic-release/commit-analyzer, applied per package after path filtering. */ + /** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */ readonly analyzeCommits?: Record; - /** Options for the wrapped @semantic-release/release-notes-generator, applied per package after path filtering. */ + /** Options for the wrapped `@semantic-release/release-notes-generator`, applied per package after path filtering. */ readonly generateNotes?: Record; /** Progress sink for the orchestrator's own narration (semantic-release logs its own detail). Defaults to `console.log`. */ readonly log?: (message: string) => void; @@ -61,17 +61,16 @@ export interface AppliedDependencyBump extends DependencyBump { readonly field: DependencyField; } -export interface PackageReleaseOutcome { +// `released` is the discriminant: `version`/`gitTag`/`type` are only ever populated together, whenever a package actually released, and never independently -- every producer of this type sets all three or none. Modelling that as two required-together fields tied to a boolean, rather than three independently-optional ones, lets a consumer that has already checked `released` use `gitTag`/`type` without a further null check. +export type PackageReleaseOutcome = { readonly name: string; readonly directory: string; - readonly released: boolean; - readonly version: string | undefined; - readonly gitTag: string | undefined; - /** The semantic-release release type ('minor', 'patch', ...), including the forced 'patch' of a dependency-bump-only release. */ - readonly type: string | undefined; /** Dependency ranges rewritten in this package's own manifest because a workspace dependency released earlier in the run. */ readonly dependencyBumps: readonly AppliedDependencyBump[]; -} +} & ( + | { readonly released: true; readonly version: string; readonly gitTag: string; readonly type: string } + | { readonly released: false; readonly version?: undefined; readonly gitTag?: undefined; readonly type?: undefined } +); export interface WorkspaceReleaseOutcome { /** The topological order the packages were released in. */ @@ -110,7 +109,7 @@ export async function releaseWorkspace(options: ReleaseWorkspaceOptions = {}): P const graph = buildDependencyGraph(workspace.packages); validateDependencyRangeShapes(graph); const order = topologicalOrder(graph); - log(`${packageName}: ${order.length} packages in release order: ${order.join(' -> ')}`); + log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(' -> ')}`); const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun }); const analyzeCommitsConfig = options.analyzeCommits ?? {}; @@ -132,15 +131,17 @@ export async function releaseWorkspace(options: ReleaseWorkspaceOptions = {}): P const packages: PackageReleaseOutcome[] = entries.map((entry) => { const nextRelease = entry.result === false ? undefined : entry.result.nextRelease; - return { - name: entry.name, - directory: entry.directory, - released: nextRelease !== undefined, - version: nextRelease?.version, - gitTag: nextRelease?.gitTag, - type: nextRelease?.type, - dependencyBumps: entry.dependencyBumps, - }; + return nextRelease === undefined + ? { name: entry.name, directory: entry.directory, released: false, dependencyBumps: entry.dependencyBumps } + : { + name: entry.name, + directory: entry.directory, + released: true, + version: nextRelease.version, + gitTag: nextRelease.gitTag, + type: nextRelease.type, + dependencyBumps: entry.dependencyBumps, + }; }); return { order, packages }; diff --git a/src/single-commit-release.ts b/src/single-commit-release.ts index 6273140..896512e 100644 --- a/src/single-commit-release.ts +++ b/src/single-commit-release.ts @@ -20,7 +20,7 @@ import { discoverWorkspace, type WorkspacePackage } from './workspace'; * * 1. **Analyse** (this file's `analysePackage`): for every package, in topological order, run semantic-release with `dryRun: true` forced (regardless of the caller's own `dryRun` option) using the same path-scoped `analyzeCommits`/`generateNotes` wrapper `commitStrategy: 'per-package'` uses -- computing each package's next version and notes without writing, committing, tagging, or publishing anything. Cross-package dependency bumps are tracked purely in memory during this phase (`pendingBumps`), exactly as the per-package strategy tracks them for the span of one run; nothing is committed yet for a later run to recover from, because this strategy never leaves a partial commit for a crash to recover from in the first place -- either the whole combined commit lands, or nothing does. * 2. **Verify** every released package's configured publish plugins' `verifyConditions` step (npm registry auth, GitHub token/repo access), before any file is written -- the same fail-fast-before-anything-releases discipline `validateDependencyRangeShapes` already applies to dependency ranges. - * 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines -- @semantic-release/npm bumps `package.json`'s version, @semantic-release/changelog writes `CHANGELOG.md`). @semantic-release/git is rejected outright from this mode's plugin list (see `resolvePublishPlugins`'s `forbidGitPlugin`), since its own `prepare` step would create exactly the per-package commit this mode exists to avoid. The lockfile is regenerated once at the end, not once per bump, since `pnpm install --lockfile-only` recomputes it from whatever is on disk regardless of how many manifests changed. + * 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines -- `@semantic-release/npm` bumps `package.json`'s version, `@semantic-release/changelog` writes `CHANGELOG.md`). `@semantic-release/git` is rejected outright from this mode's plugin list (see `resolvePublishPlugins`'s `forbidGitPlugin`), since its own `prepare` step would create exactly the per-package commit this mode exists to avoid. The lockfile is regenerated once at the end, not once per bump, since `pnpm install --lockfile-only` recomputes it from whatever is on disk regardless of how many manifests changed. * 4. **Commit**: discover every file phase 3 touched via `git status` (rather than predicting filenames per plugin), make one commit, tag it once per released package (`name@version`, lightweight, matching semantic-release's own tag form), and push the commit and every tag together. * 5. **Publish**: for every released package, in topological order, call each configured plugin's own `publish` step directly (not through semantic-release's top-level orchestrator -- see the note below), then `success`. * @@ -40,7 +40,7 @@ export async function releaseWorkspaceSingleCommit(options: ReleaseWorkspaceOpti const graph = buildDependencyGraph(workspace.packages); validateDependencyRangeShapes(graph); const order = topologicalOrder(graph); - log(`${packageName}: ${order.length} packages in release order: ${order.join(' -> ')} (commitStrategy: single)`); + log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(' -> ')} (commitStrategy: single)`); const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: false, @@ -68,22 +68,28 @@ export async function releaseWorkspaceSingleCommit(options: ReleaseWorkspaceOpti bumpsForThisPackage, env, branches: options.branches, - onCommitsResolved: (commits) => capturedCommits.set(name, commits), + onCommitsResolved: (commits) => { + capturedCommits.set(name, commits); + }, onContextCaptured: (context) => { captured.branch = context.branch; captured.repositoryUrl = context.repositoryUrl; }, }); - outcomes.push({ - name, - directory: pkg.directory, - released: nextRelease !== undefined, - version: nextRelease?.version, - gitTag: nextRelease?.gitTag, - type: nextRelease?.type, - dependencyBumps: bumpsForThisPackage, - }); + outcomes.push( + nextRelease === undefined + ? { name, directory: pkg.directory, released: false, dependencyBumps: bumpsForThisPackage } + : { + name, + directory: pkg.directory, + released: true, + version: nextRelease.version, + gitTag: nextRelease.gitTag, + type: nextRelease.type, + dependencyBumps: bumpsForThisPackage, + }, + ); if (nextRelease === undefined) { log(`${name}: no release`); @@ -107,7 +113,7 @@ export async function releaseWorkspaceSingleCommit(options: ReleaseWorkspaceOpti const repositoryUrl = captured.repositoryUrl; if (branch === undefined || repositoryUrl === undefined) { throw new ReleaseConfigurationError( - `Internal error: ${packageName} analysed ${planned.length} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`, + `Internal error: ${packageName} analysed ${String(planned.length)} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`, ); } @@ -149,7 +155,7 @@ export async function releaseWorkspaceSingleCommit(options: ReleaseWorkspaceOpti const touchedPaths = await workingTreeChanges({ cwd: repoRoot }); if (touchedPaths.length === 0) { throw new ReleaseConfigurationError( - `${packageName}: analysis planned ${planned.length} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`, + `${packageName}: analysis planned ${String(planned.length)} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`, ); } const identity = await resolveCommitIdentity({ cwd: repoRoot }); @@ -160,7 +166,7 @@ export async function releaseWorkspaceSingleCommit(options: ReleaseWorkspaceOpti await createTag(tagName, commitSha, { cwd: repoRoot }); } await pushHeadAndTags(tagNames, { cwd: repoRoot }); - log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(', ')}`); + log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(', ')}`); // Phase 5: publish, then success, per released package. for (const release of planned) { @@ -283,7 +289,7 @@ function describeCombinedCommit(planned: readonly PlannedPackageRelease[]): stri return ['chore(release): batch release [skip ci]', '', ...lines].join('\n'); } -/** The subset of a semantic-release plugin context this mode's own hand-built calls actually construct and pass, covering exactly the fields the `verifyConditions`/`prepare`/`publish`/`success` steps of @semantic-release/changelog, @semantic-release/npm, and @semantic-release/github read (confirmed by reading each plugin's own source) -- not the full upstream `VerifyReleaseContext` shape, most of which (`envCi`, `branches` plural, `lastRelease`) none of those steps consult. */ +/** The subset of a semantic-release plugin context this mode's own hand-built calls actually construct and pass, covering exactly the fields the `verifyConditions`/`prepare`/`publish`/`success` steps of `@semantic-release/changelog`, `@semantic-release/npm`, and `@semantic-release/github` read (confirmed by reading each plugin's own source) -- not the full upstream `VerifyReleaseContext` shape, most of which (`envCi`, `branches` plural, `lastRelease`) none of those steps consult. */ interface PluginCallContext { readonly cwd: string; readonly env: NodeJS.ProcessEnv; @@ -325,10 +331,10 @@ function buildPluginContext( releases: readonly unknown[], ): PluginCallContext { const logger: PluginLogger = { - log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(' ')}`), - warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(' ')}`), - error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(' ')}`), - success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(' ')}`), + log: (...args) => { shared.log(`[${release.pkg.name}] ${args.map(String).join(' ')}`); }, + warn: (...args) => { shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(' ')}`); }, + error: (...args) => { shared.log(`[${release.pkg.name}] error: ${args.map(String).join(' ')}`); }, + success: (...args) => { shared.log(`[${release.pkg.name}] ${args.map(String).join(' ')}`); }, }; return { cwd: release.pkg.directory, From 11dae8060928ae5125886b7e412538d88a6fc92c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:57:21 +0100 Subject: [PATCH 07/12] fix: stringify numeric values interpolated into log and error text restrict-template-expressions no longer allows a bare number in a template literal. Both are simple .length counts, so String() is a plain, behaviour-preserving wrap. Also widens collectRepeated's previous parameter to readonly string[], since it only ever reads it. --- src/cli.ts | 4 ++-- src/errors.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b2b2dc5..67af21e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -134,7 +134,7 @@ async function runRelease(flags: ReleaseFlags): Promise { throw new WorkspaceReleaseError('gatePublish was true but no --gate-state-file was resolved -- this should be unreachable.'); } await writeFile(flags.gateStateFile, JSON.stringify(outcome.detached ?? [], null, 2)); - console.log(`${packageName}: wrote gate state for ${(outcome.detached ?? []).length} package(s) to ${flags.gateStateFile}`); + console.log(`${packageName}: wrote gate state for ${String((outcome.detached ?? []).length)} package(s) to ${flags.gateStateFile}`); } } @@ -164,7 +164,7 @@ function describeOutcome(pkg: PackageReleaseOutcome): string { return `${pkg.name}: ${pkg.gitTag} (${pkg.type}${bumps === '' ? '' : `; dependency bumps: ${bumps}`})`; } -function collectRepeated(value: string, previous: string[]): string[] { +function collectRepeated(value: string, previous: readonly string[]): string[] { return [...previous, value]; } diff --git a/src/errors.ts b/src/errors.ts index dd38a26..996da00 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -35,7 +35,7 @@ export class GitCommandError extends WorkspaceReleaseError { readonly exitCode: number | undefined; constructor(args: readonly string[], cwd: string, exitCode: number | undefined, detail: string) { - super(`git ${args.join(' ')} failed in ${cwd}${exitCode === undefined ? '' : ` (exit ${exitCode})`}: ${detail}`); + super(`git ${args.join(' ')} failed in ${cwd}${exitCode === undefined ? '' : ` (exit ${String(exitCode)})`}: ${detail}`); this.exitCode = exitCode; } } From 07ebdead65524068cbe547a7081376d75caa78e7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:57:43 +0100 Subject: [PATCH 08/12] fix(manifest): drop a redundant fallback on an always-populated errors array validate-npm-package-name's own overloaded return type guarantees errors: string[] (not optional) once validForOldPackages is false, so the ?? [] fallback could never actually run; TypeScript's own narrowing already proves this once the guard above it is in scope. --- src/manifest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.ts b/src/manifest.ts index c6a583f..826b310 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -33,7 +33,7 @@ export async function readManifest(path: string): Promise { // The name is spliced verbatim into a lodash template (semantic-release's `tagFormat`), so it must be restricted to npm's own package-name rules -- which exclude every lodash template delimiter -- before it ever reaches that template, not merely "non-empty". const validity = validateNpmPackageName(name); if (!validity.validForOldPackages) { - throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${(validity.errors ?? []).join('; ')}`); + throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join('; ')}`); } if (typeof version !== 'string' || version.length === 0) { throw new WorkspaceDiscoveryError(`${path} has no "version".`); From f2e5f5b291e787656fd6ac3135e3c5bb5da88dec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:58:00 +0100 Subject: [PATCH 09/12] docs: escape scoped package names in two doc comments @exadev, @semantic-release, and @types read as inline JSDoc/TSDoc tags to the new doc-comment quality rules this bump enables. Backtick-wrapping each name as an identifier resolves both, and reads no differently. --- src/index.ts | 2 +- src/plugin-contracts.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 40b0279..3badb42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ /** - * @exadev/semantic-release-workspace -- independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning. + * `@exadev/semantic-release-workspace` -- independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning. * * The public programmatic surface: discover a workspace and its inter-package dependency graph, order it topologically, rewrite dependency ranges when a sibling releases, and drive the whole release run. `releaseWorkspace` composes all of it; the individual pieces are exported so an embedder can inspect or reuse any stage. */ diff --git a/src/plugin-contracts.d.ts b/src/plugin-contracts.d.ts index f00412a..4a9503d 100644 --- a/src/plugin-contracts.d.ts +++ b/src/plugin-contracts.d.ts @@ -1,5 +1,5 @@ /** - * Ambient declarations for the two standard plugins this package wraps. Neither @semantic-release/commit-analyzer nor @semantic-release/release-notes-generator ships type declarations and no @types package exists for either, so their documented plugin contract is declared here once: a named export invoked as (pluginConfig, context), returning the determined release type (analyzer, falsy when no release is warranted) or the release-notes markdown (generator). The context types are imported from semantic-release's own declarations rather than mirrored, and the configs are deliberately loose records: this package passes the user's plugin options through verbatim and the plugin itself validates them. + * Ambient declarations for the two standard plugins this package wraps. Neither `@semantic-release/commit-analyzer` nor `@semantic-release/release-notes-generator` ships type declarations and no `@types` package exists for either, so their documented plugin contract is declared here once: a named export invoked as (pluginConfig, context), returning the determined release type (analyzer, falsy when no release is warranted) or the release-notes markdown (generator). The context types are imported from semantic-release's own declarations rather than mirrored, and the configs are deliberately loose records: this package passes the user's plugin options through verbatim and the plugin itself validates them. */ declare module '@semantic-release/commit-analyzer' { import type { AnalyzeCommitsContext } from 'semantic-release'; From e4eda99bf5c1c13f3933341c9bf7b5ff5555933a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:58:34 +0100 Subject: [PATCH 10/12] test: mark three temp-directory cleanup callbacks async Each callback's body is a bare return of rm(...), a promise-returning call, which promise-function-async now requires the callback itself to be declared async for. --- src/cli.test.ts | 2 +- src/manifest.test.ts | 2 +- src/workspace.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index edde493..d6c7315 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,7 +8,7 @@ import { createProgram, readReleaseConfigFile } from './cli'; const temporaryDirectories: string[] = []; afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); + await Promise.all(temporaryDirectories.splice(0).map(async (directory) => rm(directory, { recursive: true, force: true }))); }); async function temporaryConfigFile(filename: string, contents: string): Promise { diff --git a/src/manifest.test.ts b/src/manifest.test.ts index 8502073..8e435bc 100644 --- a/src/manifest.test.ts +++ b/src/manifest.test.ts @@ -8,7 +8,7 @@ import { readManifest } from './manifest'; const temporaryDirectories: string[] = []; afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); + await Promise.all(temporaryDirectories.splice(0).map(async (directory) => rm(directory, { recursive: true, force: true }))); }); async function temporaryManifest(manifest: Record): Promise { diff --git a/src/workspace.test.ts b/src/workspace.test.ts index ff3a9e1..78606a2 100644 --- a/src/workspace.test.ts +++ b/src/workspace.test.ts @@ -9,7 +9,7 @@ import { discoverWorkspace } from './workspace'; const temporaryDirectories: string[] = []; afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); + await Promise.all(temporaryDirectories.splice(0).map(async (directory) => rm(directory, { recursive: true, force: true }))); }); /** Discovery requires a real git repository (it resolves the workspace root's own prefix within the repository via `git rev-parse --show-prefix` to path-scope commit filtering correctly), so every fixture directory is git-initialised even when a test never makes a commit. */ From 3af27035afc78523616ca5ac4ebee757f6a50a52 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:58:59 +0100 Subject: [PATCH 11/12] test: extract a shared TestTimeoutMs enum, derive fixture-count assertions Every fixture-backed test's third-argument timeout (240_000, 60_000, or 20_000ms) was a bare literal repeated across four files, which the newly enabled no-magic-numbers rule flags wherever it appears, including a well-named module constant: the shared config exempts enum members from that rule but not const declarations. A new TestTimeoutMs enum in test-timeouts.ts replaces every one of those literals with a named tier (Long/Medium/Short) shared across the suite instead of redeclaring it per file. A handful of assertions also hardcoded a package or commit count that was really a property of the fixture array already in scope (chainPackages.length, or a newly-named cyclePackages.length + 1 for createWorkspaceFixture's own "one scaffold commit plus one per package" invariant) -- deriving it from that array instead of a literal removes the magic number and keeps the assertion correct if the fixture ever grows. --- src/gate-publish.test.ts | 13 ++++----- src/git.test.ts | 3 ++- src/release.test.ts | 45 +++++++++++++++---------------- src/single-commit-release.test.ts | 17 ++++++------ src/test-timeouts.ts | 11 ++++++++ 5 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 src/test-timeouts.ts diff --git a/src/gate-publish.test.ts b/src/gate-publish.test.ts index ae5346f..ca1e8aa 100644 --- a/src/gate-publish.test.ts +++ b/src/gate-publish.test.ts @@ -8,6 +8,7 @@ import { type FixturePackage, createWorkspaceFixture } from './git-workspace-fix import { isJsonObject, isUnknownArray } from './json'; import { type PublishPluginSpec } from './plugins'; import { releaseWorkspace } from './release'; +import { TestTimeoutMs } from './test-timeouts'; interface RecordingPlugin { readonly modulePath: string; @@ -15,7 +16,7 @@ interface RecordingPlugin { } /** - * A real, resolvable ESM plugin module recording every `publish`/`success` call -- not a mock. `PublishPluginSpec` only accepts a module name or file path (not an inline object the way semantic-release's own engine supports, see @exadev/release-gate's test fixtures for that alternative), so this writes a genuine file `resolvePluginModule` resolves via `require.resolve` on its absolute path. Calls are recorded to a plain JSON file on disk, synchronously, rather than an in-memory module-level array: semantic-release's own plugin loader (`await import(...)` deep inside its own compiled internals) and this test file's own re-import of the same path are two separate module registries under vitest's vite-node runtime, so a shared in-memory array written by one is invisible to the other -- confirmed directly, the array read back was always empty despite the real calls genuinely happening. A file on disk has no such ambiguity, and incidentally matches this feature's own real-world shape better: a resume can genuinely run in a different process from the one that recorded a detach. + * A real, resolvable ESM plugin module recording every `publish`/`success` call -- not a mock. `PublishPluginSpec` only accepts a module name or file path (not an inline object the way semantic-release's own engine supports, see `@exadev/release-gate`'s test fixtures for that alternative), so this writes a genuine file `resolvePluginModule` resolves via `require.resolve` on its absolute path. Calls are recorded to a plain JSON file on disk, synchronously, rather than an in-memory module-level array: semantic-release's own plugin loader (`await import(...)` deep inside its own compiled internals) and this test file's own re-import of the same path are two separate module registries under vitest's vite-node runtime, so a shared in-memory array written by one is invisible to the other -- confirmed directly, the array read back was always empty despite the real calls genuinely happening. A file on disk has no such ambiguity, and incidentally matches this feature's own real-world shape better: a resume can genuinely run in a different process from the one that recorded a detach. */ async function writeRecordingPlugin(dir: string): Promise { const modulePath = join(dir, 'recording-plugin.js'); @@ -124,14 +125,14 @@ describe('gatePublish against a real git workspace', () => { ]); const finalCalls = await readRecordingPluginCalls(recordingPlugin); - expect(finalCalls.publish).toHaveLength(3); - expect(finalCalls.success).toHaveLength(3); + expect(finalCalls.publish).toHaveLength(chainPackages.length); + expect(finalCalls.success).toHaveLength(chainPackages.length); // Published in the same topological order the detach pass tagged them in, not re-derived and not reversed. expect(finalCalls.publish.map((call) => call.version)).toEqual(['1.1.0', '1.0.1', '1.0.1']); } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('rejects gatePublish combined with commitStrategy "single" before touching git', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -151,7 +152,7 @@ describe('gatePublish against a real git workspace', () => { } finally { await fixture.remove(); } - }, 60_000); + }, TestTimeoutMs.Medium); it('resumeWorkspaceRelease reports "no release to resume" for a package the detach pass found nothing to release for', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -182,5 +183,5 @@ describe('gatePublish against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); }); diff --git a/src/git.test.ts b/src/git.test.ts index f37f2f7..931487f 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { GitCommandError, WorkspaceStateError } from './errors'; import { changedPathsSince, createTag, currentBranch, git, pushHead, pushHeadAndTags, resolveCommitIdentity } from './git'; import { createWorkspaceFixture } from './git-workspace-fixture'; +import { TestTimeoutMs } from './test-timeouts'; /** One package is enough for every git-level behaviour here: these tests are about the repository, not about the workspace's shape. */ const onePackage = [{ name: '@fixture/only', version: '1.0.0' }] as const; @@ -71,7 +72,7 @@ describe('pushHeadAndTags', () => { await fixture.remove(); } // 20s, not the file's default: two full workspace fixtures (each its own git init, bare remote, and initial push) run here, tight against the default budget under the disk and process contention a full concurrent suite run adds. - }, 20_000); + }, TestTimeoutMs.Short); }); describe('resolveCommitIdentity', () => { diff --git a/src/release.test.ts b/src/release.test.ts index a04e21d..10416a0 100644 --- a/src/release.test.ts +++ b/src/release.test.ts @@ -9,9 +9,10 @@ import { isJsonObject } from './json'; import { writeDependencyRange } from './manifest'; import { type PublishPluginSpec } from './plugins'; import { releaseWorkspace } from './release'; +import { TestTimeoutMs } from './test-timeouts'; /** - * The publish pipeline for these tests is deliberately offline: @semantic-release/npm with npmPublish false still performs the real manifest version bump in prepare, and @semantic-release/git still performs the real release commit, so every part of the orchestrator's sequencing is exercised against real git state (tags, commits, pushes to the fixture's bare remote) without touching the npm registry or GitHub. + * The publish pipeline for these tests is deliberately offline: `@semantic-release/npm` with npmPublish false still performs the real manifest version bump in prepare, and `@semantic-release/git` still performs the real release commit, so every part of the orchestrator's sequencing is exercised against real git state (tags, commits, pushes to the fixture's bare remote) without touching the npm registry or GitHub. */ const FIXTURE_PLUGINS: readonly PublishPluginSpec[] = [ ['@semantic-release/npm', { npmPublish: false }], @@ -96,7 +97,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('behaves identically whether commitStrategy is omitted or explicitly set to "per-package"', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -115,11 +116,11 @@ describe('releaseWorkspace against a real git workspace', () => { const bumpLog = await git(['log', '--format=%s', '--grep=^chore(deps):', 'main'], { cwd: fixture.root }); expect(bumpLog.split('\n').filter(Boolean)).toHaveLength(2); const releaseLog = await git(['log', '--format=%s', '--grep=^chore(release):', 'main'], { cwd: fixture.root }); - expect(releaseLog.split('\n').filter(Boolean)).toHaveLength(3); + expect(releaseLog.split('\n').filter(Boolean)).toHaveLength(chainPackages.length); } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('finds nothing to release on a second run over already-released state', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -136,7 +137,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('reports the same cascade in a dry run, including the forced dependency patches, without writing anything', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -158,29 +159,27 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('fails loudly on a cyclic dependency graph, naming the loop, instead of picking an arbitrary order', async () => { - const fixture = await createWorkspaceFixture( - [ - { name: '@fixture/x', version: '1.0.0', dependencies: { '@fixture/y': '^1.0.0' } }, - { name: '@fixture/y', version: '1.0.0', dependencies: { '@fixture/x': '^1.0.0' } }, - ], - [], - ); + const cyclePackages: readonly FixturePackage[] = [ + { name: '@fixture/x', version: '1.0.0', dependencies: { '@fixture/y': '^1.0.0' } }, + { name: '@fixture/y', version: '1.0.0', dependencies: { '@fixture/x': '^1.0.0' } }, + ]; + const fixture = await createWorkspaceFixture(cyclePackages, []); try { const failure = releaseWorkspace({ root: fixture.root, env: releaseEnv(), plugins: FIXTURE_PLUGINS }); await expect(failure).rejects.toBeInstanceOf(DependencyCycleError); await expect(failure).rejects.toThrow( /cycle: @fixture\/x -> @fixture\/y -> @fixture\/x|cycle: @fixture\/y -> @fixture\/x -> @fixture\/y/, ); - // Nothing was committed while failing: the log still holds only the fixture's own scaffolding. + // Nothing was committed while failing: the log still holds only the fixture's own scaffolding -- one "scaffold workspace" commit plus one per package (see createWorkspaceFixture). const log = await git(['log', '--oneline', 'main'], { cwd: fixture.root }); - expect(log.split('\n').filter(Boolean)).toHaveLength(3); + expect(log.split('\n').filter(Boolean)).toHaveLength(cyclePackages.length + 1); } finally { await fixture.remove(); } - }, 60_000); + }, TestTimeoutMs.Medium); it('cascades through workspace: ranges pnpm resolves at publish time, releasing dependents without editing or committing their manifests', async () => { const fixture = await createWorkspaceFixture( @@ -225,7 +224,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('releases only the package with changes; the packages upstream of it release nothing', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -241,7 +240,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('releases every package correctly when the workspace is nested below the git repository toplevel', async () => { const fixture = await createWorkspaceFixture( @@ -262,7 +261,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('releases a package whose directory name contains a non-ASCII character', async () => { const fixture = await createWorkspaceFixture( @@ -286,7 +285,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('recovers the forced-patch decision from a bump commit already in history, as if a previous run stopped between the dependency release and the dependent turn', async () => { const fixture = await createWorkspaceFixture( @@ -321,7 +320,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('regenerates pnpm-lock.yaml alongside a dependency-range bump, so the two never land out of sync', async () => { const fixture = await createWorkspaceFixture( @@ -346,7 +345,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('rejects an unsupported dependency range before anything releases, not only once the dependency it names has already been published', async () => { const fixture = await createWorkspaceFixture( @@ -367,7 +366,7 @@ describe('releaseWorkspace against a real git workspace', () => { } finally { await fixture.remove(); } - }, 60_000); + }, TestTimeoutMs.Medium); }); async function readManifest(root: string, packageName: string): Promise> { diff --git a/src/single-commit-release.test.ts b/src/single-commit-release.test.ts index ea00449..01dd836 100644 --- a/src/single-commit-release.test.ts +++ b/src/single-commit-release.test.ts @@ -7,9 +7,10 @@ import { type FixturePackage, createWorkspaceFixture } from './git-workspace-fix import { isJsonObject } from './json'; import { type PublishPluginSpec } from './plugins'; import { releaseWorkspace } from './release'; +import { TestTimeoutMs } from './test-timeouts'; /** - * @semantic-release/changelog and @semantic-release/npm (with npmPublish false) are enough to exercise the real "prepare" path (version bump + changelog write) and the real "publish"/"verifyConditions" path (both skip real registry/network calls when npmPublish is false, exactly like release.test.ts's own FIXTURE_PLUGINS) without ever touching the npm registry or GitHub. @semantic-release/git is deliberately absent: commitStrategy "single" rejects it outright (see the dedicated test below). + * `@semantic-release/changelog` and `@semantic-release/npm` (with npmPublish false) are enough to exercise the real "prepare" path (version bump + changelog write) and the real "publish"/"verifyConditions" path (both skip real registry/network calls when npmPublish is false, exactly like release.test.ts's own FIXTURE_PLUGINS) without ever touching the npm registry or GitHub. `@semantic-release/git` is deliberately absent: commitStrategy "single" rejects it outright (see the dedicated test below). */ const SINGLE_FIXTURE_PLUGINS: readonly PublishPluginSpec[] = ['@semantic-release/changelog', ['@semantic-release/npm', { npmPublish: false }]]; @@ -102,7 +103,7 @@ describe('releaseWorkspace with commitStrategy "single"', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('releases only the package with changes; the packages upstream of it release nothing and nothing is committed for them', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -122,7 +123,7 @@ describe('releaseWorkspace with commitStrategy "single"', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('reports the same cascade in a dry run, including the forced dependency patches, without writing, committing, tagging, or pushing anything', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -148,7 +149,7 @@ describe('releaseWorkspace with commitStrategy "single"', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('finds nothing to release, and makes no commit at all, on a second run over already-released state', async () => { const fixture = await createWorkspaceFixture(chainPackages, [ @@ -169,7 +170,7 @@ describe('releaseWorkspace with commitStrategy "single"', () => { } finally { await fixture.remove(); } - }, 240_000); + }, TestTimeoutMs.Long); it('rejects @semantic-release/git in the plugin list, since this mode does its own committing', async () => { const fixture = await createWorkspaceFixture(chainPackages, []); @@ -183,13 +184,13 @@ describe('releaseWorkspace with commitStrategy "single"', () => { await expect(failure).rejects.toBeInstanceOf(ReleaseConfigurationError); await expect(failure).rejects.toThrow(/@semantic-release\/git/); - // Nothing published: the run fails before analysis even starts. + // Nothing published: the run fails before analysis even starts -- one "scaffold workspace" commit plus one per chainPackages entry (see createWorkspaceFixture). const log = await git(['log', '--oneline', 'main'], { cwd: fixture.root }); - expect(log.split('\n').filter(Boolean)).toHaveLength(4); + expect(log.split('\n').filter(Boolean)).toHaveLength(chainPackages.length + 1); } finally { await fixture.remove(); } - }, 60_000); + }, TestTimeoutMs.Medium); }); async function readManifest(root: string, packageName: string): Promise> { diff --git a/src/test-timeouts.ts b/src/test-timeouts.ts new file mode 100644 index 0000000..f5bdba0 --- /dev/null +++ b/src/test-timeouts.ts @@ -0,0 +1,11 @@ +/** + * Shared vitest per-test timeout budgets (milliseconds) for this package's fixture-backed integration tests: each spins up a real git repository, a real bare remote, and (for most suites) semantic-release's actual git/npm plugin pipeline, which is slow enough that vitest's default 5-second per-test timeout is never enough. + */ +export const enum TestTimeoutMs { + /** A full multi-package release run through the real plugin pipeline (per-package or single-commit strategy, with or without gate-publish resume) -- the slowest category. */ + Long = 240_000, + /** A fixture-backed case that fails fast (a validation error, a cycle check) or exercises only one release before any heavier pipeline work happens, but still pays for fixture setup/teardown. */ + Medium = 60_000, + /** The fastest fixture-backed cases: workspace fixtures (each its own git init, bare remote, and initial push) with no release pipeline at all. */ + Short = 20_000, +} From 5946048b8abe4ae399a6c06d041110fd34acf865 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:03:45 +0100 Subject: [PATCH 12/12] chore: bump the pinned pnpm from 11.6.0 to the exact 12.4.1 release Run via `corepack use pnpm@12.4.1`, which also regenerates the lockfile for the new engine: pnpm 12 records its own executable as a packageManagerDependency in a new leading YAML document ahead of the existing workspace lockfile, rather than changing that document's own shape. install (plain and --frozen-lockfile), lint, typecheck, test, and build all pass unchanged under the new engine. --- package.json | 2 +- pnpm-lock.yaml | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d49365..ff75654 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "ci" ], "license": "MIT", - "packageManager": "pnpm@11.6.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "dependencies": { "@exadev/release-gate": "^1.0.0", "commander": "^15.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 925c7cc..ca70269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.1 + version: 12.4.1 + +packages: + + '@pnpm/exe.android-arm64@12.4.1': + resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.4.1': + resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.4.1': + resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.4.1': + resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.4.1': + resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.4.1': + resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.4.1': + resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.4.1': + resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.4.1': + resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.4.1': + resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.4.1': + resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.4.1': + resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.4.1': + resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.4.1': + resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==} + cpu: [x64] + os: [win32] + + pnpm@12.4.1: + resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.4.1': + optional: true + + '@pnpm/exe.android-x64@12.4.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.4.1': + optional: true + + '@pnpm/exe.darwin-x64@12.4.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.4.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.4.1': + optional: true + + '@pnpm/exe.linux-arm64@12.4.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.4.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.4.1': + optional: true + + '@pnpm/exe.linux-s390x@12.4.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.4.1': + optional: true + + '@pnpm/exe.linux-x64@12.4.1': + optional: true + + '@pnpm/exe.win32-arm64@12.4.1': + optional: true + + '@pnpm/exe.win32-x64@12.4.1': + optional: true + + pnpm@12.4.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.4.1 + '@pnpm/exe.android-x64': 12.4.1 + '@pnpm/exe.darwin-arm64': 12.4.1 + '@pnpm/exe.darwin-x64': 12.4.1 + '@pnpm/exe.freebsd-x64': 12.4.1 + '@pnpm/exe.linux-arm64': 12.4.1 + '@pnpm/exe.linux-arm64-musl': 12.4.1 + '@pnpm/exe.linux-ppc64': 12.4.1 + '@pnpm/exe.linux-riscv64': 12.4.1 + '@pnpm/exe.linux-s390x': 12.4.1 + '@pnpm/exe.linux-x64': 12.4.1 + '@pnpm/exe.linux-x64-musl': 12.4.1 + '@pnpm/exe.win32-arm64': 12.4.1 + '@pnpm/exe.win32-x64': 12.4.1 + +--- lockfileVersion: '9.0' settings: