From c09725d3d1fccfe1da8d1b5b54f7bcf4c8f6dd37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 29 Oct 2026 15:05:55 +0100 Subject: [PATCH 01/26] fix: retain third-party licenses in distributed artifacts --- LICENSES/Apache-2.0.txt | 202 ++++++++++ LICENSES/jpeg-js.txt | 53 +++ THIRD_PARTY_NOTICES.md | 40 ++ package.json | 1 + pnpm-lock.yaml | 365 ++++++++++++++++++ .../__tests__/bundled-license-notices.test.ts | 112 ++++++ scripts/lib/bundled-license-notices.ts | 36 ++ tsdown.config.ts | 9 +- 8 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/jpeg-js.txt create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 scripts/__tests__/bundled-license-notices.test.ts create mode 100644 scripts/lib/bundled-license-notices.ts diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/jpeg-js.txt b/LICENSES/jpeg-js.txt new file mode 100644 index 0000000000..6dcbbfa4c0 --- /dev/null +++ b/LICENSES/jpeg-js.txt @@ -0,0 +1,53 @@ +jpeg-js 0.4.4 source-file notices + +lib/decoder.js: + + Copyright 2011 notmasteryet + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +The full Apache License 2.0 accompanies this notice. + +lib/encoder.js: + + Copyright (c) 2008, Adobe Systems Incorporated + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of Adobe Systems Incorporated nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +JPEG encoder ported to JavaScript and optimized by Andreas Ritter, www.bytestrom.eu, 11/2009 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..7d8868279c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,40 @@ +# Third-party material + +Third-party material retains its original license. The root MIT license does not +replace the licenses listed here. + +## Bundled JavaScript dependencies + +Each build generates `dist/src/THIRD_PARTY_NOTICES.txt` from the dependencies +included in the bundle. That file ships in the npm package with the dependencies' +license texts and applicable NOTICE contents. Dependencies resolved separately at +runtime retain the license files in their own packages. + +`jpeg-js` also carries source-file notices for its decoder and encoder, beyond +the package-level BSD license. These are retained in +[`LICENSES/jpeg-js.txt`](LICENSES/jpeg-js.txt) and included with the Apache License +2.0 in the generated notice file whenever `jpeg-js` is bundled. + +## Maestro conformance fixtures + +The 42 upstream flows in `scripts/maestro-conformance/corpus/upstream/` are copied +without modification from [Maestro v2.5.1](https://github.com/mobile-dev-inc/Maestro/tree/v2.5.1), +commit `a4c7c95f5ba1884858f7e35efa6b8e0165db9448`, under the Apache License 2.0. +Their original paths and SHA-256 hashes are recorded in the adjacent +`scripts/maestro-conformance/corpus/manifest.json`. + +The license is retained in [LICENSES/Apache-2.0.txt](LICENSES/Apache-2.0.txt). +The conformance fixtures are development inputs and do not ship in the npm package. + +## Gradle wrapper + +The Gradle wrapper in `scripts/maestro-conformance/jvm-harness/` is licensed under +the [Apache License 2.0](LICENSES/Apache-2.0.txt). +Its wrapper scripts retain the upstream copyright notices for the original authors. + +## Simulator accessibility bridge + +`apple/snapshot-bridge/SnapshotBridgeRuntime.m` contains code adapted from Meta's +idb v1.5.2. Its MIT copyright notice and license are retained in +[`apple/snapshot-bridge/LICENSE.idb`](apple/snapshot-bridge/LICENSE.idb), which also +ships with the bridge in the npm package. diff --git a/package.json b/package.json index 7035902303..0f62669662 100644 --- a/package.json +++ b/package.json @@ -367,6 +367,7 @@ "oxlint": "^1.79.0", "pngjs": "^7.0.0", "publint": "^0.3.24", + "rollup-plugin-license": "^3.7.1", "tar-stream": "^3.2.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e4bdab090..8158af07c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: publint: specifier: ^0.3.24 version: 0.3.24 + rollup-plugin-license: + specifier: ^3.7.1 + version: 3.7.1(picomatch@4.0.7)(rollup@4.66.0) tar-stream: specifier: ^3.2.0 version: 3.2.0 @@ -2098,6 +2101,13 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -2737,6 +2747,144 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.66.0': + resolution: {integrity: sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.66.0': + resolution: {integrity: sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.66.0': + resolution: {integrity: sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.66.0': + resolution: {integrity: sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.66.0': + resolution: {integrity: sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.66.0': + resolution: {integrity: sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.66.0': + resolution: {integrity: sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.66.0': + resolution: {integrity: sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.66.0': + resolution: {integrity: sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.66.0': + resolution: {integrity: sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.66.0': + resolution: {integrity: sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.66.0': + resolution: {integrity: sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.66.0': + resolution: {integrity: sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.66.0': + resolution: {integrity: sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.66.0': + resolution: {integrity: sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.66.0': + resolution: {integrity: sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.66.0': + resolution: {integrity: sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.66.0': + resolution: {integrity: sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.66.0': + resolution: {integrity: sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.66.0': + resolution: {integrity: sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.66.0': + resolution: {integrity: sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.66.0': + resolution: {integrity: sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.66.0': + resolution: {integrity: sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.66.0': + resolution: {integrity: sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.66.0': + resolution: {integrity: sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==} + cpu: [x64] + os: [win32] + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3445,6 +3593,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-find-index@1.0.2: + resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} + engines: {node: '>=0.10.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3656,6 +3808,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commenting@1.1.0: + resolution: {integrity: sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA==} + common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} @@ -4439,6 +4594,9 @@ packages: lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4450,6 +4608,9 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -4617,6 +4778,9 @@ packages: resolution: {integrity: sha512-t+Vn6DX4wPCxS23y03C0uhF7S1jpi1GfqPrnlEipCJaA2ykiNbMkVlDutZtmvLfRcUN6R8AYVlzTsQrT4RZoLQ==} engines: {node: 18 || 20 || >=22} + moment@2.31.0: + resolution: {integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4786,6 +4950,10 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + package-name-regex@2.0.6: + resolution: {integrity: sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA==} + engines: {node: '>=12'} + pagefind@1.5.2: resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} hasBin: true @@ -5008,6 +5176,17 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-license@3.7.1: + resolution: {integrity: sha512-FcGXUbAmPvRSLxjVdjp/r/MUtKBlttVQd+ApUyvKfREnsoAfAZA6Ic2fE1Tz4RL0f9XqEQU9UIRNUMdtQtliDw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + + rollup@4.66.0: + resolution: {integrity: sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5102,6 +5281,27 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-compare@1.0.0: + resolution: {integrity: sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-expression-validate@2.0.0: + resolution: {integrity: sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg==} + + spdx-license-ids@3.0.24: + resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} + + spdx-ranges@2.1.1: + resolution: {integrity: sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==} + + spdx-satisfies@5.0.1: + resolution: {integrity: sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==} + starlight-llms-txt@0.12.0: resolution: {integrity: sha512-ya4c0ZoiPUXuHDzd3AYCKh78Y3II3EToSE9Yp6uVN0c2UUKoNpqTjijhW86W/4toZzSUhWSnxeYs7QBcXNIPGg==} peerDependencies: @@ -6711,6 +6911,9 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -7046,6 +7249,81 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.66.0': + optional: true + + '@rollup/rollup-android-arm64@4.66.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.66.0': + optional: true + + '@rollup/rollup-darwin-x64@4.66.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.66.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.66.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.66.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.66.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.66.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.66.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.66.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.66.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.66.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.66.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.66.0': + optional: true + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@4.0.2': @@ -7669,6 +7947,8 @@ snapshots: aria-query@5.3.2: {} + array-find-index@1.0.2: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7(patch_hash=8f07585d066beb92a665ea78ec8bef87af3af32de9076c9e23122fbabd9fb475): @@ -7927,6 +8207,8 @@ snapshots: commander@14.0.3: {} + commenting@1.1.0: {} + common-ancestor-path@2.0.0: {} convert-source-map@2.0.0: {} @@ -8696,12 +8978,18 @@ snapshots: lodash.groupby@4.6.0: {} + lodash@4.18.1: {} + longest-streak@3.1.0: {} lru-cache@11.5.2: {} lru-cache@11.5.3: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -9075,6 +9363,8 @@ snapshots: '@isaacs/nonrecursive-glob-match': 1.0.3 brace-expansion: 5.0.12 + moment@2.31.0: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -9271,6 +9561,8 @@ snapshots: package-manager-detector@1.9.0: {} + package-name-regex@2.0.6: {} + pagefind@1.5.2: optionalDependencies: '@pagefind/darwin-arm64': 1.5.2 @@ -9526,6 +9818,52 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5 + rollup-plugin-license@3.7.1(picomatch@4.0.7)(rollup@4.66.0): + dependencies: + commenting: 1.1.0 + fdir: 6.5.0(picomatch@4.0.7) + lodash: 4.18.1 + magic-string: 0.30.21 + moment: 2.31.0 + package-name-regex: 2.0.6 + rollup: 4.66.0 + spdx-expression-validate: 2.0.0 + spdx-satisfies: 5.0.1 + transitivePeerDependencies: + - picomatch + + rollup@4.66.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.66.0 + '@rollup/rollup-android-arm64': 4.66.0 + '@rollup/rollup-darwin-arm64': 4.66.0 + '@rollup/rollup-darwin-x64': 4.66.0 + '@rollup/rollup-freebsd-arm64': 4.66.0 + '@rollup/rollup-freebsd-x64': 4.66.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.66.0 + '@rollup/rollup-linux-arm-musleabihf': 4.66.0 + '@rollup/rollup-linux-arm64-gnu': 4.66.0 + '@rollup/rollup-linux-arm64-musl': 4.66.0 + '@rollup/rollup-linux-loong64-gnu': 4.66.0 + '@rollup/rollup-linux-loong64-musl': 4.66.0 + '@rollup/rollup-linux-ppc64-gnu': 4.66.0 + '@rollup/rollup-linux-ppc64-musl': 4.66.0 + '@rollup/rollup-linux-riscv64-gnu': 4.66.0 + '@rollup/rollup-linux-riscv64-musl': 4.66.0 + '@rollup/rollup-linux-s390x-gnu': 4.66.0 + '@rollup/rollup-linux-x64-gnu': 4.66.0 + '@rollup/rollup-linux-x64-musl': 4.66.0 + '@rollup/rollup-openbsd-x64': 4.66.0 + '@rollup/rollup-openharmony-arm64': 4.66.0 + '@rollup/rollup-win32-arm64-msvc': 4.66.0 + '@rollup/rollup-win32-ia32-msvc': 4.66.0 + '@rollup/rollup-win32-x64-gnu': 4.66.0 + '@rollup/rollup-win32-x64-msvc': 4.66.0 + fsevents: 2.3.3 + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -9669,6 +10007,33 @@ snapshots: space-separated-tokens@2.0.2: {} + spdx-compare@1.0.0: + dependencies: + array-find-index: 1.0.2 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.24 + + spdx-expression-validate@2.0.0: + dependencies: + spdx-expression-parse: 3.0.1 + + spdx-license-ids@3.0.24: {} + + spdx-ranges@2.1.1: {} + + spdx-satisfies@5.0.1: + dependencies: + spdx-compare: 1.0.0 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + starlight-llms-txt@0.12.0(@astrojs/markdown-satteri@0.4.5)(@astrojs/starlight@0.42.7(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1))(supports-color@7.2.0)(typescript@5.9.3))(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1))(supports-color@7.2.0): dependencies: '@astrojs/mdx': 8.1.1(@astrojs/markdown-satteri@0.4.5)(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1)) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts new file mode 100644 index 0000000000..58dfe8228c --- /dev/null +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { build } from 'tsdown'; +import { test } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import { bundledLicenseNotices } from '../lib/bundled-license-notices.ts'; + +async function fixture(files: Record): Promise { + const root = await mkdtempForTest('bundled-licenses-'); + const contents = { + 'package.json': JSON.stringify({ name: 'fixture-project', private: true, type: 'module' }), + ...files, + }; + for (const [relative, source] of Object.entries(contents)) { + const file = path.join(root, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, source); + } + return root; +} + +async function bundle(root: string): Promise { + await build({ + config: false, + cwd: root, + entry: ['input.ts'], + outDir: 'dist', + dts: false, + minify: true, + deps: { onlyBundle: ['included', 'unused', 'jpeg-js'] }, + plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], + logLevel: 'silent', + }); +} + +test('bundled licenses retain full license and NOTICE text after minification', async () => { + const root = await fixture({ + 'input.ts': [ + "import { value } from 'included';", + "import { ignored } from 'unused';", + "import { external } from 'external';", + 'export const result = value + external;', + ].join('\n'), + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.2.3', + license: 'MIT', + main: 'index.js', + }), + 'node_modules/included/index.js': + "export const value = process.env.FIXTURE_NUMBER ?? 'fixture';", + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms retained.', + 'node_modules/included/NOTICE': 'Upstream attribution retained.', + 'node_modules/unused/package.json': JSON.stringify({ + name: 'unused', + version: '1.0.0', + main: 'index.js', + sideEffects: false, + }), + 'node_modules/unused/index.js': 'export const ignored = 99;', + 'node_modules/external/package.json': JSON.stringify({ + name: 'external', + version: '1.0.0', + main: 'index.js', + }), + 'node_modules/external/index.js': 'export const external = 1;', + }); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /included/); + assert.match(notices, /1\.2\.3/); + assert.match(notices, /Copyright fixture author\nFull license terms retained\./); + assert.match(notices, /Upstream attribution retained\./); + assert.doesNotMatch(notices, /unused|external|fixture-project/); +}); + +test('bundling fails when a dependency has a license identifier but no license text', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }), + 'node_modules/included/index.js': 'export const value = 42;', + }); + await assert.rejects(bundle(root), /included@1\.0\.0 is missing its license or license text/); +}); + +test('bundled jpeg-js retains source-file notices and the Apache license', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'jpeg-js';", + 'node_modules/jpeg-js/package.json': JSON.stringify({ + name: 'jpeg-js', + version: '0.4.4', + license: 'BSD-3-Clause', + main: 'index.js', + }), + 'node_modules/jpeg-js/index.js': 'export const value = process.env.FIXTURE_NUMBER;', + 'node_modules/jpeg-js/LICENSE': 'Package-level BSD terms.', + 'LICENSES/jpeg-js.txt': 'Copyright 2011 notmasteryet\nCopyright 2008 Adobe.', + 'LICENSES/Apache-2.0.txt': 'Full Apache license text.', + }); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /Package-level BSD terms/); + assert.match(notices, /Copyright 2011 notmasteryet/); + assert.match(notices, /Copyright 2008 Adobe/); + assert.match(notices, /Full Apache license text/); +}); diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts new file mode 100644 index 0000000000..6198ee77cf --- /dev/null +++ b/scripts/lib/bundled-license-notices.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import license from 'rollup-plugin-license'; + +export function bundledLicenseNotices(root: string, outputDirectory: string) { + return license({ + cwd: root, + sourcemap: false, + thirdParty: { + multipleVersions: true, + output: { + file: path.join(outputDirectory, 'THIRD_PARTY_NOTICES.txt'), + template(dependencies) { + for (const dependency of dependencies) { + if (!dependency.license || !dependency.licenseText?.trim()) { + throw new Error( + `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, + ); + } + } + const notices = dependencies.map((dependency) => { + if (dependency.name !== 'jpeg-js') return dependency.text(); + return [ + dependency.text(), + fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8'), + fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8'), + ].join('\n\n'); + }); + return notices.length === 0 + ? 'No third-party dependencies are bundled.\n' + : notices.join('\n\n'); + }, + }, + }, + }); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 26b29b5a65..957be33693 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,6 +2,7 @@ import { buildEntries } from './scripts/build-entries.ts'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { defineConfig, type TsdownPluginOption } from 'tsdown'; +import { bundledLicenseNotices } from './scripts/lib/bundled-license-notices.ts'; const typeScriptPackageJsonUrl = import.meta.resolve('typescript/package.json'); const { default: getTypeScript7ExePath } = await import( @@ -174,7 +175,13 @@ export default defineConfig({ }, outExtensions: () => ({ js: '.js', dts: '.d.ts' }), minify: true, - plugins: [dropAmbientDeclarationImports], + plugins: [ + dropAmbientDeclarationImports, + bundledLicenseNotices( + import.meta.dirname, + fileURLToPath(new URL('./dist/src', import.meta.url)), + ), + ], dts: { tsgo: { path: getTypeScript7ExePath(), From b47f0cbcb9016a8795fbb0413a55c8e2665b4f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 29 Oct 2026 15:05:55 +0100 Subject: [PATCH 02/26] chore(gates): require bundled license notices in npm packages --- .../fixtures/size-report-npm-pack.json | 4 ++++ scripts/__tests__/size-report-package.test.ts | 21 +++++++++++++++++++ scripts/check-package.ts | 4 +++- scripts/size-report-package.mjs | 9 ++++++++ vitest.config.ts | 1 + 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index 8494a3fcc3..25a3fcb700 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -1,6 +1,10 @@ { "unpackedSize": 2319, "files": [ + { + "path": "dist/src/THIRD_PARTY_NOTICES.txt", + "size": 100 + }, { "path": "dist/src/index.js", "size": 401 diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 137cadee5b..f1750672ea 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -23,6 +23,27 @@ const helperManifestSource = [ ' name: "AgentDeviceMacOSInputTests",', ].join('\n'); +test('publish package requires bundled dependency license notices', () => { + assert.throws( + () => + assertPublishPackageContents( + fixturePack.files.filter((entry) => entry.path !== 'dist/src/THIRD_PARTY_NOTICES.txt'), + { macosHelperManifestSource: helperManifestSource }, + ), + /THIRD_PARTY_NOTICES\.txt/, + ); + assert.throws( + () => + assertPublishPackageContents( + fixturePack.files.map((entry) => + entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt' ? { ...entry, size: 0 } : entry, + ), + { macosHelperManifestSource: helperManifestSource }, + ), + /non-empty.*THIRD_PARTY_NOTICES\.txt/, + ); +}); + test('publish package requires both Android helpers and excludes benchmark scripts', () => { assert.doesNotThrow(() => assertPublishPackageContents(fixturePack.files, { diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 5b7291301e..c511a077a0 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -31,6 +31,7 @@ import { type PackedManifest as PackedDependencies, } from './lib/shipped-imports.ts'; import { assertInstalledSnapshotBridge } from './size-report-install.mjs'; +import { assertBundledLicenseNotices } from './size-report-package.mjs'; import { testOnlyContentFailures, testOnlyPathFailures } from './lib/shipped-test-code.ts'; import { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; @@ -102,7 +103,8 @@ function packTarball(): { tarball: string; packedPaths: string[] } { ['pack', '--ignore-scripts', '--json', '--pack-destination', packDestination], repoRoot, ), - ) as [{ filename: string; files: { path: string }[] }]; + ) as [{ filename: string; files: { path: string; size: number }[] }]; + assertBundledLicenseNotices(packed[0].files); return { tarball: path.join(packDestination, packed[0].filename), packedPaths: packed[0].files.map((file) => file.path), diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 787f624012..84ee1876a9 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -65,7 +65,16 @@ export function collectNpmPack(root) { }; } +export function assertBundledLicenseNotices(entries) { + if ( + !entries.some((entry) => entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt' && entry.size > 0) + ) { + throw new Error('npm pack is missing non-empty dist/src/THIRD_PARTY_NOTICES.txt'); + } +} + export function assertPublishPackageContents(entries, options = {}) { + assertBundledLicenseNotices(entries); const paths = entries.map((entry) => entry.path); const requiredAssets = [ { directory: 'android/snapshot-helper/dist/', suffix: '.apk' }, diff --git a/vitest.config.ts b/vitest.config.ts index d3becdd644..1e831a7926 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -184,6 +184,7 @@ export default defineConfig({ // Package attribution models npm-pack output. Keep it in the fast lane so every new // package path remains accounted for. 'scripts/__tests__/size-report-package.test.ts', + 'scripts/__tests__/bundled-license-notices.test.ts', // Publish preparation spawns only fixture-owned scripts and proves both Android // helper families are rebuilt through the shared release/size-report owner. 'scripts/__tests__/prepare-publish-assets.test.ts', From 481f306ec76237e349d228e6f2bc910cfa453dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 29 Oct 2026 15:26:29 +0100 Subject: [PATCH 03/26] perf: deduplicate bundled license notices --- .../__tests__/bundled-license-notices.test.ts | 39 ++++++++++++++++++- scripts/lib/bundled-license-notices.ts | 38 ++++++++++++------ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 58dfe8228c..ab988d6a25 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -28,7 +28,7 @@ async function bundle(root: string): Promise { outDir: 'dist', dts: false, minify: true, - deps: { onlyBundle: ['included', 'unused', 'jpeg-js'] }, + deps: { onlyBundle: ['included', 'shared', 'distinct', 'unused', 'jpeg-js'] }, plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], logLevel: 'silent', }); @@ -89,6 +89,43 @@ test('bundling fails when a dependency has a license identifier but no license t await assert.rejects(bundle(root), /included@1\.0\.0 is missing its license or license text/); }); +test('identical notices are shared without losing packages or distinct copyrights', async () => { + const sharedLicense = 'Copyright shared author\nFull shared license terms.'; + const sharedNotice = 'Shared upstream attribution.'; + const distinctLicense = 'Copyright different author\nFull shared license terms.'; + const files: Record = { + 'input.ts': [ + "export { value as first } from 'included';", + "export { value as second } from 'shared';", + "export { value as third } from 'distinct';", + ].join('\n'), + }; + for (const name of ['included', 'shared', 'distinct']) { + files[`node_modules/${name}/package.json`] = JSON.stringify({ + name, + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }); + files[`node_modules/${name}/index.js`] = `export const value = process.env.${name};`; + files[`node_modules/${name}/LICENSE`] = name === 'distinct' ? distinctLicense : sharedLicense; + files[`node_modules/${name}/NOTICE`] = sharedNotice; + } + const root = await fixture(files); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.equal(notices.split(sharedLicense).length - 1, 1); + assert.equal(notices.split(sharedNotice).length - 1, 1); + assert.equal(notices.split(distinctLicense).length - 1, 1); + const references = ['included', 'shared', 'distinct'].map((name) => { + const line = new RegExp(`^${name}@1\\.0\\.0 \\(MIT\\): (.+)$`, 'm'); + assert.match(notices, line); + return notices.match(line)?.[1]; + }); + assert.equal(references[0], references[1]); + assert.notEqual(references[0], references[2]); +}); + test('bundled jpeg-js retains source-file notices and the Apache license', async () => { const root = await fixture({ 'input.ts': "export { value } from 'jpeg-js';", diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index 6198ee77cf..21c11020c9 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -11,24 +11,40 @@ export function bundledLicenseNotices(root: string, outputDirectory: string) { output: { file: path.join(outputDirectory, 'THIRD_PARTY_NOTICES.txt'), template(dependencies) { + if (dependencies.length === 0) return 'No third-party dependencies are bundled.\n'; + const texts = new Map(); + const reference = (text: string): string => { + const normalized = text.replaceAll('\r\n', '\n').trim(); + let id = texts.get(normalized); + if (id === undefined) { + id = texts.size + 1; + texts.set(normalized, id); + } + return `[${id}]`; + }; + const packages: string[] = []; for (const dependency of dependencies) { if (!dependency.license || !dependency.licenseText?.trim()) { throw new Error( `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, ); } + const references = [reference(dependency.licenseText)]; + if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); + if (dependency.name === 'jpeg-js') { + references.push( + reference(fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8')), + reference(fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')), + ); + } + packages.push( + `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}`, + ); } - const notices = dependencies.map((dependency) => { - if (dependency.name !== 'jpeg-js') return dependency.text(); - return [ - dependency.text(), - fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8'), - fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8'), - ].join('\n\n'); - }); - return notices.length === 0 - ? 'No third-party dependencies are bundled.\n' - : notices.join('\n\n'); + return [ + packages.join('\n'), + ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`), + ].join('\n\n'); }, }, }, From 3ab13cc07b91b74f5f140b039b0a3ab5ed316529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 08:47:59 +0100 Subject: [PATCH 04/26] fix: address bundled license notice review findings --- .../__tests__/bundled-license-notices.test.ts | 48 +++++++++++++++++-- .../fixtures/size-report-npm-pack.json | 2 +- scripts/lib/bundled-license-notices.ts | 7 ++- scripts/size-report-package.d.mts | 1 + tsdown.config.ts | 9 ++-- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index ab988d6a25..be2e972a05 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -7,9 +7,13 @@ import { mkdtempForTest } from '@agent-device/test-kit'; import { bundledLicenseNotices } from '../lib/bundled-license-notices.ts'; async function fixture(files: Record): Promise { - const root = await mkdtempForTest('bundled-licenses-'); + const root = await fs.realpath(await mkdtempForTest('bundled-licenses-')); const contents = { - 'package.json': JSON.stringify({ name: 'fixture-project', private: true, type: 'module' }), + 'package.json': JSON.stringify({ name: 'fixture-project', type: 'module' }), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { target: 'es2022', module: 'nodenext', skipLibCheck: true }, + include: ['input.ts'], + }), ...files, }; for (const [relative, source] of Object.entries(contents)) { @@ -20,13 +24,15 @@ async function fixture(files: Record): Promise { return root; } -async function bundle(root: string): Promise { +async function bundle(root: string, dts = false): Promise { await build({ config: false, cwd: root, + tsconfig: path.join(root, 'tsconfig.json'), entry: ['input.ts'], outDir: 'dist', - dts: false, + outExtensions: () => ({ js: '.js', dts: '.d.ts' }), + dts, minify: true, deps: { onlyBundle: ['included', 'shared', 'distinct', 'unused', 'jpeg-js'] }, plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], @@ -46,6 +52,7 @@ test('bundled licenses retain full license and NOTICE text after minification', name: 'included', version: '1.2.3', license: 'MIT', + author: 'Fixture author ', main: 'index.js', }), 'node_modules/included/index.js': @@ -72,6 +79,7 @@ test('bundled licenses retain full license and NOTICE text after minification', assert.match(notices, /1\.2\.3/); assert.match(notices, /Copyright fixture author\nFull license terms retained\./); assert.match(notices, /Upstream attribution retained\./); + assert.match(notices, /Author: Fixture author /); assert.doesNotMatch(notices, /unused|external|fixture-project/); }); @@ -124,6 +132,38 @@ test('identical notices are shared without losing packages or distinct copyright }); assert.equal(references[0], references[1]); assert.notEqual(references[0], references[2]); + await fs.writeFile( + path.join(root, 'input.ts'), + [ + "export { value as third } from 'distinct';", + "export { value as second } from 'shared';", + "export { value as first } from 'included';", + ].join('\n'), + ); + await bundle(root); + assert.equal(await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), notices); +}); + +test('declaration generation preserves JavaScript dependency notices', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + types: 'index.d.ts', + }), + 'node_modules/included/index.js': "export const value = 'included';", + 'node_modules/included/index.d.ts': 'export declare const value: string;', + 'node_modules/included/LICENSE': 'Copyright included author\nIncluded license terms.', + }); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /included@1\.0\.0/); + await bundle(root, true); + assert.ok((await fs.stat(path.join(root, 'dist/input.d.ts'))).size > 0); + assert.equal(await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), notices); }); test('bundled jpeg-js retains source-file notices and the Apache license', async () => { diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index 25a3fcb700..2a229a1a22 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -86,6 +86,6 @@ "size": 426 } ], - "entryCount": 20, + "entryCount": 21, "size": 3376 } diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index 21c11020c9..b1541b307c 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -23,7 +23,10 @@ export function bundledLicenseNotices(root: string, outputDirectory: string) { return `[${id}]`; }; const packages: string[] = []; - for (const dependency of dependencies) { + const sortedDependencies = [...dependencies].sort((left, right) => + `${left.name}@${left.version}`.localeCompare(`${right.name}@${right.version}`), + ); + for (const dependency of sortedDependencies) { if (!dependency.license || !dependency.licenseText?.trim()) { throw new Error( `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, @@ -38,7 +41,7 @@ export function bundledLicenseNotices(root: string, outputDirectory: string) { ); } packages.push( - `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}`, + `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}${dependency.author ? `; Author: ${dependency.author.text()}` : ''}`, ); } return [ diff --git a/scripts/size-report-package.d.mts b/scripts/size-report-package.d.mts index 3ba2bff28a..ac2cdd0774 100644 --- a/scripts/size-report-package.d.mts +++ b/scripts/size-report-package.d.mts @@ -4,6 +4,7 @@ export const DOCUMENTED_PACK_STUBS: readonly string[]; export function readMacosHelperTestTargets(manifestSource: string): string[]; export type NpmPackEntry = Readonly<{ path: string; size: number }>; +export function assertBundledLicenseNotices(entries: readonly NpmPackEntry[]): void; export function assertPublishPackageContents( entries: readonly NpmPackEntry[], options?: Readonly<{ diff --git a/tsdown.config.ts b/tsdown.config.ts index 957be33693..1dc4c5982f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, type TsdownPluginOption } from 'tsdown'; import { bundledLicenseNotices } from './scripts/lib/bundled-license-notices.ts'; +const outDir = 'dist/src'; + const typeScriptPackageJsonUrl = import.meta.resolve('typescript/package.json'); const { default: getTypeScript7ExePath } = await import( new URL('lib/getExePath.js', typeScriptPackageJsonUrl).href @@ -157,7 +159,7 @@ export default defineConfig({ format: 'esm', platform: 'node', target: 'es2022', - outDir: 'dist/src', + outDir, tsconfig: 'tsconfig.lib.json', define: { __AGENT_DEVICE_VERSION__: JSON.stringify(packageJson.version), @@ -177,10 +179,7 @@ export default defineConfig({ minify: true, plugins: [ dropAmbientDeclarationImports, - bundledLicenseNotices( - import.meta.dirname, - fileURLToPath(new URL('./dist/src', import.meta.url)), - ), + bundledLicenseNotices(import.meta.dirname, fileURLToPath(new URL(outDir, import.meta.url))), ], dts: { tsgo: { From 4e93c03e2618c841e210151c8f0b179d0634ffe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 09:19:55 +0100 Subject: [PATCH 05/26] fix: stabilize license ordering and reject stale jpeg-js notices --- .../__tests__/bundled-license-notices.test.ts | 93 ++++++++++++++----- scripts/lib/bundled-license-notices.ts | 22 ++++- 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index be2e972a05..3805832fdf 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -34,7 +34,18 @@ async function bundle(root: string, dts = false): Promise { outExtensions: () => ({ js: '.js', dts: '.d.ts' }), dts, minify: true, - deps: { onlyBundle: ['included', 'shared', 'distinct', 'unused', 'jpeg-js'] }, + deps: { + onlyBundle: [ + 'included', + 'shared', + 'distinct', + 'unused', + 'jpeg-js', + 'included-extra', + 'included.extra', + 'included_extra', + ], + }, plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], logLevel: 'silent', }); @@ -144,6 +155,36 @@ test('identical notices are shared without losing packages or distinct copyright assert.equal(await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), notices); }); +test('notice references use code-unit ordering for package names', async () => { + const names = ['included_extra', 'included-extra', 'included.extra']; + const files: Record = { + 'input.ts': names + .map((name, index) => `export { value as value${index} } from '${name}';`) + .join('\n'), + }; + for (const name of names) { + files[`node_modules/${name}/package.json`] = JSON.stringify({ + name, + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }); + files[`node_modules/${name}/index.js`] = `export const value = '${name}';`; + files[`node_modules/${name}/LICENSE`] = 'Copyright ordering fixture\nShared license terms.'; + } + const root = await fixture(files); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.equal( + notices.split('\n\n')[0], + [ + 'included-extra@1.0.0 (MIT): [1]', + 'included.extra@1.0.0 (MIT): [1]', + 'included_extra@1.0.0 (MIT): [1]', + ].join('\n'), + ); +}); + test('declaration generation preserves JavaScript dependency notices', async () => { const root = await fixture({ 'input.ts': "export { value } from 'included';", @@ -166,24 +207,32 @@ test('declaration generation preserves JavaScript dependency notices', async () assert.equal(await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), notices); }); -test('bundled jpeg-js retains source-file notices and the Apache license', async () => { - const root = await fixture({ - 'input.ts': "export { value } from 'jpeg-js';", - 'node_modules/jpeg-js/package.json': JSON.stringify({ - name: 'jpeg-js', - version: '0.4.4', - license: 'BSD-3-Clause', - main: 'index.js', - }), - 'node_modules/jpeg-js/index.js': 'export const value = process.env.FIXTURE_NUMBER;', - 'node_modules/jpeg-js/LICENSE': 'Package-level BSD terms.', - 'LICENSES/jpeg-js.txt': 'Copyright 2011 notmasteryet\nCopyright 2008 Adobe.', - 'LICENSES/Apache-2.0.txt': 'Full Apache license text.', - }); - await bundle(root); - const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); - assert.match(notices, /Package-level BSD terms/); - assert.match(notices, /Copyright 2011 notmasteryet/); - assert.match(notices, /Copyright 2008 Adobe/); - assert.match(notices, /Full Apache license text/); -}); +test.each(['0.4.4', '0.4.5'])( + 'bundled jpeg-js@%s requires matching source-file notices', + async (version) => { + const root = await fixture({ + 'input.ts': "export { value } from 'jpeg-js';", + 'node_modules/jpeg-js/package.json': JSON.stringify({ + name: 'jpeg-js', + version, + license: 'BSD-3-Clause', + main: 'index.js', + }), + 'node_modules/jpeg-js/index.js': 'export const value = process.env.FIXTURE_NUMBER;', + 'node_modules/jpeg-js/LICENSE': 'Package-level BSD terms.', + 'LICENSES/jpeg-js.txt': + 'jpeg-js 0.4.4 source-file notices\n\nCopyright 2011 notmasteryet\nCopyright 2008 Adobe.', + 'LICENSES/Apache-2.0.txt': 'Full Apache license text.', + }); + if (version === '0.4.5') { + await assert.rejects(bundle(root), /LICENSES\/jpeg-js\.txt.*jpeg-js@0\.4\.5/); + return; + } + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /Package-level BSD terms/); + assert.match(notices, /Copyright 2011 notmasteryet/); + assert.match(notices, /Copyright 2008 Adobe/); + assert.match(notices, /Full Apache license text/); + }, +); diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index b1541b307c..f80d17a7fb 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -23,9 +23,11 @@ export function bundledLicenseNotices(root: string, outputDirectory: string) { return `[${id}]`; }; const packages: string[] = []; - const sortedDependencies = [...dependencies].sort((left, right) => - `${left.name}@${left.version}`.localeCompare(`${right.name}@${right.version}`), - ); + const sortedDependencies = [...dependencies].sort((left, right) => { + const a = `${left.name}@${left.version}`; + const b = `${right.name}@${right.version}`; + return a < b ? -1 : a > b ? 1 : 0; + }); for (const dependency of sortedDependencies) { if (!dependency.license || !dependency.licenseText?.trim()) { throw new Error( @@ -35,8 +37,20 @@ export function bundledLicenseNotices(root: string, outputDirectory: string) { const references = [reference(dependency.licenseText)]; if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); if (dependency.name === 'jpeg-js') { + const sourceNotices = fs.readFileSync( + path.join(root, 'LICENSES/jpeg-js.txt'), + 'utf8', + ); + const noticeVersion = sourceNotices + .split(/\r?\n/, 1)[0] + ?.match(/^jpeg-js (\S+) source-file notices$/)?.[1]; + if (noticeVersion !== dependency.version) { + throw new Error( + `Refresh LICENSES/jpeg-js.txt for bundled jpeg-js@${dependency.version}; its first line must name that version.`, + ); + } references.push( - reference(fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8')), + reference(sourceNotices), reference(fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')), ); } From 3f422538daeaddd319eecd12ce59990f1be943e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 10:18:45 +0100 Subject: [PATCH 06/26] test: isolate license fixture builds from Vitest workers --- .../__tests__/bundled-license-notices.test.ts | 37 ++++++------------- .../fixtures/bundled-license-build.mts | 31 ++++++++++++++++ 2 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 scripts/__tests__/fixtures/bundled-license-build.mts diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 3805832fdf..c2aea2a696 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -1,10 +1,13 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { build } from 'tsdown'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; import { test } from 'vitest'; import { mkdtempForTest } from '@agent-device/test-kit'; -import { bundledLicenseNotices } from '../lib/bundled-license-notices.ts'; + +const execFileAsync = promisify(execFile); async function fixture(files: Record): Promise { const root = await fs.realpath(await mkdtempForTest('bundled-licenses-')); @@ -25,30 +28,12 @@ async function fixture(files: Record): Promise { } async function bundle(root: string, dts = false): Promise { - await build({ - config: false, - cwd: root, - tsconfig: path.join(root, 'tsconfig.json'), - entry: ['input.ts'], - outDir: 'dist', - outExtensions: () => ({ js: '.js', dts: '.d.ts' }), - dts, - minify: true, - deps: { - onlyBundle: [ - 'included', - 'shared', - 'distinct', - 'unused', - 'jpeg-js', - 'included-extra', - 'included.extra', - 'included_extra', - ], - }, - plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], - logLevel: 'silent', - }); + await execFileAsync(process.execPath, [ + '--experimental-strip-types', + fileURLToPath(new URL('./fixtures/bundled-license-build.mts', import.meta.url)), + root, + String(dts), + ]); } test('bundled licenses retain full license and NOTICE text after minification', async () => { diff --git a/scripts/__tests__/fixtures/bundled-license-build.mts b/scripts/__tests__/fixtures/bundled-license-build.mts new file mode 100644 index 0000000000..552358fff7 --- /dev/null +++ b/scripts/__tests__/fixtures/bundled-license-build.mts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { build } from 'tsdown'; +import { bundledLicenseNotices } from '../../lib/bundled-license-notices.ts'; + +const root = process.argv[2]; +if (!root) throw new Error('Missing license fixture root.'); + +await build({ + config: false, + cwd: root, + tsconfig: path.join(root, 'tsconfig.json'), + entry: ['input.ts'], + outDir: 'dist', + outExtensions: () => ({ js: '.js', dts: '.d.ts' }), + dts: process.argv[3] === 'true', + minify: true, + deps: { + onlyBundle: [ + 'included', + 'shared', + 'distinct', + 'unused', + 'jpeg-js', + 'included-extra', + 'included.extra', + 'included_extra', + ], + }, + plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], + logLevel: 'silent', +}); From 54d55880b42a48e3c8a3af4d046dcca8ca70b896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 10:18:46 +0100 Subject: [PATCH 07/26] chore(gates): flush diagnostics before forced exit --- scripts/check-affected/command.ts | 8 ------- scripts/lib/cli-entrypoint.test.ts | 35 ++++++++++++++++++++++++++++++ scripts/lib/cli-entrypoint.ts | 22 +++++++++++++------ vitest.config.ts | 1 + 4 files changed, 51 insertions(+), 15 deletions(-) create mode 100644 scripts/lib/cli-entrypoint.test.ts diff --git a/scripts/check-affected/command.ts b/scripts/check-affected/command.ts index 46ab92f5ad..07540b2298 100644 --- a/scripts/check-affected/command.ts +++ b/scripts/check-affected/command.ts @@ -18,13 +18,5 @@ runEntrypoint('validation command', async () => { process.stderr.write(chunk); }, }); - await Promise.all( - [process.stdout, process.stderr].map( - (stream) => - new Promise((resolve, reject) => - stream.write('', (error) => (error ? reject(error) : resolve())), - ), - ), - ); return result.exitCode; }); diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts new file mode 100644 index 0000000000..f74cafb14a --- /dev/null +++ b/scripts/lib/cli-entrypoint.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { test } from 'vitest'; + +test.each([false, true])('script exit flushes piped diagnostics (throws=%s)', async (throws) => { + const bytes = 4 * 1024 * 1024; + const stdoutTail = 'stdout diagnostic tail\n'; + const stderrTail = 'stderr diagnostic tail\n'; + const failure = 'fixture: expected failure\n'; + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + 'runEntrypoint("fixture", async () => {', + `process.stdout.write('o'.repeat(${bytes}) + ${JSON.stringify(stdoutTail)});`, + `process.stderr.write('e'.repeat(${bytes}) + ${JSON.stringify(stderrTail)});`, + throws ? 'throw new Error("expected failure");' : 'return 7;', + '});', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, throws ? 1 : 7); + assert.equal(stdout.length, bytes + stdoutTail.length); + assert.ok(stdout.endsWith(stdoutTail)); + assert.equal(stderr.length, bytes + stderrTail.length + (throws ? failure.length : 0)); + assert.ok(stderr.endsWith(throws ? failure : stderrTail)); +}); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index f75fcd7849..53e4dd4343 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -2,11 +2,19 @@ // thrown error into one prefixed line rather than a stack trace. export function runEntrypoint(prefix: string, main: () => Promise): void { - main().then( - (code) => process.exit(code), - (error: unknown) => { - process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`); - process.exit(1); - }, - ); + const exitAfterOutput = async (code: number): Promise => { + await Promise.all( + [process.stdout, process.stderr].map( + (stream) => + new Promise((resolve, reject) => + stream.write('', (error) => (error ? reject(error) : resolve())), + ), + ), + ); + process.exit(code); + }; + main().then(exitAfterOutput, (error: unknown) => { + process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`); + return exitAfterOutput(1); + }); } diff --git a/vitest.config.ts b/vitest.config.ts index 1e831a7926..06d7375b6f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -185,6 +185,7 @@ export default defineConfig({ // package path remains accounted for. 'scripts/__tests__/size-report-package.test.ts', 'scripts/__tests__/bundled-license-notices.test.ts', + 'scripts/lib/cli-entrypoint.test.ts', // Publish preparation spawns only fixture-owned scripts and proves both Android // helper families are rebuilt through the shared release/size-report owner. 'scripts/__tests__/prepare-publish-assets.test.ts', From e7ea646302774f00944aa4ba735f98047791afbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 10:56:11 +0100 Subject: [PATCH 08/26] fix(gates): preserve exit codes after output pipes close --- scripts/check-package.ts | 13 +++++++------ scripts/lib/cli-entrypoint.test.ts | 29 +++++++++++++++++++++++++++++ scripts/lib/cli-entrypoint.ts | 11 ++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/scripts/check-package.ts b/scripts/check-package.ts index c511a077a0..f75ee19dc6 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -31,7 +31,7 @@ import { type PackedManifest as PackedDependencies, } from './lib/shipped-imports.ts'; import { assertInstalledSnapshotBridge } from './size-report-install.mjs'; -import { assertBundledLicenseNotices } from './size-report-package.mjs'; +import { assertBundledLicenseNotices, type NpmPackEntry } from './size-report-package.mjs'; import { testOnlyContentFailures, testOnlyPathFailures } from './lib/shipped-test-code.ts'; import { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; @@ -92,7 +92,7 @@ function step(message: string): void { * npm reports is the package's own declaration of what it ships, which is what the test-code guard * reads — a clean-install tree would also hold the installed dependencies' files. */ -function packTarball(): { tarball: string; packedPaths: string[] } { +function packTarball(): { tarball: string; packedEntries: readonly NpmPackEntry[] } { if (!fs.existsSync(path.join(repoRoot, 'dist', 'src'))) { throw new Error('No dist/src build found. Run `pnpm build` first.'); } @@ -103,11 +103,10 @@ function packTarball(): { tarball: string; packedPaths: string[] } { ['pack', '--ignore-scripts', '--json', '--pack-destination', packDestination], repoRoot, ), - ) as [{ filename: string; files: { path: string; size: number }[] }]; - assertBundledLicenseNotices(packed[0].files); + ) as [{ filename: string; files: NpmPackEntry[] }]; return { tarball: path.join(packDestination, packed[0].filename), - packedPaths: packed[0].files.map((file) => file.path), + packedEntries: packed[0].files, }; } @@ -239,7 +238,9 @@ function smokeTestBin(installedRoot: string, manifest: PackedManifest): void { step(`Packing and verifying agent-device in ${workDir}`); try { - const { tarball, packedPaths } = packTarball(); + const { tarball, packedEntries } = packTarball(); + assertBundledLicenseNotices(packedEntries); + const packedPaths = packedEntries.map((file) => file.path); lintTarball(tarball); const installedRoot = installIntoCleanConsumer(tarball); assertInstalledSnapshotBridge(installedRoot); diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index f74cafb14a..8bf02afb12 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -33,3 +33,32 @@ test.each([false, true])('script exit flushes piped diagnostics (throws=%s)', as assert.equal(stderr.length, bytes + stderrTail.length + (throws ? failure.length : 0)); assert.ok(stderr.endsWith(throws ? failure : stderrTail)); }); + +test.each([0, 7])('closed output pipe preserves command exit code %s', async (exitCode) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + 'process.once("message", () => {', + ' process.disconnect();', + ` runEntrypoint("fixture", async () => ${exitCode});`, + '});', + 'process.send("ready");', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const ready = once(child, 'message'); + const outputClosed = once(child.stdout, 'close'); + child.stdout.destroy(); + await outputClosed; + await ready; + const exited = once(child, 'close'); + child.send('run'); + const [code, signal] = await exited; + assert.equal(signal, null); + assert.equal(code, exitCode); + assert.equal(stderr, ''); +}); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index 53e4dd4343..b4cd7f3a2b 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -6,9 +6,14 @@ export function runEntrypoint(prefix: string, main: () => Promise): void await Promise.all( [process.stdout, process.stderr].map( (stream) => - new Promise((resolve, reject) => - stream.write('', (error) => (error ? reject(error) : resolve())), - ), + new Promise((resolve) => { + if (stream.destroyed || !stream.writable) { + resolve(); + return; + } + stream.once('error', () => resolve()); + stream.write('', () => resolve()); + }), ), ); process.exit(code); From 02ff19e52411db4cf7ba30ad55eb2fab73333b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 12:00:10 +0100 Subject: [PATCH 09/26] fix(build): respect notice output overrides and script failures --- .../__tests__/bundled-license-notices.test.ts | 23 +++- .../fixtures/bundled-license-build.mts | 4 +- scripts/__tests__/size-report-package.test.ts | 25 +++- scripts/check-package.ts | 14 +- scripts/lib/bundled-license-notices.ts | 126 ++++++++++-------- scripts/lib/cli-entrypoint.test.ts | 24 ++++ scripts/lib/cli-entrypoint.ts | 29 +++- scripts/size-report-package.d.mts | 5 + scripts/size-report-package.mjs | 10 +- tsdown.config.ts | 9 +- 10 files changed, 185 insertions(+), 84 deletions(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index c2aea2a696..d1f40f9d35 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -27,12 +27,13 @@ async function fixture(files: Record): Promise { return root; } -async function bundle(root: string, dts = false): Promise { +async function bundle(root: string, dts = false, outputDirectory = 'dist'): Promise { await execFileAsync(process.execPath, [ '--experimental-strip-types', fileURLToPath(new URL('./fixtures/bundled-license-build.mts', import.meta.url)), root, String(dts), + outputDirectory, ]); } @@ -221,3 +222,23 @@ test.each(['0.4.4', '0.4.5'])( assert.match(notices, /Full Apache license text/); }, ); + +test('output overrides write notices beside the bundle without creating the default dist', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }), + 'node_modules/included/index.js': 'export const value = 42;', + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms.', + }); + await bundle(root, false, 'redirected'); + assert.match( + await fs.readFile(path.join(root, 'redirected/THIRD_PARTY_NOTICES.txt'), 'utf8'), + /included@1\.0\.0/, + ); + await assert.rejects(fs.stat(path.join(root, 'dist')), { code: 'ENOENT' }); +}); diff --git a/scripts/__tests__/fixtures/bundled-license-build.mts b/scripts/__tests__/fixtures/bundled-license-build.mts index 552358fff7..09938d8aaa 100644 --- a/scripts/__tests__/fixtures/bundled-license-build.mts +++ b/scripts/__tests__/fixtures/bundled-license-build.mts @@ -10,7 +10,7 @@ await build({ cwd: root, tsconfig: path.join(root, 'tsconfig.json'), entry: ['input.ts'], - outDir: 'dist', + outDir: process.argv[4] ?? 'dist', outExtensions: () => ({ js: '.js', dts: '.d.ts' }), dts: process.argv[3] === 'true', minify: true, @@ -26,6 +26,6 @@ await build({ 'included_extra', ], }, - plugins: [bundledLicenseNotices(root, path.join(root, 'dist'))], + plugins: [bundledLicenseNotices(root)], logLevel: 'silent', }); diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index f1750672ea..141705c556 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { test } from 'vitest'; import { formatMarkdown } from '../size-report.mjs'; -import { assertPublishPackageContents } from '../size-report-package.mjs'; +import { assertPublishPackageContents, parseNpmPackOutput } from '../size-report-package.mjs'; const fixturePack = JSON.parse( await readFile(join(import.meta.dirname, 'fixtures', 'size-report-npm-pack.json'), 'utf8'), @@ -192,3 +192,26 @@ test('base measurement uses the measured revision package asset policy', async ( /git checkout --detach[\s\S]*cp scripts\/size-report-package\.mjs \/tmp\/agent-device-size-report\/[\s\S]*node \/tmp\/agent-device-size-report\/size-report\.mjs/, ); }); + +test.each(['1', -1, 0.5, null])('npm pack rejects malformed file sizes (%s)', (size) => { + assert.throws( + () => + parseNpmPackOutput( + JSON.stringify([ + { + filename: 'fixture.tgz', + size: 1, + unpackedSize: 1, + files: [{ path: 'dist/src/THIRD_PARTY_NOTICES.txt', size }], + }, + ]), + ), + /invalid file entry/, + ); +}); + +test('npm pack parser shares validated file entries across both output shapes', () => { + const pack = { filename: 'fixture.tgz', ...fixturePack }; + assert.deepEqual(parseNpmPackOutput(JSON.stringify([pack])).files, fixturePack.files); + assert.deepEqual(parseNpmPackOutput(JSON.stringify(pack)).files, fixturePack.files); +}); diff --git a/scripts/check-package.ts b/scripts/check-package.ts index f75ee19dc6..81d28e4675 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -31,7 +31,11 @@ import { type PackedManifest as PackedDependencies, } from './lib/shipped-imports.ts'; import { assertInstalledSnapshotBridge } from './size-report-install.mjs'; -import { assertBundledLicenseNotices, type NpmPackEntry } from './size-report-package.mjs'; +import { + assertBundledLicenseNotices, + parseNpmPackOutput, + type NpmPackEntry, +} from './size-report-package.mjs'; import { testOnlyContentFailures, testOnlyPathFailures } from './lib/shipped-test-code.ts'; import { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; @@ -97,16 +101,16 @@ function packTarball(): { tarball: string; packedEntries: readonly NpmPackEntry[ throw new Error('No dist/src build found. Run `pnpm build` first.'); } fs.mkdirSync(packDestination, { recursive: true }); - const packed = JSON.parse( + const packed = parseNpmPackOutput( run( 'npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', packDestination], repoRoot, ), - ) as [{ filename: string; files: NpmPackEntry[] }]; + ); return { - tarball: path.join(packDestination, packed[0].filename), - packedEntries: packed[0].files, + tarball: path.join(packDestination, packed.filename), + packedEntries: packed.files, }; } diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index f80d17a7fb..9054cc2f2d 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -1,69 +1,79 @@ import fs from 'node:fs'; import path from 'node:path'; -import license from 'rollup-plugin-license'; +import license, { type Dependency } from 'rollup-plugin-license'; -export function bundledLicenseNotices(root: string, outputDirectory: string) { - return license({ +/** Reads extra notices from root's LICENSES directory and emits them beside the bundle. */ +export function bundledLicenseNotices(root: string): ReturnType { + let source: string | undefined; + const collector = license({ cwd: root, sourcemap: false, thirdParty: { multipleVersions: true, - output: { - file: path.join(outputDirectory, 'THIRD_PARTY_NOTICES.txt'), - template(dependencies) { - if (dependencies.length === 0) return 'No third-party dependencies are bundled.\n'; - const texts = new Map(); - const reference = (text: string): string => { - const normalized = text.replaceAll('\r\n', '\n').trim(); - let id = texts.get(normalized); - if (id === undefined) { - id = texts.size + 1; - texts.set(normalized, id); - } - return `[${id}]`; - }; - const packages: string[] = []; - const sortedDependencies = [...dependencies].sort((left, right) => { - const a = `${left.name}@${left.version}`; - const b = `${right.name}@${right.version}`; - return a < b ? -1 : a > b ? 1 : 0; - }); - for (const dependency of sortedDependencies) { - if (!dependency.license || !dependency.licenseText?.trim()) { - throw new Error( - `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, - ); - } - const references = [reference(dependency.licenseText)]; - if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); - if (dependency.name === 'jpeg-js') { - const sourceNotices = fs.readFileSync( - path.join(root, 'LICENSES/jpeg-js.txt'), - 'utf8', - ); - const noticeVersion = sourceNotices - .split(/\r?\n/, 1)[0] - ?.match(/^jpeg-js (\S+) source-file notices$/)?.[1]; - if (noticeVersion !== dependency.version) { - throw new Error( - `Refresh LICENSES/jpeg-js.txt for bundled jpeg-js@${dependency.version}; its first line must name that version.`, - ); - } - references.push( - reference(sourceNotices), - reference(fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')), - ); - } - packages.push( - `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}${dependency.author ? `; Author: ${dependency.author.text()}` : ''}`, - ); - } - return [ - packages.join('\n'), - ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`), - ].join('\n\n'); - }, + output(dependencies) { + source = renderNotices(root, dependencies); }, }, }); + return { + ...collector, + async generateBundle(options, bundle, isWrite) { + source = undefined; + const hook = collector.generateBundle; + const generate = typeof hook === 'function' ? hook : hook?.handler; + await generate?.call(this, options, bundle, isWrite); + if (source === undefined) this.error('License collector did not produce bundled notices.'); + this.emitFile({ type: 'asset', fileName: 'THIRD_PARTY_NOTICES.txt', source }); + }, + }; +} + +function renderNotices(root: string, dependencies: readonly Dependency[]): string { + if (dependencies.length === 0) return 'No third-party dependencies are bundled.\n'; + const texts = new Map(); + const reference = (text: string): string => { + const normalized = text.replaceAll('\r\n', '\n').trim(); + let id = texts.get(normalized); + if (id === undefined) { + id = texts.size + 1; + texts.set(normalized, id); + } + return `[${id}]`; + }; + const packages: string[] = []; + const sortedDependencies = [...dependencies].sort((left, right) => { + const a = `${left.name}@${left.version}`; + const b = `${right.name}@${right.version}`; + return a < b ? -1 : a > b ? 1 : 0; + }); + for (const dependency of sortedDependencies) { + if (!dependency.license || !dependency.licenseText?.trim()) { + throw new Error( + `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, + ); + } + const references = [reference(dependency.licenseText)]; + if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); + if (dependency.name === 'jpeg-js') { + const sourceNotices = fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8'); + const noticeVersion = sourceNotices + .split(/\r?\n/, 1)[0] + ?.match(/^jpeg-js (\S+) source-file notices$/)?.[1]; + if (noticeVersion !== dependency.version) { + throw new Error( + `Refresh LICENSES/jpeg-js.txt for bundled jpeg-js@${dependency.version}; its first line must name that version.`, + ); + } + references.push( + reference(sourceNotices), + reference(fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')), + ); + } + packages.push( + `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}${dependency.author ? `; Author: ${dependency.author.text()}` : ''}`, + ); + } + return [packages.join('\n'), ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`)].join( + '\n\n', + ); } diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index 8bf02afb12..6aae0bb609 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -62,3 +62,27 @@ test.each([0, 7])('closed output pipe preserves command exit code %s', async (ex assert.equal(code, exitCode); assert.equal(stderr, ''); }); + +test.each([0, 7])( + 'unexpected output error fails a successful command (code=%s)', + async (exitCode) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + "const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: 'EIO' })); } });", + "Object.defineProperty(process, 'stdout', { value: output });", + `runEntrypoint('fixture', async () => ${exitCode});`, + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || 1); + assert.equal(stderr, ''); + }, +); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index b4cd7f3a2b..87e99914e5 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -1,22 +1,37 @@ +function isClosedPipe(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') + ); +} + // Shared bootstrap for script entrypoints: run `main`, exit on its code, and turn a // thrown error into one prefixed line rather than a stack trace. - export function runEntrypoint(prefix: string, main: () => Promise): void { const exitAfterOutput = async (code: number): Promise => { - await Promise.all( + const failures = await Promise.all( [process.stdout, process.stderr].map( (stream) => - new Promise((resolve) => { + new Promise((resolve) => { + const finish = (error?: unknown): void => { + resolve(error != null && !isClosedPipe(error)); + }; if (stream.destroyed || !stream.writable) { - resolve(); + finish(stream.errored); return; } - stream.once('error', () => resolve()); - stream.write('', () => resolve()); + stream.once('error', finish); + try { + stream.write('', finish); + } catch (error) { + finish(error); + } }), ), ); - process.exit(code); + process.exit(code || (failures.some(Boolean) ? 1 : 0)); }; main().then(exitAfterOutput, (error: unknown) => { process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`); diff --git a/scripts/size-report-package.d.mts b/scripts/size-report-package.d.mts index ac2cdd0774..0a9eca7375 100644 --- a/scripts/size-report-package.d.mts +++ b/scripts/size-report-package.d.mts @@ -13,3 +13,8 @@ export function assertPublishPackageContents( macosHelperManifestSource?: string; }>, ): void; + +export function parseNpmPackOutput(stdout: string): Readonly<{ + filename: string; + files: readonly NpmPackEntry[]; +}>; diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 84ee1876a9..a0dad5bcd2 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -50,7 +50,7 @@ export function collectNpmPack(root) { { cwd: root, encoding: 'utf8' }, ); const pack = parseNpmPackOutput(stdout); - const entries = normalizeNpmPackEntries(pack); + const entries = pack.files; assertPublishPackageContents(entries, { repoRoot: root, requireSnapshotBridge: fs.existsSync(path.join(root, 'apple', 'snapshot-bridge')), @@ -166,9 +166,13 @@ export function assertPublishPackageContents(entries, options = {}) { } } -function parseNpmPackOutput(stdout) { +export function parseNpmPackOutput(stdout) { const parsed = JSON.parse(stdout); - return Array.isArray(parsed) ? parsed[0] : parsed; + const pack = Array.isArray(parsed) ? parsed[0] : parsed; + if (!pack || typeof pack.filename !== 'string' || pack.filename.length === 0) { + throw new Error('npm pack did not return a filename'); + } + return { ...pack, files: normalizeNpmPackEntries(pack) }; } function normalizeNpmPackEntries(pack) { diff --git a/tsdown.config.ts b/tsdown.config.ts index 1dc4c5982f..4310dfdf72 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,8 +4,6 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, type TsdownPluginOption } from 'tsdown'; import { bundledLicenseNotices } from './scripts/lib/bundled-license-notices.ts'; -const outDir = 'dist/src'; - const typeScriptPackageJsonUrl = import.meta.resolve('typescript/package.json'); const { default: getTypeScript7ExePath } = await import( new URL('lib/getExePath.js', typeScriptPackageJsonUrl).href @@ -159,7 +157,7 @@ export default defineConfig({ format: 'esm', platform: 'node', target: 'es2022', - outDir, + outDir: 'dist/src', tsconfig: 'tsconfig.lib.json', define: { __AGENT_DEVICE_VERSION__: JSON.stringify(packageJson.version), @@ -177,10 +175,7 @@ export default defineConfig({ }, outExtensions: () => ({ js: '.js', dts: '.d.ts' }), minify: true, - plugins: [ - dropAmbientDeclarationImports, - bundledLicenseNotices(import.meta.dirname, fileURLToPath(new URL(outDir, import.meta.url))), - ], + plugins: [dropAmbientDeclarationImports, bundledLicenseNotices(import.meta.dirname)], dts: { tsgo: { path: getTypeScript7ExePath(), From 4cad269036c3956135a76cbecd79f557b6afa2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 16:03:11 +0100 Subject: [PATCH 10/26] test(build): pin curated notice bytes and clarify collector contract --- scripts/__tests__/bundled-license-notices.test.ts | 15 ++++++++++----- scripts/lib/bundled-license-notices.ts | 6 +++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index d1f40f9d35..9a5f47dee9 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -196,6 +196,10 @@ test('declaration generation preserves JavaScript dependency notices', async () test.each(['0.4.4', '0.4.5'])( 'bundled jpeg-js@%s requires matching source-file notices', async (version) => { + const [sourceNotices, apacheLicense] = await Promise.all([ + fs.readFile(new URL('../../LICENSES/jpeg-js.txt', import.meta.url), 'utf8'), + fs.readFile(new URL('../../LICENSES/Apache-2.0.txt', import.meta.url), 'utf8'), + ]); const root = await fixture({ 'input.ts': "export { value } from 'jpeg-js';", 'node_modules/jpeg-js/package.json': JSON.stringify({ @@ -206,9 +210,8 @@ test.each(['0.4.4', '0.4.5'])( }), 'node_modules/jpeg-js/index.js': 'export const value = process.env.FIXTURE_NUMBER;', 'node_modules/jpeg-js/LICENSE': 'Package-level BSD terms.', - 'LICENSES/jpeg-js.txt': - 'jpeg-js 0.4.4 source-file notices\n\nCopyright 2011 notmasteryet\nCopyright 2008 Adobe.', - 'LICENSES/Apache-2.0.txt': 'Full Apache license text.', + 'LICENSES/jpeg-js.txt': sourceNotices, + 'LICENSES/Apache-2.0.txt': apacheLicense, }); if (version === '0.4.5') { await assert.rejects(bundle(root), /LICENSES\/jpeg-js\.txt.*jpeg-js@0\.4\.5/); @@ -218,8 +221,10 @@ test.each(['0.4.4', '0.4.5'])( const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); assert.match(notices, /Package-level BSD terms/); assert.match(notices, /Copyright 2011 notmasteryet/); - assert.match(notices, /Copyright 2008 Adobe/); - assert.match(notices, /Full Apache license text/); + assert.match(notices, /Adobe Systems Incorporated/); + assert.match(notices, /JPEG encoder ported to JavaScript and optimized by Andreas Ritter/); + assert.ok(notices.includes(sourceNotices.replaceAll('\r\n', '\n').trim())); + assert.ok(notices.includes(apacheLicense.replaceAll('\r\n', '\n').trim())); }, ); diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index 9054cc2f2d..415dd89e32 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -15,6 +15,7 @@ export function bundledLicenseNotices(root: string): ReturnType }, }, }); + // rollup-plugin-license 3.7.1 exposes synchronous dependency output without a bundler context. return { ...collector, async generateBundle(options, bundle, isWrite) { @@ -22,7 +23,10 @@ export function bundledLicenseNotices(root: string): ReturnType const hook = collector.generateBundle; const generate = typeof hook === 'function' ? hook : hook?.handler; await generate?.call(this, options, bundle, isWrite); - if (source === undefined) this.error('License collector did not produce bundled notices.'); + if (source === undefined) + this.error( + 'rollup-plugin-license did not report dependencies from generateBundle; expected its synchronous thirdParty.output callback.', + ); this.emitFile({ type: 'asset', fileName: 'THIRD_PARTY_NOTICES.txt', source }); }, }; From f75cc698de4ab20f6ab969c3b60f8d5ab2294b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 16:33:37 +0100 Subject: [PATCH 11/26] fix(scripts): bound output draining and report stream failures --- scripts/lib/cli-entrypoint.test.ts | 60 ++++++++++++++++++++--- scripts/lib/cli-entrypoint.ts | 76 ++++++++++++++++++------------ 2 files changed, 101 insertions(+), 35 deletions(-) diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index 6aae0bb609..aa27118a7c 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -39,7 +39,7 @@ test.each([0, 7])('closed output pipe preserves command exit code %s', async (ex `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, 'process.once("message", () => {', ' process.disconnect();', - ` runEntrypoint("fixture", async () => ${exitCode});`, + ` runEntrypoint("fixture", async () => { process.stdout.write("closed-consumer output"); return ${exitCode}; });`, '});', 'process.send("ready");', ].join('\n'); @@ -63,13 +63,17 @@ test.each([0, 7])('closed output pipe preserves command exit code %s', async (ex assert.equal(stderr, ''); }); -test.each([0, 7])( - 'unexpected output error fails a successful command (code=%s)', - async (exitCode) => { +test.each( + ['EIO', 'ENOSPC', 'ERR_STREAM_DESTROYED'].flatMap((errorCode) => + [0, 7].map((exitCode) => ({ errorCode, exitCode })), + ), +)( + 'unexpected output error is reported without losing command failure ($errorCode, code=$exitCode)', + async ({ errorCode, exitCode }) => { const source = [ `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, "import { Writable } from 'node:stream';", - "const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: 'EIO' })); } });", + `const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: ${JSON.stringify(errorCode)} })); } });`, "Object.defineProperty(process, 'stdout', { value: output });", `runEntrypoint('fixture', async () => ${exitCode});`, ].join('\n'); @@ -83,6 +87,50 @@ test.each([0, 7])( const [code, signal] = await once(child, 'close'); assert.equal(signal, null); assert.equal(code, exitCode || 1); - assert.equal(stderr, ''); + assert.equal(stderr, 'fixture: unable to flush stdout: fixture write failure\n'); + }, +); + +// A real pipe must stay unread: synthetic write callbacks cannot prove process termination under backpressure. +test.each([0, 7])( + 'a stalled output reader cannot keep a finished command alive (code=%s)', + async (exitCode) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + 'setTimeout(() => process.exit(99), 3000);', + 'runEntrypoint("fixture", async () => {', + " process.stdout.write('o'.repeat(4 * 1024 * 1024));", + ' process.send({ queuedBytes: process.stdout.writableLength });', + ` return ${exitCode};`, + '});', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const stderrEnded = once(child.stderr, 'end'); + const exited = once(child, 'exit'); + try { + const [message] = await once(child, 'message'); + assert.ok( + message.queuedBytes > 0, + 'the real producer must have queued output behind the unread pipe', + ); + const [code, signal] = await exited; + await stderrEnded; + assert.equal(signal, null); + assert.equal( + code, + exitCode || 1, + 'the flush deadline must win before the independent leak guard', + ); + assert.equal(stderr, 'fixture: unable to flush stdout: output drain timed out\n'); + } finally { + child.stdout.destroy(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } }, ); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index 87e99914e5..8376186545 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -1,37 +1,55 @@ +const OUTPUT_DRAIN_TIMEOUT_MS = 2000; + function isClosedPipe(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') - ); + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPIPE'; +} + +function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise { + return new Promise((resolve) => { + const finish = (error?: unknown): void => { + clearTimeout(timer); + const cause = stream.errored ?? error; + resolve(cause != null && !isClosedPipe(cause) ? cause : undefined); + }; + const timer = setTimeout( + () => finish(new Error('output drain timed out')), + Math.max(0, deadline - Date.now()), + ); + if (stream.destroyed || !stream.writable) { + finish(stream.errored); + return; + } + stream.once('error', finish); + try { + stream.write('', finish); + } catch (error) { + finish(error); + } + }); } -// Shared bootstrap for script entrypoints: run `main`, exit on its code, and turn a -// thrown error into one prefixed line rather than a stack trace. +// Shared bootstrap for script entrypoints. Output failure fails a successful command; a closed +// pipe preserves its code. A stopped reader cannot hold exit past the shared drain deadline. export function runEntrypoint(prefix: string, main: () => Promise): void { + // Node retains the cause on stream.errored; early writes must survive until the drain classifies it. + for (const stream of [process.stdout, process.stderr]) stream.on('error', () => undefined); const exitAfterOutput = async (code: number): Promise => { - const failures = await Promise.all( - [process.stdout, process.stderr].map( - (stream) => - new Promise((resolve) => { - const finish = (error?: unknown): void => { - resolve(error != null && !isClosedPipe(error)); - }; - if (stream.destroyed || !stream.writable) { - finish(stream.errored); - return; - } - stream.once('error', finish); - try { - stream.write('', finish); - } catch (error) { - finish(error); - } - }), - ), - ); - process.exit(code || (failures.some(Boolean) ? 1 : 0)); + const deadline = Date.now() + OUTPUT_DRAIN_TIMEOUT_MS; + const [stdoutError, initialStderrError] = await Promise.all([ + drainOutput(process.stdout, deadline), + drainOutput(process.stderr, deadline), + ]); + let stderrError = initialStderrError; + if (stdoutError != null && stderrError == null) { + const message = stdoutError instanceof Error ? stdoutError.message : String(stdoutError); + try { + process.stderr.write(`${prefix}: unable to flush stdout: ${message}\n`); + stderrError = await drainOutput(process.stderr, deadline); + } catch (error) { + stderrError = error; + } + } + process.exit(code || (stdoutError != null || stderrError != null ? 1 : 0)); }; main().then(exitAfterOutput, (error: unknown) => { process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`); From 4348d35b503a7518546f0f03acc319e1b836e075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 16:55:47 +0100 Subject: [PATCH 12/26] fix(scripts): reserve a bounded window for flush diagnostics --- scripts/lib/cli-entrypoint.test.ts | 19 ++++++++++++++++--- scripts/lib/cli-entrypoint.ts | 5 +++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index aa27118a7c..b79fa4e4f0 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -92,11 +92,24 @@ test.each( ); // A real pipe must stay unread: synthetic write callbacks cannot prove process termination under backpressure. -test.each([0, 7])( - 'a stalled output reader cannot keep a finished command alive (code=%s)', - async (exitCode) => { +test.each([ + { exitCode: 0, diagnosticDelayMs: 0 }, + { exitCode: 7, diagnosticDelayMs: 0 }, + { exitCode: 0, diagnosticDelayMs: 50 }, +])( + 'a stalled output reader cannot keep a finished command alive (code=$exitCode, diagnostic delay=$diagnosticDelayMs)', + async ({ exitCode, diagnosticDelayMs }) => { const source = [ `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + `if (${diagnosticDelayMs} > 0) {`, + ' const stderr = process.stderr;', + ' const delayed = new Writable({ write(chunk, encoding, done) {', + ` if (chunk.length > 0) setTimeout(() => stderr.write(chunk, encoding, done), ${diagnosticDelayMs});`, + ' else stderr.write(chunk, encoding, done);', + ' } });', + " Object.defineProperty(process, 'stderr', { value: delayed });", + '}', 'setTimeout(() => process.exit(99), 3000);', 'runEntrypoint("fixture", async () => {', " process.stdout.write('o'.repeat(4 * 1024 * 1024));", diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index 8376186545..c52bfedec8 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -29,7 +29,8 @@ function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise Promise): void { // Node retains the cause on stream.errored; early writes must survive until the drain classifies it. for (const stream of [process.stdout, process.stderr]) stream.on('error', () => undefined); @@ -44,7 +45,7 @@ export function runEntrypoint(prefix: string, main: () => Promise): void const message = stdoutError instanceof Error ? stdoutError.message : String(stdoutError); try { process.stderr.write(`${prefix}: unable to flush stdout: ${message}\n`); - stderrError = await drainOutput(process.stderr, deadline); + stderrError = await drainOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); } catch (error) { stderrError = error; } From 4e6e8d95ede908f3b9ca166bd6004b67108ab0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:21:53 +0100 Subject: [PATCH 13/26] fix(scripts): retain output failures on natural exit --- scripts/lib/cli-entrypoint.test.ts | 27 +++++++++++++++++++++++++++ scripts/lib/cli-entrypoint.ts | 7 +++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index b79fa4e4f0..0cc8302de4 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -91,6 +91,33 @@ test.each( }, ); +test.each( + ['EIO', 'EPIPE'].flatMap((errorCode) => [0, 7].map((exitCode) => ({ errorCode, exitCode }))), +)( + 'early output error sets natural exit status while main stays pending ($errorCode, code=$exitCode)', + async ({ errorCode, exitCode }) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + `const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: ${JSON.stringify(errorCode)} })); } });`, + "Object.defineProperty(process, 'stdout', { value: output });", + `process.exitCode = ${exitCode};`, + "runEntrypoint('fixture', () => { process.stdout.write('trigger'); return new Promise(() => {}); });", + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || (errorCode === 'EPIPE' ? 0 : 1)); + assert.equal(stderr, ''); + }, +); + // A real pipe must stay unread: synthetic write callbacks cannot prove process termination under backpressure. test.each([ { exitCode: 0, diagnosticDelayMs: 0 }, diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index c52bfedec8..90a87f06e5 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -32,8 +32,11 @@ function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise Promise): void { - // Node retains the cause on stream.errored; early writes must survive until the drain classifies it. - for (const stream of [process.stdout, process.stderr]) stream.on('error', () => undefined); + // A pending main promise does not keep Node alive; retain early failures for natural exit. + const retainOutputFailure = (error: unknown): void => { + if (!isClosedPipe(error) && !Number(process.exitCode)) process.exitCode = 1; + }; + for (const stream of [process.stdout, process.stderr]) stream.on('error', retainOutputFailure); const exitAfterOutput = async (code: number): Promise => { const deadline = Date.now() + OUTPUT_DRAIN_TIMEOUT_MS; const [stdoutError, initialStderrError] = await Promise.all([ From 027ac35b9152ae2d404b548ff087cc4ad92116e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:36:52 +0100 Subject: [PATCH 14/26] fix(scripts): report early stdout failures once --- scripts/lib/cli-entrypoint.test.ts | 47 ++++++++++++++++++++++++++---- scripts/lib/cli-entrypoint.ts | 32 +++++++++++++++----- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index 0cc8302de4..96453ca615 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -92,17 +92,21 @@ test.each( ); test.each( - ['EIO', 'EPIPE'].flatMap((errorCode) => [0, 7].map((exitCode) => ({ errorCode, exitCode }))), + ['EIO', 'EPIPE'].flatMap((errorCode) => + [0, 7].flatMap((exitCode) => + [false, true].map((settles) => ({ errorCode, exitCode, settles })), + ), + ), )( - 'early output error sets natural exit status while main stays pending ($errorCode, code=$exitCode)', - async ({ errorCode, exitCode }) => { + 'early output error reports once and preserves status ($errorCode, code=$exitCode, settles=$settles)', + async ({ errorCode, exitCode, settles }) => { const source = [ `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, "import { Writable } from 'node:stream';", `const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: ${JSON.stringify(errorCode)} })); } });`, "Object.defineProperty(process, 'stdout', { value: output });", `process.exitCode = ${exitCode};`, - "runEntrypoint('fixture', () => { process.stdout.write('trigger'); return new Promise(() => {}); });", + `runEntrypoint('fixture', () => { process.stdout.write('trigger'); return ${settles ? `new Promise(resolve => setImmediate(() => resolve(${exitCode})))` : 'new Promise(() => {})'}; });`, ].join('\n'); const child = spawn( process.execPath, @@ -114,7 +118,40 @@ test.each( const [code, signal] = await once(child, 'close'); assert.equal(signal, null); assert.equal(code, exitCode || (errorCode === 'EPIPE' ? 0 : 1)); - assert.equal(stderr, ''); + assert.equal( + stderr, + errorCode === 'EPIPE' ? '' : 'fixture: unable to flush stdout: fixture write failure\n', + ); + }, +); + +test.each([0, 7])( + 'an early diagnostic queued after the initial drain is flushed (code=%s)', + async (exitCode) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + 'const stderr = process.stderr;', + "const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: 'EIO' })); } });", + 'const delayed = new Writable({ write(chunk, encoding, done) {', + ' if (chunk.length > 0) setTimeout(() => stderr.write(chunk, encoding, done), 50);', + ' else stderr.write(chunk, encoding, done);', + '} });', + "Object.defineProperty(process, 'stdout', { value: output });", + "Object.defineProperty(process, 'stderr', { value: delayed });", + `runEntrypoint('fixture', async () => ${exitCode});`, + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || 1); + assert.equal(stderr, 'fixture: unable to flush stdout: fixture write failure\n'); }, ); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index 90a87f06e5..0e4ff63b2d 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -36,7 +36,28 @@ export function runEntrypoint(prefix: string, main: () => Promise): void const retainOutputFailure = (error: unknown): void => { if (!isClosedPipe(error) && !Number(process.exitCode)) process.exitCode = 1; }; - for (const stream of [process.stdout, process.stderr]) stream.on('error', retainOutputFailure); + let stdoutDiagnosticAttempted = false; + const reportStdoutFailure = (error: unknown): void => { + if ( + stdoutDiagnosticAttempted || + process.stderr.destroyed || + !process.stderr.writable || + process.stderr.errored != null + ) + return; + stdoutDiagnosticAttempted = true; + const message = error instanceof Error ? error.message : String(error); + try { + process.stderr.write(`${prefix}: unable to flush stdout: ${message}\n`); + } catch (error) { + retainOutputFailure(error); + } + }; + process.stderr.on('error', retainOutputFailure); + process.stdout.on('error', (error: unknown) => { + retainOutputFailure(error); + if (!isClosedPipe(error)) reportStdoutFailure(error); + }); const exitAfterOutput = async (code: number): Promise => { const deadline = Date.now() + OUTPUT_DRAIN_TIMEOUT_MS; const [stdoutError, initialStderrError] = await Promise.all([ @@ -45,13 +66,8 @@ export function runEntrypoint(prefix: string, main: () => Promise): void ]); let stderrError = initialStderrError; if (stdoutError != null && stderrError == null) { - const message = stdoutError instanceof Error ? stdoutError.message : String(stdoutError); - try { - process.stderr.write(`${prefix}: unable to flush stdout: ${message}\n`); - stderrError = await drainOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); - } catch (error) { - stderrError = error; - } + reportStdoutFailure(stdoutError); + stderrError = await drainOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); } process.exit(code || (stdoutError != null || stderrError != null ? 1 : 0)); }; From 63a8a9a1ee5a075e630704914e2a4b72a0e5bed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:48:55 +0100 Subject: [PATCH 15/26] fix(scripts): retain late stream errors and validate pack sizes --- scripts/__tests__/size-report-package.test.ts | 22 ++++++++++ scripts/lib/cli-entrypoint.test.ts | 44 +++++++++++++++++++ scripts/lib/cli-entrypoint.ts | 13 ++++-- scripts/size-report-package.d.mts | 2 + scripts/size-report-package.mjs | 5 +++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 141705c556..e0ca9f6c3e 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -210,6 +210,28 @@ test.each(['1', -1, 0.5, null])('npm pack rejects malformed file sizes (%s)', (s ); }); +test.each( + ['size', 'unpackedSize'].flatMap((field) => + [undefined, null, '1', -1, 0.5, Number.MAX_SAFE_INTEGER + 1].map((value) => ({ field, value })), + ), +)('npm pack rejects invalid aggregate $field ($value)', ({ field, value }) => { + assert.throws( + () => + parseNpmPackOutput( + JSON.stringify({ filename: 'fixture.tgz', ...fixturePack, [field]: value }), + ), + new RegExp(`invalid ${field}`), + ); +}); + +test.each([0, Number.MAX_SAFE_INTEGER])('npm pack retains valid aggregate sizes (%s)', (size) => { + const parsed = parseNpmPackOutput( + JSON.stringify({ filename: 'fixture.tgz', ...fixturePack, size, unpackedSize: size }), + ); + assert.equal(parsed.size, size); + assert.equal(parsed.unpackedSize, size); +}); + test('npm pack parser shares validated file entries across both output shapes', () => { const pack = { filename: 'fixture.tgz', ...fixturePack }; assert.deepEqual(parseNpmPackOutput(JSON.stringify([pack])).files, fixturePack.files); diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index 96453ca615..979f599f84 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -155,6 +155,50 @@ test.each([0, 7])( }, ); +test.each( + ['stdout', 'stderr'].flatMap((stream) => + ['EIO', 'EPIPE'].flatMap((errorCode) => + [0, 7].map((exitCode) => ({ stream, errorCode, exitCode })), + ), + ), +)( + 'a late $stream error during the other drain preserves status ($errorCode, code=$exitCode)', + async ({ stream, errorCode, exitCode }) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + 'const stderr = process.stderr;', + 'const faulty = new Writable({ write(_chunk, _encoding, done) { done(); } });', + 'let injected = false;', + 'const waiting = new Writable({ write(chunk, encoding, done) {', + ' if (!injected && chunk.length === 0) {', + ' injected = true;', + ` setImmediate(() => { faulty.destroy(Object.assign(new Error('late write failure'), { code: ${JSON.stringify(errorCode)} })); setImmediate(done); });`, + ' } else stderr.write(chunk, encoding, done);', + '} });', + `Object.defineProperty(process, ${JSON.stringify(stream)}, { value: faulty });`, + `Object.defineProperty(process, ${JSON.stringify(stream === 'stdout' ? 'stderr' : 'stdout')}, { value: waiting });`, + `runEntrypoint('fixture', async () => ${exitCode});`, + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || (errorCode === 'EPIPE' ? 0 : 1)); + assert.equal( + stderr, + stream === 'stdout' && errorCode !== 'EPIPE' + ? 'fixture: unable to flush stdout: late write failure\n' + : '', + ); + }, +); + // A real pipe must stay unread: synthetic write callbacks cannot prove process termination under backpressure. test.each([ { exitCode: 0, diagnosticDelayMs: 0 }, diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index 0e4ff63b2d..1e68b9d70d 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -4,12 +4,16 @@ function isClosedPipe(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPIPE'; } +function outputFailure(stream: NodeJS.WriteStream, error?: unknown): unknown { + const cause = stream.errored ?? error; + return cause != null && !isClosedPipe(cause) ? cause : undefined; +} + function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise { return new Promise((resolve) => { const finish = (error?: unknown): void => { clearTimeout(timer); - const cause = stream.errored ?? error; - resolve(cause != null && !isClosedPipe(cause) ? cause : undefined); + resolve(outputFailure(stream, error)); }; const timer = setTimeout( () => finish(new Error('output drain timed out')), @@ -60,11 +64,12 @@ export function runEntrypoint(prefix: string, main: () => Promise): void }); const exitAfterOutput = async (code: number): Promise => { const deadline = Date.now() + OUTPUT_DRAIN_TIMEOUT_MS; - const [stdoutError, initialStderrError] = await Promise.all([ + const [initialStdoutError, initialStderrError] = await Promise.all([ drainOutput(process.stdout, deadline), drainOutput(process.stderr, deadline), ]); - let stderrError = initialStderrError; + const stdoutError = initialStdoutError ?? outputFailure(process.stdout); + let stderrError = initialStderrError ?? outputFailure(process.stderr); if (stdoutError != null && stderrError == null) { reportStdoutFailure(stdoutError); stderrError = await drainOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); diff --git a/scripts/size-report-package.d.mts b/scripts/size-report-package.d.mts index 0a9eca7375..6316ae08c1 100644 --- a/scripts/size-report-package.d.mts +++ b/scripts/size-report-package.d.mts @@ -16,5 +16,7 @@ export function assertPublishPackageContents( export function parseNpmPackOutput(stdout: string): Readonly<{ filename: string; + size: number; + unpackedSize: number; files: readonly NpmPackEntry[]; }>; diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index a0dad5bcd2..7bcfe31e50 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -172,6 +172,11 @@ export function parseNpmPackOutput(stdout) { if (!pack || typeof pack.filename !== 'string' || pack.filename.length === 0) { throw new Error('npm pack did not return a filename'); } + for (const field of ['size', 'unpackedSize']) { + if (!Number.isSafeInteger(pack[field]) || pack[field] < 0) { + throw new Error(`npm pack returned an invalid ${field}: ${JSON.stringify(pack[field])}`); + } + } return { ...pack, files: normalizeNpmPackEntries(pack) }; } From 0fe4cbdd29c522ffb26da6928a8a83ef2d8e854a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 18:02:59 +0100 Subject: [PATCH 16/26] test(scripts): pass child fixture values as arguments --- scripts/lib/cli-entrypoint.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index 979f599f84..7bf6a4be68 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -98,7 +98,7 @@ test.each( ), ), )( - 'early output error reports once and preserves status ($errorCode, code=$exitCode, settles=$settles)', + 'early output error preserves status and reports non-EPIPE failures once ($errorCode, code=$exitCode, settles=$settles)', async ({ errorCode, exitCode, settles }) => { const source = [ `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, @@ -167,22 +167,31 @@ test.each( const source = [ `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, "import { Writable } from 'node:stream';", + 'const [errorCode, stream, exitCode] = process.argv.slice(1);', 'const stderr = process.stderr;', 'const faulty = new Writable({ write(_chunk, _encoding, done) { done(); } });', 'let injected = false;', 'const waiting = new Writable({ write(chunk, encoding, done) {', ' if (!injected && chunk.length === 0) {', ' injected = true;', - ` setImmediate(() => { faulty.destroy(Object.assign(new Error('late write failure'), { code: ${JSON.stringify(errorCode)} })); setImmediate(done); });`, + " setImmediate(() => { faulty.destroy(Object.assign(new Error('late write failure'), { code: errorCode })); setImmediate(done); });", ' } else stderr.write(chunk, encoding, done);', '} });', - `Object.defineProperty(process, ${JSON.stringify(stream)}, { value: faulty });`, - `Object.defineProperty(process, ${JSON.stringify(stream === 'stdout' ? 'stderr' : 'stdout')}, { value: waiting });`, - `runEntrypoint('fixture', async () => ${exitCode});`, + 'Object.defineProperty(process, stream, { value: faulty });', + "Object.defineProperty(process, stream === 'stdout' ? 'stderr' : 'stdout', { value: waiting });", + "runEntrypoint('fixture', async () => Number(exitCode));", ].join('\n'); const child = spawn( process.execPath, - ['--experimental-strip-types', '--input-type=module', '--eval', source], + [ + '--experimental-strip-types', + '--input-type=module', + '--eval', + source, + errorCode, + stream, + String(exitCode), + ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); let stderr = ''; From 059cb60f6392984cea3a6a1845d25f7dfeca2d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 18:18:06 +0100 Subject: [PATCH 17/26] test(apple): await queued readiness without spending fake time --- .../src/__tests__/runner-lifecycle-request-cancellation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts b/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts index d2ce983b8d..9493297cbd 100644 --- a/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts +++ b/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts @@ -387,7 +387,7 @@ test('a wedged build with no caller left is ended by the default start budget an (build.signal?.reason as AppError | undefined)?.details?.reason, 'runner_start_budget_exhausted', ); - await settleUnderFakeTimers(next); + await next; }); assert.match(diagnostics, /ios_runner_detached_start_failed/); From 828447c22cbf5b3e2a6c35c74cf2029b9510c936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 19:27:05 +0100 Subject: [PATCH 18/26] test(android): keep the fake clock off the helper reply's path `underFakeTimers` advanced the clock 10 ms per awaited step, and each step is a real event-loop turn. A fill-verification sample crosses a real loopback socket to the fake helper session, so the turns it needed burned fake time: the 150 ms sample interval came out at 230-250 ms, and the 1500 ms deadline was reached with a sample still owed. The late-value test then saw seven captures instead of eight and reported a mismatch, at three in ten runs on an unloaded host. One millisecond per step keeps a socket reply inside its own interval: the interval is met at 150-160 ms and the test's sample count is no longer a function of host speed. The budgets and every assertion stay as they were. --- .../src/__tests__/test-utils/fake-timers.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/platform-android/src/__tests__/test-utils/fake-timers.ts b/packages/platform-android/src/__tests__/test-utils/fake-timers.ts index cbb60f6584..d4a9ad8bc6 100644 --- a/packages/platform-android/src/__tests__/test-utils/fake-timers.ts +++ b/packages/platform-android/src/__tests__/test-utils/fake-timers.ts @@ -1,8 +1,16 @@ import { vi } from 'vitest'; /** - * Runs `run` with its sleeps advanced instead of waited out. The clock moves in slices shorter than - * a poll interval, so a helper session's own timers never fire ahead of its replies. + * How far the clock moves per step. One step awaits a real event-loop turn, so every turn a session + * reply still needs burns this much of the clock. A reply crossing a real loopback socket takes + * several turns: at 10 ms the polling helper's own interval fired ahead of the reply it was waiting + * for, and the deadline was reached a sample or two early. + */ +const CLOCK_STEP_MS = 1; + +/** + * Runs `run` with its sleeps advanced instead of waited out. The clock moves in slices much shorter + * than a poll interval, so a helper session's own timers never fire ahead of its replies. */ export async function underFakeTimers(run: () => Promise): Promise { vi.useFakeTimers(); @@ -12,7 +20,7 @@ export async function underFakeTimers(run: () => Promise): Promise { done = true; }); outcome.catch(() => {}); - while (!done) await vi.advanceTimersByTimeAsync(10); + while (!done) await vi.advanceTimersByTimeAsync(CLOCK_STEP_MS); return await outcome; } finally { vi.useRealTimers(); From afef31f7b615f312ab0a3a1c10f6f60f63616eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 19:56:35 +0100 Subject: [PATCH 19/26] fix(scripts): name a bundled dependency that reports no version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rollup-plugin-license` types `Dependency.version` as optional and reports `null` for a module whose `package.json` has none, which is what an unresolvable or path-linked module looks like to it. `renderNotices` tested only the license and its text, so an otherwise fully licensed dependency was emitted as `included@null (MIT): [1]` — a shipped notice attributing code to a release that never existed — and a dependency missing both fields was reported as a license problem, pointing at a LICENSE file instead of at the version-less module. The version is now checked first, so every later diagnostic can name the package the way the notice file does. No shipping package is affected: the current build resolves a version for all 27 bundled dependencies. --- .../__tests__/bundled-license-notices.test.ts | 48 +++++++++++++++++++ scripts/lib/bundled-license-notices.ts | 24 ++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 9a5f47dee9..3db4c1f3d4 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -94,6 +94,34 @@ test('bundling fails when a dependency has a license identifier but no license t await assert.rejects(bundle(root), /included@1\.0\.0 is missing its license or license text/); }); +// `rollup-plugin-license` reports `version: null` for a module whose `package.json` has none, which +// is what an unresolvable or path-linked module looks like to it. Before the version was checked, a +// fully licensed such dependency shipped a notice reading `included@null (MIT)`, attributing code to +// a release that never existed, and one missing its license too was reported as a license problem — +// sending the reader to the fixture's LICENSE file instead of to the version-less module. +test.each([ + { name: 'no version field', version: undefined }, + { name: 'blank version', version: ' ' }, +])('bundling rejects a licensed dependency with a missing version: $name', async ({ version }) => { + const manifest: Record = { name: 'included', license: 'MIT', main: 'index.js' }; + if (version !== undefined) manifest.version = version; + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify(manifest), + 'node_modules/included/index.js': 'export const value = 42;', + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms.', + }); + await assert.rejects(bundle(root), /Bundled dependency included is missing its version/); + await assert.rejects(fs.stat(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt')), { + code: 'ENOENT', + }); +}); + +// A module whose `package.json` carries neither a version nor a license identifier never reaches +// `renderNotices`: the plugin reports no dependencies for it at all, and the bundle gets the generic +// empty notice. That gap belongs to the publish gate, not to this formatter — see the +// `size-report-package` notices content test. + test('identical notices are shared without losing packages or distinct copyrights', async () => { const sharedLicense = 'Copyright shared author\nFull shared license terms.'; const sharedNotice = 'Shared upstream attribution.'; @@ -228,6 +256,26 @@ test.each(['0.4.4', '0.4.5'])( }, ); +// A project that genuinely bundles nothing keeps getting the generic notice: the content policy that +// holds the published CLI to naming somebody is the publish gate's, not this formatter's. Pinning it +// here keeps a future "reject the placeholder" edit at the formatter from breaking an empty build. +test('a bundle with no third-party code emits the generic notice unchanged', async () => { + const root = await fixture({ + 'input.ts': "import { external } from 'external';\nexport const result = external;", + 'node_modules/external/package.json': JSON.stringify({ + name: 'external', + version: '1.0.0', + main: 'index.js', + }), + 'node_modules/external/index.js': 'export const external = 1;', + }); + await bundle(root); + assert.equal( + await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), + 'No third-party dependencies are bundled.\n', + ); +}); + test('output overrides write notices beside the bundle without creating the default dist', async () => { const root = await fixture({ 'input.ts': "export { value } from 'included';", diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index 415dd89e32..57d56237e3 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -32,6 +32,24 @@ export function bundledLicenseNotices(root: string): ReturnType }; } +/** + * A bundled dependency must be attributable before it can be listed: `version` is optional to + * `rollup-plugin-license` and reports `null` for a module whose `package.json` has none, which is what + * an unresolvable or path-linked module looks like to it. Emitting `included@null (MIT)` would + * attribute code to a release that never existed, so the version is named first and every later + * diagnostic can name the package the way the notice file does. + */ +function assertBundledDependencyAttributable(dependency: Dependency): void { + if (!dependency.version?.trim()) { + throw new Error(`Bundled dependency ${dependency.name} is missing its version.`); + } + if (!dependency.license || !dependency.licenseText?.trim()) { + throw new Error( + `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, + ); + } +} + function renderNotices(root: string, dependencies: readonly Dependency[]): string { if (dependencies.length === 0) return 'No third-party dependencies are bundled.\n'; const texts = new Map(); @@ -51,11 +69,7 @@ function renderNotices(root: string, dependencies: readonly Dependency[]): strin return a < b ? -1 : a > b ? 1 : 0; }); for (const dependency of sortedDependencies) { - if (!dependency.license || !dependency.licenseText?.trim()) { - throw new Error( - `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, - ); - } + assertBundledDependencyAttributable(dependency); const references = [reference(dependency.licenseText)]; if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); if (dependency.name === 'jpeg-js') { From a5c849d6ccb14f82553d778a1600a8e6463c6a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 19:56:56 +0100 Subject: [PATCH 20/26] fix(scripts): require the published notices to attribute somebody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assertBundledLicenseNotices` read the `npm pack` manifest, which can only say that `dist/src/THIRD_PARTY_NOTICES.txt` exists and how many bytes it is. The bundler writes a 41-byte "No third-party dependencies are bundled." notice when it reports zero dependencies, and a dependency that carries neither a version nor a license identifier is reported as none — with its code still bundled. Either way the publish gate cleared a tarball shipping unattributed code, which is the failure this PR exists to prevent. The published CLI always bundles, so its notices must carry at least one `name@version (license)` attribution and one referenced license text. The gate now reads those bytes: from the tree it just packed, and again from the clean-installed tree a user receives. A project that genuinely bundles nothing still gets the generic notice — that policy is the published package's, not the bundler's — and a caller holding only the manifest keeps the manifest-only check, which is what the size lane's base measurement of an older tree needs. --- scripts/__tests__/size-report-package.test.ts | 102 +++++++++++++++++- scripts/check-package.ts | 11 +- scripts/size-report-package.d.mts | 6 +- scripts/size-report-package.mjs | 56 ++++++++-- 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index e0ca9f6c3e..82bace8641 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -1,9 +1,14 @@ import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { test } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; import { formatMarkdown } from '../size-report.mjs'; -import { assertPublishPackageContents, parseNpmPackOutput } from '../size-report-package.mjs'; +import { + assertBundledLicenseNotices, + assertPublishPackageContents, + parseNpmPackOutput, +} from '../size-report-package.mjs'; const fixturePack = JSON.parse( await readFile(join(import.meta.dirname, 'fixtures', 'size-report-npm-pack.json'), 'utf8'), @@ -44,6 +49,99 @@ test('publish package requires bundled dependency license notices', () => { ); }); +// The publish gate's own copy of what a shipped notices file must contain. Written out rather than +// produced by the bundler: a fixture the producer generates cannot show the producer emitted the wrong +// thing. The attribution lines are the shape the real build writes; the placeholder is verbatim what +// `rollup-plugin-license` reports for a zero-dependency bundle. +const REAL_NOTICES = [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT): [1]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', +].join('\n'); +const EMPTY_BUNDLE_NOTICES = 'No third-party dependencies are bundled.\n'; + +/** The pack manifest's notices entry — non-empty by bytes, which is all a manifest can say. */ +function noticesEntry() { + return fixturePack.files.find((entry) => entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt'); +} + +/** + * A pack manifest plus the tree it was built from, so the gate is asked about real content and not + * only about a byte count. `noticesRoot` is the half the placeholder slipped past before. + */ +async function treeWithNotices(contents: string | null): Promise { + const root = await mkdtempForTest('notices-tree-'); + await mkdir(join(root, 'dist', 'src'), { recursive: true }); + if (contents !== null) { + await writeFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), contents); + } + return root; +} + +test('pack gate rejects the zero-dependency placeholder a non-empty manifest reports', async () => { + // The failure this exists for: the bundler stops bundling dependencies, the bundler reports none, + // and the tarball still ships a non-empty notices file. The manifest entry below is exactly the one + // a healthy build produces, so only the bytes tell the two apart. + const root = await treeWithNotices(EMPTY_BUNDLE_NOTICES); + assert.throws( + () => assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + /no bundled dependency notices[\s\S]*found 0 attributions and 0 license texts/, + ); + // Attribution lines with no license text behind them are the same defect by half. + const halfWritten = await treeWithNotices('@agent-device/proxy@0.21.37-dev (MIT): [1]\n'); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: halfWritten, + context: 'npm pack', + }), + /found 1 attributions and 0 license texts/, + ); +}); + +test('pack gate accepts a tree whose notices attribute the bundled dependencies', async () => { + const root = await treeWithNotices(REAL_NOTICES); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate names the tree it could not read notices from', async () => { + const root = await treeWithNotices(null); + assert.throws( + () => assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + /npm pack has no dist\/src\/THIRD_PARTY_NOTICES\.txt/, + ); +}); + +test('pack gate reads content without a tree, staying a manifest-only check', () => { + // `check-package.ts` proves the bytes from the installed tree; a caller holding only the manifest + // (the size lane's base measurement of an older tree) still gets the presence check alone. + assert.doesNotThrow(() => assertBundledLicenseNotices([noticesEntry()])); +}); + +test('publish package gate rejects placeholder notices from the tree it packed', async () => { + const root = await treeWithNotices(EMPTY_BUNDLE_NOTICES); + assert.throws( + () => + assertPublishPackageContents(fixturePack.files, { + noticesRoot: root, + macosHelperManifestSource: helperManifestSource, + }), + /no bundled dependency notices/, + ); + await writeFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), REAL_NOTICES); + assert.doesNotThrow(() => + assertPublishPackageContents(fixturePack.files, { + noticesRoot: root, + macosHelperManifestSource: helperManifestSource, + }), + ); +}); + test('publish package requires both Android helpers and excludes benchmark scripts', () => { assert.doesNotThrow(() => assertPublishPackageContents(fixturePack.files, { diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 81d28e4675..0bb72f6733 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -243,10 +243,19 @@ function smokeTestBin(installedRoot: string, manifest: PackedManifest): void { step(`Packing and verifying agent-device in ${workDir}`); try { const { tarball, packedEntries } = packTarball(); - assertBundledLicenseNotices(packedEntries); + assertBundledLicenseNotices(packedEntries, { + noticesRoot: repoRoot, + context: `The build packed from ${repoRoot}`, + }); const packedPaths = packedEntries.map((file) => file.path); lintTarball(tarball); const installedRoot = installIntoCleanConsumer(tarball); + // A user's machine is the tree that has to carry the attribution, so the content policy runs again + // against the installed file, not only the working-tree copy the tarball was built from. + assertBundledLicenseNotices(packedEntries, { + noticesRoot: installedRoot, + context: `The installed package at ${installedRoot}`, + }); assertInstalledSnapshotBridge(installedRoot); assertInstalledTreeIsNotASourceCheckout(installedRoot); assertPackedTreeCarriesNoTestCode(installedRoot, packedPaths); diff --git a/scripts/size-report-package.d.mts b/scripts/size-report-package.d.mts index 6316ae08c1..2056499a20 100644 --- a/scripts/size-report-package.d.mts +++ b/scripts/size-report-package.d.mts @@ -4,10 +4,14 @@ export const DOCUMENTED_PACK_STUBS: readonly string[]; export function readMacosHelperTestTargets(manifestSource: string): string[]; export type NpmPackEntry = Readonly<{ path: string; size: number }>; -export function assertBundledLicenseNotices(entries: readonly NpmPackEntry[]): void; +export function assertBundledLicenseNotices( + entries: readonly NpmPackEntry[], + options?: Readonly<{ noticesRoot?: string; context?: string }>, +): void; export function assertPublishPackageContents( entries: readonly NpmPackEntry[], options?: Readonly<{ + noticesRoot?: string; repoRoot?: string; requireSnapshotBridge?: boolean; macosHelperManifestSource?: string; diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 7bcfe31e50..21adf1b483 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -52,6 +52,7 @@ export function collectNpmPack(root) { const pack = parseNpmPackOutput(stdout); const entries = pack.files; assertPublishPackageContents(entries, { + noticesRoot: root, repoRoot: root, requireSnapshotBridge: fs.existsSync(path.join(root, 'apple', 'snapshot-bridge')), }); @@ -65,16 +66,59 @@ export function collectNpmPack(root) { }; } -export function assertBundledLicenseNotices(entries) { - if ( - !entries.some((entry) => entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt' && entry.size > 0) - ) { - throw new Error('npm pack is missing non-empty dist/src/THIRD_PARTY_NOTICES.txt'); +/** + * The notices asset a packed tree ships, and the build output it comes from. One path keeps the + * manifest check and the content check naming the same file. + */ +const BUNDLED_LICENSE_NOTICES_PATH = 'dist/src/THIRD_PARTY_NOTICES.txt'; + +/** + * The published CLI bundles its runtime dependencies into `dist`, so the notices it ships must name + * them: at least one `name@version (license)` attribution line and at least one referenced license + * text. The plugin's zero-dependency output — the generic "nothing bundled" line — names neither, so + * a build that stopped bundling dependencies would otherwise keep clearing this gate with a notice + * file holding no license text for the code the tarball ships. + * + * This is the published CLI's policy, not the bundler's: a project that genuinely bundles nothing + * still gets the generic notice, and only this package is held to naming somebody. + */ +function assertLicenseNoticeContents(contents, context) { + const attributions = contents.match(/^\S+@\S+ \([^)]+\): /gm) ?? []; + const licenseTexts = contents.match(/^\[\d+\]$/gm) ?? []; + if (attributions.length === 0 || licenseTexts.length === 0) { + throw new Error( + `${context} ships ${BUNDLED_LICENSE_NOTICES_PATH} with no bundled dependency notices: ` + + `expected at least one "name@version (license)" attribution and one referenced license text, ` + + `found ${attributions.length} attributions and ${licenseTexts.length} license texts.`, + ); + } +} + +/** + * Requires the notices asset the `npm pack` manifest declares, and — when `noticesRoot` names the tree + * that was packed or installed — that its contents attribute somebody. A manifest says only that a + * path exists and how many bytes it is, which the bundler's zero-dependency placeholder satisfies; + * reading the bytes is what lets the gate see the attribution a user actually receives. A tree without + * the file fails naming the path, rather than with a bare ENOENT from a read. + */ +export function assertBundledLicenseNotices(entries, options = {}) { + if (!entries.some((entry) => entry.path === BUNDLED_LICENSE_NOTICES_PATH && entry.size > 0)) { + throw new Error(`npm pack is missing non-empty ${BUNDLED_LICENSE_NOTICES_PATH}`); } + if (!options.noticesRoot) return; + const noticesPath = path.join(options.noticesRoot, BUNDLED_LICENSE_NOTICES_PATH); + const context = options.context ?? options.noticesRoot; + if (!fs.existsSync(noticesPath)) { + throw new Error(`${context} has no ${BUNDLED_LICENSE_NOTICES_PATH}.`); + } + assertLicenseNoticeContents(fs.readFileSync(noticesPath, 'utf8'), context); } export function assertPublishPackageContents(entries, options = {}) { - assertBundledLicenseNotices(entries); + assertBundledLicenseNotices(entries, { + noticesRoot: options.noticesRoot, + context: options.noticesRoot ? `npm pack of ${options.noticesRoot}` : undefined, + }); const paths = entries.map((entry) => entry.path); const requiredAssets = [ { directory: 'android/snapshot-helper/dist/', suffix: '.apk' }, From 8c3d6231f1321f09078e407fd7d2929cabf14e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:09:49 +0100 Subject: [PATCH 21/26] fix(scripts): require every notices attribution to carry license text The published-notices gate counted the two kinds of line a notices file holds: at least one `name@version (license)` attribution and at least one `[n]` marker. A file of bare headings satisfies both counts and carries no license terms at all, so `pkg@1.0.0 (MIT): [1]` over an empty `[1]` cleared the gate, as did a reference to an id the file never writes. The gate now reads the sections: every reference between a package's colon and its optional `; Author:` clause must name a numbered section with text in it, and the failure names the attributions that do not. Deduplicated and multi-reference files, author clauses, and license bodies with blank lines inside them are the shapes the real build writes, so they stay accepted, and a tree that genuinely bundles nothing still gets the generic notice. --- scripts/__tests__/size-report-package.test.ts | 109 +++++++++++++++++- scripts/size-report-package.mjs | 84 +++++++++++--- 2 files changed, 176 insertions(+), 17 deletions(-) diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 82bace8641..6534f17c87 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -51,8 +51,9 @@ test('publish package requires bundled dependency license notices', () => { // The publish gate's own copy of what a shipped notices file must contain. Written out rather than // produced by the bundler: a fixture the producer generates cannot show the producer emitted the wrong -// thing. The attribution lines are the shape the real build writes; the placeholder is verbatim what -// `rollup-plugin-license` reports for a zero-dependency bundle. +// thing. The attribution lines, the reference markers and the license bodies are the shape +// `renderNotices` in scripts/lib/bundled-license-notices.ts writes for a bundled build, and the +// placeholder below is the line that same function writes when the bundle holds no third-party code. const REAL_NOTICES = [ '@agent-device/proxy@0.21.37-dev (MIT): [1]', 'undici@7.1.1 (MIT): [1]', @@ -63,6 +64,22 @@ const REAL_NOTICES = [ ].join('\n'); const EMPTY_BUNDLE_NOTICES = 'No third-party dependencies are bundled.\n'; +// Two distinct license bodies, one of them shared by two attributions: the shape the bundler writes +// when it deduplicates identical texts, which the gate must keep accepting. +const TWO_LICENSES_NOTICES = [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT): [2]', + 'yauzl@3.4.0 (MIT): [1], [2]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + '', + '[2]', + 'Copyright (c) 2018 Nathan LaFreniere and other contributors', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', +].join('\n'); + /** The pack manifest's notices entry — non-empty by bytes, which is all a manifest can say. */ function noticesEntry() { return fixturePack.files.find((entry) => entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt'); @@ -109,6 +126,88 @@ test('pack gate accepts a tree whose notices attribute the bundled dependencies' ); }); +test('pack gate rejects attributions whose references hold no license text', async () => { + // A marker is a heading, not a license: this file has the shape the bundler writes and zero terms + // in it, and a gate that only counts the two kinds of line lets it through. + const markerWithoutTerms = await treeWithNotices( + '@agent-device/proxy@0.21.37-dev (MIT): [1]\n\n[1]\n', + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: markerWithoutTerms, + context: 'npm pack', + }), + /no bundled dependency notices[\s\S]*1 attributions and 0 license texts[\s\S]*unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> \[1\]/, + ); + // The other half: the reference names an id the file never writes. + const dangling = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [3]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: dangling, context: 'npm pack' }), + /unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> \[3\]/, + ); + // An attribution with nothing after the colon promises a license and carries none. + const noReference = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): ', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: noReference, + context: 'npm pack', + }), + /unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> no reference/, + ); +}); + +test('pack gate accepts every reference a deduplicated notices file makes', async () => { + // The bundler numbers each distinct license text once, so one package can point at two texts and + // two packages can share one. Resolving references must not read that as a missing license. + const root = await treeWithNotices(TWO_LICENSES_NOTICES); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); + // The shapes the real build writes and a naive reader would reject: an attribution that names an + // author after the references, and an Apache-style license whose body carries blank lines. + await writeFile( + join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]; Author: Callstack', + 'undici@7.1.1 (Apache-2.0): [1], [2]; Author: Nathan LaFreniere ', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + '', + '[2]', + 'Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file', + 'except in compliance with the License.', + '', + 'You may obtain a copy of the License at', + '', + ' https://www.apache.org/licenses/LICENSE-2.0', + ].join('\n'), + ); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + test('pack gate names the tree it could not read notices from', async () => { const root = await treeWithNotices(null); assert.throws( @@ -118,8 +217,10 @@ test('pack gate names the tree it could not read notices from', async () => { }); test('pack gate reads content without a tree, staying a manifest-only check', () => { - // `check-package.ts` proves the bytes from the installed tree; a caller holding only the manifest - // (the size lane's base measurement of an older tree) still gets the presence check alone. + // The manifest entry alone is a presence-and-bytes claim, so that is all this call can prove; the + // content policy needs the tree whose bytes it is standing in for. Production callers — `collectNpmPack` + // and both calls in `check-package.ts` — always name that tree, so the packed and installed copies + // are read, never only the manifest. assert.doesNotThrow(() => assertBundledLicenseNotices([noticesEntry()])); }); diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 21adf1b483..623438a8e7 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -72,26 +72,84 @@ export function collectNpmPack(root) { */ const BUNDLED_LICENSE_NOTICES_PATH = 'dist/src/THIRD_PARTY_NOTICES.txt'; +/** + * The ids of the numbered license sections that hold text. A section runs from its `[n]` heading to + * the next heading, so a license whose body carries blank lines keeps that body. A line shaped like a + * heading inside a license body only shortens the section it sits in; it cannot fill an empty one. + */ +function readLicenseTextIds(lines) { + const idsWithText = new Set(); + let openId; + let seenText = false; + for (const line of lines) { + const heading = line.match(/^\[(\d+)\]$/); + if (heading) { + if (seenText) idsWithText.add(openId); + openId = Number(heading[1]); + seenText = false; + } else if (openId !== undefined && line.trim()) { + seenText = true; + } + } + if (seenText) idsWithText.add(openId); + return idsWithText; +} + +/** + * The `name@version (license): [n]` line the bundler writes, or undefined for any other line. The + * references are what stands between the colon and the optional `; Author: …` clause; one package can + * name several ids when it ships a NOTICE or source-file notices with its license. + */ +function readAttribution(line) { + const attribution = line.match(/^(\S+@\S+ \([^)]+\)): ?(.*)$/); + if (!attribution) return undefined; + return { + label: attribution[1], + references: [...attribution[2].matchAll(/\[(\d+)\]/g)].map((reference) => Number(reference[1])), + }; +} + +function describeUnresolvedAttribution(attribution) { + return ( + `${attribution.label} -> ` + + (attribution.references.length > 0 + ? `[${attribution.references.join('], [')}]` + : 'no reference') + ); +} + /** * The published CLI bundles its runtime dependencies into `dist`, so the notices it ships must name - * them: at least one `name@version (license)` attribution line and at least one referenced license - * text. The plugin's zero-dependency output — the generic "nothing bundled" line — names neither, so - * a build that stopped bundling dependencies would otherwise keep clearing this gate with a notice - * file holding no license text for the code the tarball ships. + * them and carry the terms behind every name: each `name@version (license): [n]` attribution must + * point at a numbered section that holds text. The bundler's zero-dependency output — the generic + * "nothing bundled" line — names nobody, and a file of bare `[n]` headings has the right shape with + * no license terms in it, so a build that stopped bundling dependencies, or wrote headings without + * bodies, would otherwise keep clearing a gate that only counts the two kinds of line. * * This is the published CLI's policy, not the bundler's: a project that genuinely bundles nothing * still gets the generic notice, and only this package is held to naming somebody. */ function assertLicenseNoticeContents(contents, context) { - const attributions = contents.match(/^\S+@\S+ \([^)]+\): /gm) ?? []; - const licenseTexts = contents.match(/^\[\d+\]$/gm) ?? []; - if (attributions.length === 0 || licenseTexts.length === 0) { - throw new Error( - `${context} ships ${BUNDLED_LICENSE_NOTICES_PATH} with no bundled dependency notices: ` + - `expected at least one "name@version (license)" attribution and one referenced license text, ` + - `found ${attributions.length} attributions and ${licenseTexts.length} license texts.`, - ); - } + const lines = contents.replaceAll('\r\n', '\n').split('\n'); + const licenseTextIds = readLicenseTextIds(lines); + const attributions = lines + .map(readAttribution) + .filter((attribution) => attribution !== undefined); + const unresolved = attributions.filter( + (attribution) => + attribution.references.length === 0 || + attribution.references.some((reference) => !licenseTextIds.has(reference)), + ); + if (attributions.length > 0 && unresolved.length === 0) return; + throw new Error( + `${context} ships ${BUNDLED_LICENSE_NOTICES_PATH} with no bundled dependency notices: ` + + `expected every "name@version (license): [n]" attribution to reference a non-empty license ` + + `text, found ${attributions.length} attributions and ${licenseTextIds.size} license texts` + + (unresolved.length > 0 + ? `; unresolved: ${unresolved.map(describeUnresolvedAttribution).join('; ')}` + : '') + + '.', + ); } /** From 44b59309e80d6c49f876f995fd738c5b703faf8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:09:55 +0100 Subject: [PATCH 22/26] fix(scripts): end a populated notices file with a newline The generic notice for a bundle with no third-party code ended in a newline and the populated one did not, so the file a user reads ran its last license block against the shell prompt. Every other text file the build writes ends one line, and the notice is 49 kB of text people read with `cat`. One newline after the joined sections, and the existing populated fixture now pins that the file ends in exactly one. --- scripts/__tests__/bundled-license-notices.test.ts | 4 ++++ scripts/lib/bundled-license-notices.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 3db4c1f3d4..104d7a1c8d 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -78,6 +78,10 @@ test('bundled licenses retain full license and NOTICE text after minification', assert.match(notices, /Upstream attribution retained\./); assert.match(notices, /Author: Fixture author /); assert.doesNotMatch(notices, /unused|external|fixture-project/); + // The populated notice ends like any other text file: exactly one trailing newline, so a reader + // who cats it and a tool that counts its lines both see a finished last license block. + assert.ok(notices.endsWith('\n'), 'notices end with a newline'); + assert.ok(!notices.endsWith('\n\n'), 'notices carry exactly one trailing newline'); }); test('bundling fails when a dependency has a license identifier but no license text', async () => { diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index 57d56237e3..9dbf99946f 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -91,7 +91,10 @@ function renderNotices(root: string, dependencies: readonly Dependency[]): strin `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}${dependency.author ? `; Author: ${dependency.author.text()}` : ''}`, ); } - return [packages.join('\n'), ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`)].join( + // One trailing newline closes the last license block: the generic notice above carries one, and a + // reader who `cat`s the file or counts its lines sees a finished file either way. + const body = [packages.join('\n'), ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`)].join( '\n\n', ); + return `${body}\n`; } From 53539d311c82146b9c067835499807f6ce0ba213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:55:50 +0100 Subject: [PATCH 23/26] fix(scripts): read the attribution the notices renderer writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published-notices gate matched the license field with `[^)]+`, so an attribution whose license is a grouped SPDX expression — `renderNotices` wraps whatever the plugin reports, and the plugin passes a parenthesized `license` field through, producing `pkg@1.0.0 ((MIT OR Apache-2.0)): [1]` — was not recognized at all. A notices file holding only such a line reported zero attributions, so the gate rejected bytes a healthy build produced; mixed with a valid line, the unparseable one was skipped, so a grouped attribution with a dangling or missing reference cleared the gate unseen. The field now ends at the first `):` boundary, the only place a nested expression can close, and the reference list stops at the renderer's optional `; Author: ` clause. That also drops a disclosed phantom: a package whose author field carried `[7]` read that footnote as a second license reference, failing valid bytes that referenced only `[1]` (and passing a file whose sole "reference" was such a footnote). The new cases run the real license plugin over a fixture tree, so they assert on bytes `renderNotices` emits, not on hand-written shapes: a grouped expression and an author footnote each pass the pack gate, and a grouped line whose reference is dangling or absent still fails naming it. --- scripts/__tests__/size-report-package.test.ts | 171 +++++++++++++++++- scripts/size-report-package.mjs | 14 +- 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 6534f17c87..db81c97e0a 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { promisify } from 'node:util'; import { test } from 'vitest'; import { mkdtempForTest } from '@agent-device/test-kit'; import { formatMarkdown } from '../size-report.mjs'; @@ -14,6 +16,8 @@ const fixturePack = JSON.parse( await readFile(join(import.meta.dirname, 'fixtures', 'size-report-npm-pack.json'), 'utf8'), ); +const execFileAsync = promisify(execFile); + // The fixture pack ships the macOS helper (manifest, sources, and per-target PackStub.swift), // so every assertion here names the manifest source the guard would read from the packed tree; // without it the guard falls back to this checkout's manifest via repoRoot. @@ -224,6 +228,171 @@ test('pack gate reads content without a tree, staying a manifest-only check', () assert.doesNotThrow(() => assertBundledLicenseNotices([noticesEntry()])); }); +/** A dependency the license plugin bundles, with the manifest fields that shape its attribution. */ +function dependencyManifest(name: string, fields: Record): [string, string][] { + return [ + [ + `node_modules/${name}/package.json`, + JSON.stringify({ name, version: '1.2.3', main: 'index.js', ...fields }), + ], + [ + `node_modules/${name}/index.js`, + `export const ${name.replaceAll(/[^A-Za-z0-9_$]/g, '_')} = process.env.${name.toUpperCase()};`, + ], + [`node_modules/${name}/LICENSE`, `Copyright ${name} author\nFull ${name} license terms.`], + ]; +} + +/** + * Real renderer bytes: runs the same `bundledLicenseNotices` plugin the published build uses (through + * the shared fixture build script) over a fixture tree, so the attribution lines the pack gate reads + * are the ones `renderNotices` writes — grouped SPDX field, author clause and all — rather than a + * hand-written approximation of them. + */ +async function renderedNotices(dependencies: [string, Record][]): Promise { + const root = await realpath(await mkdtempForTest('pack-gate-render-')); + const files: [string, string][] = [ + ['package.json', JSON.stringify({ name: 'fixture-project', type: 'module' })], + [ + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { target: 'es2022', module: 'nodenext', skipLibCheck: true }, + include: ['input.ts'], + }), + ], + ['input.ts', dependencies.map(([name]) => `export { ${name} } from '${name}';`).join('\n')], + ]; + for (const [name, fields] of dependencies) files.push(...dependencyManifest(name, fields)); + for (const [relative, source] of files) { + const file = join(root, relative); + await mkdir(join(file, '..'), { recursive: true }); + await writeFile(file, source); + } + await execFileAsync( + process.execPath, + [ + '--experimental-strip-types', + join(import.meta.dirname, 'fixtures', 'bundled-license-build.mts'), + root, + 'false', + 'dist', + ], + { cwd: import.meta.dirname }, + ); + const tree = await treeWithNotices( + await readFile(join(root, 'dist', 'THIRD_PARTY_NOTICES.txt'), 'utf8'), + ); + return tree; +} + +test('pack gate accepts the attribution the renderer writes for a grouped SPDX expression', async () => { + // A `package.json` license may be a grouped SPDX expression, and `renderNotices` wraps whatever the + // plugin reports in its own parentheses, so the attribution carries nested ones. Those bytes are + // valid producer output: the gate has to read them, not report them as naming nobody. + const root = await renderedNotices([ + ['shared', { license: '(MIT OR Apache-2.0)', author: 'Grouped author ' }], + ]); + const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(rendered, /^shared@1\.2\.3 \(\(MIT OR Apache-2\.0\)\): \[1\]; Author: /m); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate reads the license reference, not the author clause, from real bytes', async () => { + // The author line is attribution metadata: a bracketed footnote inside it is not a license + // reference. Resolving it as one would both reject valid bytes (no such section) and let an + // attribution with no reference at all pass on an author's footnote. + const root = await renderedNotices([ + ['distinct', { license: 'MIT', author: 'Bracket author [7] ' }], + ]); + const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(rendered, /\[1\]; Author: Bracket author \[7\]/); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate still rejects a grouped attribution whose reference is missing or absent', async () => { + // The mixed file is the point: a simple attribution must not stand in for the grouped one, so the + // grouped line has to be inspected and named rather than skipped as unparseable. + const groupedDangling = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT OR (Apache-2.0 AND CC0-1.0)): [9]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: groupedDangling, + context: 'npm pack', + }), + /2 attributions and 1 license texts[\s\S]*unresolved: undici@7\.1\.1 \(MIT OR \(Apache-2\.0 AND CC0-1\.0\)\) -> \[9\]/, + ); + const groupedEmpty = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT OR Apache-2.0): ', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: groupedEmpty, + context: 'npm pack', + }), + /unresolved: undici@7\.1\.1 \(MIT OR Apache-2\.0\) -> no reference/, + ); +}); + +test('pack gate takes no reference from an author clause', async () => { + // Written out rather than rendered: the shape the renderer only produces when a package's author + // field carries a footnote, which the real-bytes case above already covers end to end. + const authorSupplied = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): ; Author: Footnote author [1]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: authorSupplied, + context: 'npm pack', + }), + /unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> no reference/, + ); + // And the same file with a real reference beside the footnote passes: the author clause is + // ignored, not treated as a rejection by default. + const withReference = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]; Author: Footnote author [1]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: withReference, + context: 'npm pack', + }), + ); +}); + test('publish package gate rejects placeholder notices from the tree it packed', async () => { const root = await treeWithNotices(EMPTY_BUNDLE_NOTICES); assert.throws( diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 623438a8e7..27413f9a54 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -97,15 +97,21 @@ function readLicenseTextIds(lines) { /** * The `name@version (license): [n]` line the bundler writes, or undefined for any other line. The - * references are what stands between the colon and the optional `; Author: …` clause; one package can - * name several ids when it ships a NOTICE or source-file notices with its license. + * license field is whatever the plugin reports, wrapped in the renderer's own parentheses, so a + * grouped SPDX expression like `(MIT OR (Apache-2.0 AND CC0-1.0))` nests inside it: the field ends + * at the first `):` boundary, which only that closing parenthesis can reach. What follows the colon + * up to the renderer's optional `; Author: ` clause is the reference list — one package names + * several ids when it ships a NOTICE or source-file notices with its license. The author clause is + * attribution metadata: an author field holding a bracketed footnote must not read as a license + * reference, in either direction. */ function readAttribution(line) { - const attribution = line.match(/^(\S+@\S+ \([^)]+\)): ?(.*)$/); + const attribution = line.match(/^(\S+@\S+ \((.+?)\)): ?(.*)$/); if (!attribution) return undefined; + const references = attribution[3].split('; Author: ', 1)[0]; return { label: attribution[1], - references: [...attribution[2].matchAll(/\[(\d+)\]/g)].map((reference) => Number(reference[1])), + references: [...references.matchAll(/\[(\d+)\]/g)].map((reference) => Number(reference[1])), }; } From df46a350dd98aca3fd7682015c272f545832a407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 23:58:34 +0100 Subject: [PATCH 24/26] fix(scripts): read attributions only from the notices header block The pack gate scanned every line of THIRD_PARTY_NOTICES.txt for an attribution, but the renderer emits the attributions as one block before the first numbered section and copies each section's license or NOTICE text verbatim after it. Prose there belongs to the upstream package: a contact line or a pointer to "section [9]" shaped like an attribution made the gate reject bytes the bundler legitimately wrote, and one such line let a file that attributes nobody clear the gate that exists to catch exactly that. Scope attribution parsing to the header block the producer writes; the section scan, the reference checks and the reject directions stay as they were. --- scripts/__tests__/size-report-package.test.ts | 59 ++++++++++++++++++- scripts/size-report-package.mjs | 24 +++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index db81c97e0a..8c1dfe14f1 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -249,7 +249,9 @@ function dependencyManifest(name: string, fields: Record): [stri * are the ones `renderNotices` writes — grouped SPDX field, author clause and all — rather than a * hand-written approximation of them. */ -async function renderedNotices(dependencies: [string, Record][]): Promise { +async function renderedNotices( + dependencies: [string, Record, Record?][], +): Promise { const root = await realpath(await mkdtempForTest('pack-gate-render-')); const files: [string, string][] = [ ['package.json', JSON.stringify({ name: 'fixture-project', type: 'module' })], @@ -262,7 +264,10 @@ async function renderedNotices(dependencies: [string, Record][]) ], ['input.ts', dependencies.map(([name]) => `export { ${name} } from '${name}';`).join('\n')], ]; - for (const [name, fields] of dependencies) files.push(...dependencyManifest(name, fields)); + for (const [name, fields, extras] of dependencies) { + files.push(...dependencyManifest(name, fields)); + if (extras) files.push(...Object.entries(extras)); + } for (const [relative, source] of files) { const file = join(root, relative); await mkdir(join(file, '..'), { recursive: true }); @@ -313,6 +318,56 @@ test('pack gate reads the license reference, not the author clause, from real by ); }); +test('pack gate reads no attribution from license or NOTICE body text', async () => { + // A numbered section holds upstream license or NOTICE text verbatim, at column zero: the renderer + // neither escapes nor indents it. Prose shaped like an attribution — an email contact, a line + // pointing at "section [9]" of some other package — is ordinary upstream content, and the only + // attributions the bundler itself writes are the block before the first heading. Reading a body + // line as an attribution would reject publishable bytes: the dangling [9] and the empty reference + // below are unparseable as anything but unresolved attributions. + const root = await renderedNotices([ + [ + 'included', + { license: 'MIT' }, + { + 'node_modules/included/NOTICE': [ + 'This distribution bundles software from several upstreams:', + 'helper-pkg@0.1.0 (MIT): see section [9] for those terms', + 'maintainer@example.test (Apache-2.0): ', + ].join('\n'), + }, + ], + ]); + const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(rendered, /^helper-pkg@0\.1\.0 \(MIT\): see section \[9\]/m); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate rejects a file that attributes nobody but whose license text names somebody', async () => { + // The gate exists to catch a notices file that attributes nobody. A build that lost the leading + // attribution block keeps the heading and bodies, and one body can happen to carry a line shaped + // like an attribution pointing at its own section. That file must not clear the gate on prose it + // preserves verbatim: only the block the renderer writes before the first heading attributes. + const bodyOnly = await treeWithNotices( + [ + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: bodyOnly, + context: 'npm pack', + }), + /no bundled dependency notices[\s\S]*found 0 attributions and 1 license texts/, + ); +}); + test('pack gate still rejects a grouped attribution whose reference is missing or absent', async () => { // The mixed file is the point: a simple attribution must not stand in for the grouped one, so the // grouped line has to be inspected and named rather than skipped as unparseable. diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 27413f9a54..e0a62a289d 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -72,6 +72,13 @@ export function collectNpmPack(root) { */ const BUNDLED_LICENSE_NOTICES_PATH = 'dist/src/THIRD_PARTY_NOTICES.txt'; +/** + * The heading that opens a numbered license section. The renderer writes every attribution it + * produces before the first one, so this line is also the boundary between the attributions the + * gate reads and the license text it preserves. + */ +const LICENSE_SECTION_HEADING = /^\[(\d+)\]$/; + /** * The ids of the numbered license sections that hold text. A section runs from its `[n]` heading to * the next heading, so a license whose body carries blank lines keeps that body. A line shaped like a @@ -82,7 +89,7 @@ function readLicenseTextIds(lines) { let openId; let seenText = false; for (const line of lines) { - const heading = line.match(/^\[(\d+)\]$/); + const heading = line.match(LICENSE_SECTION_HEADING); if (heading) { if (seenText) idsWithText.add(openId); openId = Number(heading[1]); @@ -95,6 +102,19 @@ function readLicenseTextIds(lines) { return idsWithText; } +/** + * The lines the attributions can live on: everything before the first numbered section, which is the + * block the renderer writes first. A section holds a dependency's license or NOTICE text verbatim — + * the renderer neither escapes nor indents it — so its content is upstream's, and prose there can + * carry a contact address or a pointer to "section [9]" shaped like an attribution line. Reading + * those as attributions would reject publishable bytes, and one such line would stand in for the + * real attributions a build failed to write. + */ +function readAttributionLines(lines) { + const firstSection = lines.findIndex((line) => LICENSE_SECTION_HEADING.test(line)); + return firstSection === -1 ? lines : lines.slice(0, firstSection); +} + /** * The `name@version (license): [n]` line the bundler writes, or undefined for any other line. The * license field is whatever the plugin reports, wrapped in the renderer's own parentheses, so a @@ -138,7 +158,7 @@ function describeUnresolvedAttribution(attribution) { function assertLicenseNoticeContents(contents, context) { const lines = contents.replaceAll('\r\n', '\n').split('\n'); const licenseTextIds = readLicenseTextIds(lines); - const attributions = lines + const attributions = readAttributionLines(lines) .map(readAttribution) .filter((attribution) => attribution !== undefined); const unresolved = attributions.filter( From 96505cea4782a73dbb400859dbc5a51b062c9517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 07:35:40 +0100 Subject: [PATCH 25/26] fix(scripts): enumerate bundled modules and bound early diagnostics The build now walks every module rendered into the bundle and attributes each third-party one to its package itself, replacing rollup-plugin-license. That plugin dropped a package whose manifest named neither a version nor a license, so a bundle beside a licensed neighbor shipped a partial notices file the pack gate accepted. A module with no package.json under node_modules now stops the build too. The real artifact is byte-identical (48,996 bytes, 27 attributions over 23 sections). The notices format moves into scripts/size-report-package-notices.mjs: one dependency-free owner of the renderer and reader. The pack gate calls its reader and keeps the package-path policy; the size lane's base step copies size-report-package*.mjs so the measured revision's format travels with its policy. Multiline license and author text renders on one attribution line. runEntrypoint routes both output paths through settleOutput. An early stdout-failure diagnostic still unflushed at its deadline exits with the retained failure while main is pending, since Node cannot close stdio and a stalled stderr write would otherwise hold the process forever. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/size.yml | 2 +- package.json | 1 - pnpm-lock.yaml | 365 ------------------ .../__tests__/bundled-license-notices.test.ts | 76 +++- .../fixtures/bundled-license-build.mts | 1 + scripts/__tests__/size-report-package.test.ts | 74 +++- scripts/lib/bundled-license-notices.ts | 205 ++++++---- scripts/lib/cli-entrypoint.test.ts | 39 ++ scripts/lib/cli-entrypoint.ts | 39 +- scripts/size-report-package-notices.d.mts | 14 + scripts/size-report-package-notices.mjs | 106 +++++ scripts/size-report-package.mjs | 70 +--- 12 files changed, 440 insertions(+), 552 deletions(-) create mode 100644 scripts/size-report-package-notices.d.mts create mode 100644 scripts/size-report-package-notices.mjs diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 66f11fe633..be7c51abab 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -71,7 +71,7 @@ jobs: - name: Measure base size run: | git checkout --detach "${{ github.event.pull_request.base.sha }}" - cp scripts/size-report-package.mjs /tmp/agent-device-size-report/ + cp scripts/size-report-package*.mjs /tmp/agent-device-size-report/ pnpm install --frozen-lockfile if [ "${{ steps.base-dist-cache.outputs.cache-hit }}" != "true" ]; then pnpm build diff --git a/package.json b/package.json index 0f62669662..7035902303 100644 --- a/package.json +++ b/package.json @@ -367,7 +367,6 @@ "oxlint": "^1.79.0", "pngjs": "^7.0.0", "publint": "^0.3.24", - "rollup-plugin-license": "^3.7.1", "tar-stream": "^3.2.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8158af07c9..0e4bdab090 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,9 +229,6 @@ importers: publint: specifier: ^0.3.24 version: 0.3.24 - rollup-plugin-license: - specifier: ^3.7.1 - version: 3.7.1(picomatch@4.0.7)(rollup@4.66.0) tar-stream: specifier: ^3.2.0 version: 3.2.0 @@ -2101,13 +2098,6 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} - cpu: [x64] - os: [linux] - libc: [glibc] - '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -2747,144 +2737,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.66.0': - resolution: {integrity: sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.66.0': - resolution: {integrity: sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.66.0': - resolution: {integrity: sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.66.0': - resolution: {integrity: sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.66.0': - resolution: {integrity: sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.66.0': - resolution: {integrity: sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.66.0': - resolution: {integrity: sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.66.0': - resolution: {integrity: sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.66.0': - resolution: {integrity: sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.66.0': - resolution: {integrity: sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.66.0': - resolution: {integrity: sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.66.0': - resolution: {integrity: sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.66.0': - resolution: {integrity: sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.66.0': - resolution: {integrity: sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.66.0': - resolution: {integrity: sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.66.0': - resolution: {integrity: sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.66.0': - resolution: {integrity: sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.66.0': - resolution: {integrity: sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.66.0': - resolution: {integrity: sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.66.0': - resolution: {integrity: sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.66.0': - resolution: {integrity: sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.66.0': - resolution: {integrity: sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.66.0': - resolution: {integrity: sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.66.0': - resolution: {integrity: sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.66.0': - resolution: {integrity: sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==} - cpu: [x64] - os: [win32] - '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3593,10 +3445,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - array-find-index@1.0.2: - resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} - engines: {node: '>=0.10.0'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3808,9 +3656,6 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - commenting@1.1.0: - resolution: {integrity: sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA==} - common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} @@ -4594,9 +4439,6 @@ packages: lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4608,9 +4450,6 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -4778,9 +4617,6 @@ packages: resolution: {integrity: sha512-t+Vn6DX4wPCxS23y03C0uhF7S1jpi1GfqPrnlEipCJaA2ykiNbMkVlDutZtmvLfRcUN6R8AYVlzTsQrT4RZoLQ==} engines: {node: 18 || 20 || >=22} - moment@2.31.0: - resolution: {integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4950,10 +4786,6 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} - package-name-regex@2.0.6: - resolution: {integrity: sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA==} - engines: {node: '>=12'} - pagefind@1.5.2: resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} hasBin: true @@ -5176,17 +5008,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup-plugin-license@3.7.1: - resolution: {integrity: sha512-FcGXUbAmPvRSLxjVdjp/r/MUtKBlttVQd+ApUyvKfREnsoAfAZA6Ic2fE1Tz4RL0f9XqEQU9UIRNUMdtQtliDw==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 - - rollup@4.66.0: - resolution: {integrity: sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5281,27 +5102,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - spdx-compare@1.0.0: - resolution: {integrity: sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-expression-validate@2.0.0: - resolution: {integrity: sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg==} - - spdx-license-ids@3.0.24: - resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} - - spdx-ranges@2.1.1: - resolution: {integrity: sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==} - - spdx-satisfies@5.0.1: - resolution: {integrity: sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==} - starlight-llms-txt@0.12.0: resolution: {integrity: sha512-ya4c0ZoiPUXuHDzd3AYCKh78Y3II3EToSE9Yp6uVN0c2UUKoNpqTjijhW86W/4toZzSUhWSnxeYs7QBcXNIPGg==} peerDependencies: @@ -6911,9 +6711,6 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -7249,81 +7046,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.66.0': - optional: true - - '@rollup/rollup-android-arm64@4.66.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.66.0': - optional: true - - '@rollup/rollup-darwin-x64@4.66.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.66.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.66.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.66.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.66.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.66.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.66.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.66.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.66.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.66.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.66.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.66.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.66.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.66.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.66.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.66.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.66.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.66.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.66.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.66.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.66.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.66.0': - optional: true - '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@4.0.2': @@ -7947,8 +7669,6 @@ snapshots: aria-query@5.3.2: {} - array-find-index@1.0.2: {} - assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7(patch_hash=8f07585d066beb92a665ea78ec8bef87af3af32de9076c9e23122fbabd9fb475): @@ -8207,8 +7927,6 @@ snapshots: commander@14.0.3: {} - commenting@1.1.0: {} - common-ancestor-path@2.0.0: {} convert-source-map@2.0.0: {} @@ -8978,18 +8696,12 @@ snapshots: lodash.groupby@4.6.0: {} - lodash@4.18.1: {} - longest-streak@3.1.0: {} lru-cache@11.5.2: {} lru-cache@11.5.3: {} - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -9363,8 +9075,6 @@ snapshots: '@isaacs/nonrecursive-glob-match': 1.0.3 brace-expansion: 5.0.12 - moment@2.31.0: {} - mri@1.2.0: {} mrmime@2.0.1: {} @@ -9561,8 +9271,6 @@ snapshots: package-manager-detector@1.9.0: {} - package-name-regex@2.0.6: {} - pagefind@1.5.2: optionalDependencies: '@pagefind/darwin-arm64': 1.5.2 @@ -9818,52 +9526,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5 - rollup-plugin-license@3.7.1(picomatch@4.0.7)(rollup@4.66.0): - dependencies: - commenting: 1.1.0 - fdir: 6.5.0(picomatch@4.0.7) - lodash: 4.18.1 - magic-string: 0.30.21 - moment: 2.31.0 - package-name-regex: 2.0.6 - rollup: 4.66.0 - spdx-expression-validate: 2.0.0 - spdx-satisfies: 5.0.1 - transitivePeerDependencies: - - picomatch - - rollup@4.66.0: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.66.0 - '@rollup/rollup-android-arm64': 4.66.0 - '@rollup/rollup-darwin-arm64': 4.66.0 - '@rollup/rollup-darwin-x64': 4.66.0 - '@rollup/rollup-freebsd-arm64': 4.66.0 - '@rollup/rollup-freebsd-x64': 4.66.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.66.0 - '@rollup/rollup-linux-arm-musleabihf': 4.66.0 - '@rollup/rollup-linux-arm64-gnu': 4.66.0 - '@rollup/rollup-linux-arm64-musl': 4.66.0 - '@rollup/rollup-linux-loong64-gnu': 4.66.0 - '@rollup/rollup-linux-loong64-musl': 4.66.0 - '@rollup/rollup-linux-ppc64-gnu': 4.66.0 - '@rollup/rollup-linux-ppc64-musl': 4.66.0 - '@rollup/rollup-linux-riscv64-gnu': 4.66.0 - '@rollup/rollup-linux-riscv64-musl': 4.66.0 - '@rollup/rollup-linux-s390x-gnu': 4.66.0 - '@rollup/rollup-linux-x64-gnu': 4.66.0 - '@rollup/rollup-linux-x64-musl': 4.66.0 - '@rollup/rollup-openbsd-x64': 4.66.0 - '@rollup/rollup-openharmony-arm64': 4.66.0 - '@rollup/rollup-win32-arm64-msvc': 4.66.0 - '@rollup/rollup-win32-ia32-msvc': 4.66.0 - '@rollup/rollup-win32-x64-gnu': 4.66.0 - '@rollup/rollup-win32-x64-msvc': 4.66.0 - fsevents: 2.3.3 - rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -10007,33 +9669,6 @@ snapshots: space-separated-tokens@2.0.2: {} - spdx-compare@1.0.0: - dependencies: - array-find-index: 1.0.2 - spdx-expression-parse: 3.0.1 - spdx-ranges: 2.1.1 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.24 - - spdx-expression-validate@2.0.0: - dependencies: - spdx-expression-parse: 3.0.1 - - spdx-license-ids@3.0.24: {} - - spdx-ranges@2.1.1: {} - - spdx-satisfies@5.0.1: - dependencies: - spdx-compare: 1.0.0 - spdx-expression-parse: 3.0.1 - spdx-ranges: 2.1.1 - starlight-llms-txt@0.12.0(@astrojs/markdown-satteri@0.4.5)(@astrojs/starlight@0.42.7(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1))(supports-color@7.2.0)(typescript@5.9.3))(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1))(supports-color@7.2.0): dependencies: '@astrojs/mdx': 8.1.1(@astrojs/markdown-satteri@0.4.5)(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1)) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 104d7a1c8d..60a08c7d7c 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -98,11 +98,9 @@ test('bundling fails when a dependency has a license identifier but no license t await assert.rejects(bundle(root), /included@1\.0\.0 is missing its license or license text/); }); -// `rollup-plugin-license` reports `version: null` for a module whose `package.json` has none, which -// is what an unresolvable or path-linked module looks like to it. Before the version was checked, a -// fully licensed such dependency shipped a notice reading `included@null (MIT)`, attributing code to -// a release that never existed, and one missing its license too was reported as a license problem — -// sending the reader to the fixture's LICENSE file instead of to the version-less module. +// A licensed package without a version once shipped a notice reading `included@null (MIT)`, +// attributing code to a release that never existed. The version is checked first, so the failure +// names the version-less package rather than its license. test.each([ { name: 'no version field', version: undefined }, { name: 'blank version', version: ' ' }, @@ -121,10 +119,70 @@ test.each([ }); }); -// A module whose `package.json` carries neither a version nor a license identifier never reaches -// `renderNotices`: the plugin reports no dependencies for it at all, and the bundle gets the generic -// empty notice. That gap belongs to the publish gate, not to this formatter — see the -// `size-report-package` notices content test. +// The build enumerates the bundled modules itself. A package whose `package.json` names neither a +// version nor a license, or a module with no `package.json` at all, is still bundled code: beside a +// licensed neighbor it must stop the build, not drop out of a notices file that names the neighbor. +test.each([ + { + name: 'package with neither license nor version', + files: { + 'node_modules/omitted/package.json': JSON.stringify({ name: 'omitted', main: 'index.js' }), + 'node_modules/omitted/index.js': 'export const value = process.env.OMITTED;', + }, + failure: /Bundled dependency omitted is missing its version/, + }, + { + name: 'module with no package.json', + files: { 'node_modules/omitted/index.js': 'export const value = process.env.OMITTED;' }, + failure: + /Bundled module node_modules\/omitted\/index\.js has no package\.json naming its package/, + }, +])( + 'bundling rejects an unattributable module beside a licensed one: $name', + async (fixtureCase) => { + const root = await fixture({ + 'input.ts': [ + "export { value } from 'included';", + "export { value as omitted } from 'omitted';", + ].join('\n'), + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }), + 'node_modules/included/index.js': 'export const value = process.env.INCLUDED;', + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms.', + ...fixtureCase.files, + }); + await assert.rejects(bundle(root), fixtureCase.failure); + await assert.rejects(fs.stat(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt')), { + code: 'ENOENT', + }); + }, +); + +// The attribution block is one line per package, so manifest text that spans lines must not split it. +test('multiline license and author metadata render as one attribution line', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT\nOR Apache-2.0', + author: 'Multi\nline author ', + main: 'index.js', + }), + 'node_modules/included/index.js': 'export const value = process.env.INCLUDED;', + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms.', + }); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.equal( + notices.split('\n\n')[0], + 'included@1.0.0 (MIT OR Apache-2.0): [1]; Author: Multi line author ', + ); +}); test('identical notices are shared without losing packages or distinct copyrights', async () => { const sharedLicense = 'Copyright shared author\nFull shared license terms.'; diff --git a/scripts/__tests__/fixtures/bundled-license-build.mts b/scripts/__tests__/fixtures/bundled-license-build.mts index 09938d8aaa..69604fabd2 100644 --- a/scripts/__tests__/fixtures/bundled-license-build.mts +++ b/scripts/__tests__/fixtures/bundled-license-build.mts @@ -17,6 +17,7 @@ await build({ deps: { onlyBundle: [ 'included', + 'omitted', 'shared', 'distinct', 'unused', diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 8c1dfe14f1..4454ced1c5 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -56,7 +56,7 @@ test('publish package requires bundled dependency license notices', () => { // The publish gate's own copy of what a shipped notices file must contain. Written out rather than // produced by the bundler: a fixture the producer generates cannot show the producer emitted the wrong // thing. The attribution lines, the reference markers and the license bodies are the shape -// `renderNotices` in scripts/lib/bundled-license-notices.ts writes for a bundled build, and the +// `renderLicenseNotices` in scripts/size-report-package-notices.mjs writes for a bundled build, and the // placeholder below is the line that same function writes when the bundle holds no third-party code. const REAL_NOTICES = [ '@agent-device/proxy@0.21.37-dev (MIT): [1]', @@ -228,8 +228,11 @@ test('pack gate reads content without a tree, staying a manifest-only check', () assert.doesNotThrow(() => assertBundledLicenseNotices([noticesEntry()])); }); -/** A dependency the license plugin bundles, with the manifest fields that shape its attribution. */ -function dependencyManifest(name: string, fields: Record): [string, string][] { +/** A dependency the fixture build bundles, with the manifest fields that shape its attribution. */ +function dependencyManifest( + name: string, + fields: Record, +): [string, string][] { return [ [ `node_modules/${name}/package.json`, @@ -243,15 +246,16 @@ function dependencyManifest(name: string, fields: Record): [stri ]; } +type FixtureDependency = [string, Record, Record?]; + /** * Real renderer bytes: runs the same `bundledLicenseNotices` plugin the published build uses (through * the shared fixture build script) over a fixture tree, so the attribution lines the pack gate reads - * are the ones `renderNotices` writes — grouped SPDX field, author clause and all — rather than a - * hand-written approximation of them. + * are the ones `renderLicenseNotices` writes — grouped SPDX field, author clause and all — rather than a + * hand-written approximation of them. The build writes into the tree's `dist/src`, so the fixture + * root is the tree the gate reads, whether or not the build succeeded. */ -async function renderedNotices( - dependencies: [string, Record, Record?][], -): Promise { +async function bundledTree(dependencies: FixtureDependency[]): Promise { const root = await realpath(await mkdtempForTest('pack-gate-render-')); const files: [string, string][] = [ ['package.json', JSON.stringify({ name: 'fixture-project', type: 'module' })], @@ -273,6 +277,10 @@ async function renderedNotices( await mkdir(join(file, '..'), { recursive: true }); await writeFile(file, source); } + return root; +} + +async function buildTree(root: string): Promise { await execFileAsync( process.execPath, [ @@ -280,19 +288,37 @@ async function renderedNotices( join(import.meta.dirname, 'fixtures', 'bundled-license-build.mts'), root, 'false', - 'dist', + 'dist/src', ], { cwd: import.meta.dirname }, ); - const tree = await treeWithNotices( - await readFile(join(root, 'dist', 'THIRD_PARTY_NOTICES.txt'), 'utf8'), - ); - return tree; } +async function renderedNotices(dependencies: FixtureDependency[]): Promise { + const root = await bundledTree(dependencies); + await buildTree(root); + return root; +} + +test('a licensed dependency cannot carry an unattributable one past build and pack gate', async () => { + // The former license plugin reported only packages whose manifest named a version or a license, so a + // bundled `omitted` package with neither left a notices file that attributed its licensed + // neighbor, and the gate accepted it. The build enumerates bundled modules itself: it fails, writes + // no notices, and the gate then names the missing file instead of passing a partial one. + const root = await bundledTree([ + ['included', { license: 'MIT' }], + ['omitted', { version: undefined }], + ]); + await assert.rejects(buildTree(root), /Bundled dependency omitted is missing its version/); + assert.throws( + () => assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + /npm pack has no dist\/src\/THIRD_PARTY_NOTICES\.txt/, + ); +}); + test('pack gate accepts the attribution the renderer writes for a grouped SPDX expression', async () => { - // A `package.json` license may be a grouped SPDX expression, and `renderNotices` wraps whatever the - // plugin reports in its own parentheses, so the attribution carries nested ones. Those bytes are + // A `package.json` license may be a grouped SPDX expression, and `renderLicenseNotices` wraps the + // manifest's license in its own parentheses, so the attribution carries nested ones. Those bytes are // valid producer output: the gate has to read them, not report them as naming nobody. const root = await renderedNotices([ ['shared', { license: '(MIT OR Apache-2.0)', author: 'Grouped author ' }], @@ -612,8 +638,24 @@ test('base measurement uses the measured revision package asset policy', async ( .split(' - name: Save base dist cache')[0]!; assert.match( baseStep, - /git checkout --detach[\s\S]*cp scripts\/size-report-package\.mjs \/tmp\/agent-device-size-report\/[\s\S]*node \/tmp\/agent-device-size-report\/size-report\.mjs/, + /git checkout --detach[\s\S]*cp scripts\/size-report-package\*\.mjs \/tmp\/agent-device-size-report\/[\s\S]*node \/tmp\/agent-device-size-report\/size-report\.mjs/, + ); +}); + +// The size lane copies `scripts/size-report*.mjs` out of the checkout and recopies the measured +// revision's `scripts/size-report-package*.mjs`, then runs the copy with no node_modules. Each +// module the package policy imports must fall in both copies, and the notices format owner, which +// the build also imports, must import nothing. +test('the package policy and its notices format run from the standalone size-report copy', async () => { + const policy = await readFile(join(import.meta.dirname, '../size-report-package.mjs'), 'utf8'); + const localImports = [...policy.matchAll(/from '\.\/([^']+)'/g)].map((match) => match[1]); + assert.deepEqual(localImports, ['size-report-package-notices.mjs']); + for (const local of localImports) assert.match(local, /^size-report-package[^/]*\.mjs$/); + const format = await readFile( + join(import.meta.dirname, '../size-report-package-notices.mjs'), + 'utf8', ); + assert.doesNotMatch(format, /^\s*import\b|\bimport\(|\brequire\(/m); }); test.each(['1', -1, 0.5, null])('npm pack rejects malformed file sizes (%s)', (size) => { diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index 9dbf99946f..e390625fcb 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -1,100 +1,141 @@ import fs from 'node:fs'; import path from 'node:path'; -import license, { type Dependency } from 'rollup-plugin-license'; +import type { TsdownPlugin } from 'tsdown'; +import { renderLicenseNotices, type NoticeDependency } from '../size-report-package-notices.mjs'; -/** Reads extra notices from root's LICENSES directory and emits them beside the bundle. */ -export function bundledLicenseNotices(root: string): ReturnType { - let source: string | undefined; - const collector = license({ - cwd: root, - sourcemap: false, - thirdParty: { - multipleVersions: true, - output(dependencies) { - source = renderNotices(root, dependencies); - }, - }, - }); - // rollup-plugin-license 3.7.1 exposes synchronous dependency output without a bundler context. +type Manifest = { + name?: unknown; + version?: unknown; + license?: unknown; + author?: unknown; + private?: unknown; +}; + +/** + * Enumerates every module rendered into the bundle, attributes each third-party one to its package, + * and emits the notices beside the bundle. Extra notices are read from root's LICENSES directory. + */ +export function bundledLicenseNotices(root: string): TsdownPlugin { return { - ...collector, - async generateBundle(options, bundle, isWrite) { - source = undefined; - const hook = collector.generateBundle; - const generate = typeof hook === 'function' ? hook : hook?.handler; - await generate?.call(this, options, bundle, isWrite); - if (source === undefined) - this.error( - 'rollup-plugin-license did not report dependencies from generateBundle; expected its synchronous thirdParty.output callback.', - ); - this.emitFile({ type: 'asset', fileName: 'THIRD_PARTY_NOTICES.txt', source }); + name: 'bundled-license-notices', + generateBundle(_options, bundle) { + const packages = new Map(); + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') continue; + for (const [id, module] of Object.entries(output.modules)) { + if (module.renderedLength === 0) continue; + const owner = thirdPartyPackage(root, id); + if (owner) packages.set(owner.directory, owner.manifest); + } + } + const dependencies = Array.from(packages, ([directory, manifest]) => + attributableDependency(root, directory, manifest), + ); + this.emitFile({ + type: 'asset', + fileName: 'THIRD_PARTY_NOTICES.txt', + source: renderLicenseNotices(deduplicate(dependencies)), + }); }, }; } +function readManifest(directory: string): Manifest | undefined { + const file = path.join(directory, 'package.json'); + if (!fs.existsSync(file)) return undefined; + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) as Manifest; + return typeof manifest.name === 'string' && manifest.name ? manifest : undefined; +} + /** - * A bundled dependency must be attributable before it can be listed: `version` is optional to - * `rollup-plugin-license` and reports `null` for a module whose `package.json` has none, which is what - * an unresolvable or path-linked module looks like to it. Emitting `included@null (MIT)` would - * attribute code to a release that never existed, so the version is named first and every later - * diagnostic can name the package the way the notice file does. + * The package that owns a bundled module: the nearest `package.json` with a name. A `package.json` + * without one only marks a module type. Root and private workspace packages are this project's code. + * A module under `node_modules` must find its package before it leaves that `node_modules`. */ -function assertBundledDependencyAttributable(dependency: Dependency): void { - if (!dependency.version?.trim()) { - throw new Error(`Bundled dependency ${dependency.name} is missing its version.`); +function thirdPartyPackage( + root: string, + moduleId: string, +): { directory: string; manifest: Manifest } | undefined { + const file = moduleId.replace(/^\0/, ''); + if (!path.isAbsolute(file)) return undefined; + for (let directory = path.dirname(file); directory !== root;) { + const manifest = readManifest(directory); + if (manifest) { + const vendored = directory.split(path.sep).includes('node_modules'); + return manifest.private === true && !vendored ? undefined : { directory, manifest }; + } + const parent = path.dirname(directory); + if (path.basename(directory) === 'node_modules' || parent === directory) { + throw new Error( + `Bundled module ${path.relative(root, file)} has no package.json naming its package.`, + ); + } + directory = parent; } - if (!dependency.license || !dependency.licenseText?.trim()) { + return undefined; +} + +function readNamedFile(directory: string, prefix: RegExp): string | undefined { + const name = fs + .readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && prefix.test(entry.name)) + .map((entry) => entry.name) + .sort()[0]; + const text = name === undefined ? undefined : fs.readFileSync(path.join(directory, name), 'utf8'); + return text?.trim() ? text : undefined; +} + +function authorText(author: unknown): string | undefined { + if (typeof author === 'string') return author.trim() || undefined; + if (typeof author !== 'object' || author === null) return undefined; + const { name, email, url } = author as Record; + if (typeof name !== 'string' || !name) return undefined; + return `${name}${typeof email === 'string' && email ? ` <${email}>` : ''}${typeof url === 'string' && url ? ` (${url})` : ''}`; +} + +/** + * A bundled package must name its release, its license, and the license text before it is listed. + * The version is checked first so every later diagnostic names the package as the notices do. + */ +function attributableDependency( + root: string, + directory: string, + manifest: Manifest, +): NoticeDependency { + const name = String(manifest.name); + const version = typeof manifest.version === 'string' ? manifest.version.trim() : ''; + if (!version) throw new Error(`Bundled dependency ${name} is missing its version.`); + const license = typeof manifest.license === 'string' ? manifest.license.trim() : ''; + const licenseText = readNamedFile(directory, /^licen[cs]e/i); + if (!license || !licenseText) { throw new Error( - `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, + `Bundled dependency ${name}@${version} is missing its license or license text.`, ); } -} - -function renderNotices(root: string, dependencies: readonly Dependency[]): string { - if (dependencies.length === 0) return 'No third-party dependencies are bundled.\n'; - const texts = new Map(); - const reference = (text: string): string => { - const normalized = text.replaceAll('\r\n', '\n').trim(); - let id = texts.get(normalized); - if (id === undefined) { - id = texts.size + 1; - texts.set(normalized, id); - } - return `[${id}]`; - }; - const packages: string[] = []; - const sortedDependencies = [...dependencies].sort((left, right) => { - const a = `${left.name}@${left.version}`; - const b = `${right.name}@${right.version}`; - return a < b ? -1 : a > b ? 1 : 0; - }); - for (const dependency of sortedDependencies) { - assertBundledDependencyAttributable(dependency); - const references = [reference(dependency.licenseText)]; - if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); - if (dependency.name === 'jpeg-js') { - const sourceNotices = fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8'); - const noticeVersion = sourceNotices - .split(/\r?\n/, 1)[0] - ?.match(/^jpeg-js (\S+) source-file notices$/)?.[1]; - if (noticeVersion !== dependency.version) { - throw new Error( - `Refresh LICENSES/jpeg-js.txt for bundled jpeg-js@${dependency.version}; its first line must name that version.`, - ); - } - references.push( - reference(sourceNotices), - reference(fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')), + const texts = [licenseText]; + const noticeText = readNamedFile(directory, /^notice/i); + if (noticeText) texts.push(noticeText); + if (name === 'jpeg-js') { + const sourceNotices = fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8'); + const noticeVersion = sourceNotices + .split(/\r?\n/, 1)[0] + ?.match(/^jpeg-js (\S+) source-file notices$/)?.[1]; + if (noticeVersion !== version) { + throw new Error( + `Refresh LICENSES/jpeg-js.txt for bundled jpeg-js@${version}; its first line must name that version.`, ); } - packages.push( - `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}${dependency.author ? `; Author: ${dependency.author.text()}` : ''}`, - ); + texts.push(sourceNotices, fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')); } - // One trailing newline closes the last license block: the generic notice above carries one, and a - // reader who `cat`s the file or counts its lines sees a finished file either way. - const body = [packages.join('\n'), ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`)].join( - '\n\n', - ); - return `${body}\n`; + const author = authorText(manifest.author); + return { name, version, license, ...(author ? { author } : {}), texts }; +} + +/** One attribution per release: two installed copies of one `name@version` are one package. */ +function deduplicate(dependencies: readonly NoticeDependency[]): NoticeDependency[] { + return [ + ...new Map( + dependencies.map((dependency) => [`${dependency.name}@${dependency.version}`, dependency]), + ).values(), + ]; } diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts index 7bf6a4be68..0b359219c6 100644 --- a/scripts/lib/cli-entrypoint.test.ts +++ b/scripts/lib/cli-entrypoint.test.ts @@ -264,3 +264,42 @@ test.each([ } }, ); + +// The diagnostic for an early stdout failure is written while main is still pending. Behind a real +// stderr pipe nobody reads, that write never completes, and a pending main does not keep Node alive +// on its own: only the stalled write does. The diagnostic must end at its deadline so the process can +// exit with the failure it retained, instead of waiting on the reader forever. +test('an early diagnostic behind an unread stderr pipe cannot keep a pending command alive', async () => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + "const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: 'EIO' })); } });", + "Object.defineProperty(process, 'stdout', { value: output });", + 'setTimeout(() => process.exit(99), 4000).unref();', + "runEntrypoint('fixture', () => {", + " process.stderr.write('e'.repeat(4 * 1024 * 1024));", + ' process.send({ queuedBytes: process.stderr.writableLength }, () => process.disconnect());', + " process.stdout.write('trigger');", + ' return new Promise(() => {});', + '});', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + const exited = once(child, 'exit'); + try { + const [message] = await once(child, 'message'); + assert.ok( + message.queuedBytes > 0, + 'the diagnostic must queue behind output the unread pipe has not taken', + ); + const [code, signal] = await exited; + assert.equal(signal, null); + assert.equal(code, 1, 'the diagnostic deadline must win before the independent leak guard'); + } finally { + child.stderr.destroy(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index 1e68b9d70d..93df285d1a 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -9,14 +9,25 @@ function outputFailure(stream: NodeJS.WriteStream, error?: unknown): unknown { return cause != null && !isClosedPipe(cause) ? cause : undefined; } -function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise { +class OutputDeadlineExceeded extends Error { + constructor() { + super('output drain timed out'); + } +} + +/** + * The one owner of output completion: settles with the stream's failure once what was written to it + * has flushed, or with `OutputDeadlineExceeded` at `deadline`. + */ +function settleOutput(stream: NodeJS.WriteStream, deadline: number): Promise { return new Promise((resolve) => { const finish = (error?: unknown): void => { clearTimeout(timer); + stream.off('error', finish); resolve(outputFailure(stream, error)); }; const timer = setTimeout( - () => finish(new Error('output drain timed out')), + () => finish(new OutputDeadlineExceeded()), Math.max(0, deadline - Date.now()), ); if (stream.destroyed || !stream.writable) { @@ -33,29 +44,36 @@ function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise Promise): void { + let mainSettled = false; // A pending main promise does not keep Node alive; retain early failures for natural exit. const retainOutputFailure = (error: unknown): void => { if (!isClosedPipe(error) && !Number(process.exitCode)) process.exitCode = 1; }; - let stdoutDiagnosticAttempted = false; + let diagnostic: Promise | undefined; const reportStdoutFailure = (error: unknown): void => { if ( - stdoutDiagnosticAttempted || + diagnostic || process.stderr.destroyed || !process.stderr.writable || process.stderr.errored != null ) return; - stdoutDiagnosticAttempted = true; const message = error instanceof Error ? error.message : String(error); try { process.stderr.write(`${prefix}: unable to flush stdout: ${message}\n`); } catch (error) { retainOutputFailure(error); } + diagnostic = settleOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); + void diagnostic.then((failure) => { + if (!mainSettled && failure instanceof OutputDeadlineExceeded) { + process.exit(Number(process.exitCode) || 1); + } + }); }; process.stderr.on('error', retainOutputFailure); process.stdout.on('error', (error: unknown) => { @@ -63,16 +81,17 @@ export function runEntrypoint(prefix: string, main: () => Promise): void if (!isClosedPipe(error)) reportStdoutFailure(error); }); const exitAfterOutput = async (code: number): Promise => { + mainSettled = true; const deadline = Date.now() + OUTPUT_DRAIN_TIMEOUT_MS; const [initialStdoutError, initialStderrError] = await Promise.all([ - drainOutput(process.stdout, deadline), - drainOutput(process.stderr, deadline), + settleOutput(process.stdout, deadline), + settleOutput(process.stderr, deadline), ]); const stdoutError = initialStdoutError ?? outputFailure(process.stdout); let stderrError = initialStderrError ?? outputFailure(process.stderr); if (stdoutError != null && stderrError == null) { reportStdoutFailure(stdoutError); - stderrError = await drainOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); + stderrError = await diagnostic; } process.exit(code || (stdoutError != null || stderrError != null ? 1 : 0)); }; diff --git a/scripts/size-report-package-notices.d.mts b/scripts/size-report-package-notices.d.mts new file mode 100644 index 0000000000..765f57b18e --- /dev/null +++ b/scripts/size-report-package-notices.d.mts @@ -0,0 +1,14 @@ +// Type surface for the plain-Node notices format module; keep it in step with its exports. +export const NO_BUNDLED_DEPENDENCIES_NOTICE: string; +export type NoticeDependency = Readonly<{ + name: string; + version: string; + license: string; + author?: string; + texts: readonly string[]; +}>; +export function renderLicenseNotices(dependencies: readonly NoticeDependency[]): string; +export function readLicenseNotices(contents: string): Readonly<{ + attributions: readonly Readonly<{ label: string; references: readonly number[] }>[]; + licenseTextIds: ReadonlySet; +}>; diff --git a/scripts/size-report-package-notices.mjs b/scripts/size-report-package-notices.mjs new file mode 100644 index 0000000000..9ab2954184 --- /dev/null +++ b/scripts/size-report-package-notices.mjs @@ -0,0 +1,106 @@ +// The THIRD_PARTY_NOTICES.txt format: the build renders it and the pack gate reads it. It imports +// nothing, so the standalone size-report copy runs it without the repository's dependencies. + +export const NO_BUNDLED_DEPENDENCIES_NOTICE = 'No third-party dependencies are bundled.\n'; + +/** + * The heading that opens a numbered license section. Every attribution is written before the first + * one, so this line is also the boundary between the attributions and the license text. + */ +const LICENSE_SECTION_HEADING = /^\[(\d+)\]$/; +const AUTHOR_CLAUSE = '; Author: '; + +/** The attribution block is one line per package, so manifest text that spans lines is joined. */ +function singleLine(value) { + return value.replaceAll(/\s+/g, ' ').trim(); +} + +function normalizeText(text) { + return text.replaceAll('\r\n', '\n').trim(); +} + +/** + * Renders one `name@version (license): [n], …; Author: …` line per dependency, sorted by code unit, + * then each distinct text once under its `[n]` heading. + */ +export function renderLicenseNotices(dependencies) { + if (dependencies.length === 0) return NO_BUNDLED_DEPENDENCIES_NOTICE; + const texts = new Map(); + const reference = (text) => { + const normalized = normalizeText(text); + let id = texts.get(normalized); + if (id === undefined) { + id = texts.size + 1; + texts.set(normalized, id); + } + return `[${id}]`; + }; + const attributions = [...dependencies] + .sort((left, right) => { + const a = `${left.name}@${left.version}`; + const b = `${right.name}@${right.version}`; + return a < b ? -1 : a > b ? 1 : 0; + }) + .map( + (dependency) => + `${dependency.name}@${dependency.version} (${singleLine(dependency.license)}): ` + + dependency.texts.map(reference).join(', ') + + (dependency.author ? `${AUTHOR_CLAUSE}${singleLine(dependency.author)}` : ''), + ); + const sections = Array.from(texts, ([text, id]) => `[${id}]\n${text}`); + return `${[attributions.join('\n'), ...sections].join('\n\n')}\n`; +} + +/** + * The ids of the numbered sections that hold text. A section runs from its heading to the next + * heading, so a license body with blank lines keeps that body. A heading-shaped line inside a body + * only shortens its own section; it cannot fill an empty one. + */ +function readLicenseTextIds(lines) { + const idsWithText = new Set(); + let openId; + let seenText = false; + for (const line of lines) { + const heading = line.match(LICENSE_SECTION_HEADING); + if (heading) { + if (seenText) idsWithText.add(openId); + openId = Number(heading[1]); + seenText = false; + } else if (openId !== undefined && line.trim()) { + seenText = true; + } + } + if (seenText) idsWithText.add(openId); + return idsWithText; +} + +/** + * One attribution line, or undefined for any other line. The license field sits in the renderer's + * parentheses, so a grouped SPDX expression nests inside it and the field ends at the first `):`. + * References run to the author clause: an author holding `[n]` is metadata, not a reference. + */ +function readAttribution(line) { + const attribution = line.match(/^(\S+@\S+ \((.+?)\)): ?(.*)$/); + if (!attribution) return undefined; + const references = attribution[3].split(AUTHOR_CLAUSE, 1)[0]; + return { + label: attribution[1], + references: [...references.matchAll(/\[(\d+)\]/g)].map((reference) => Number(reference[1])), + }; +} + +/** + * Reads attributions only from the block before the first section: sections hold upstream license + * and NOTICE text verbatim, and prose there shaped like an attribution is not one. + */ +export function readLicenseNotices(contents) { + const lines = contents.replaceAll('\r\n', '\n').split('\n'); + const firstSection = lines.findIndex((line) => LICENSE_SECTION_HEADING.test(line)); + const attributionLines = firstSection === -1 ? lines : lines.slice(0, firstSection); + return { + attributions: attributionLines + .map(readAttribution) + .filter((attribution) => attribution !== undefined), + licenseTextIds: readLicenseTextIds(lines), + }; +} diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index e0a62a289d..fbf2628ead 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { readLicenseNotices } from './size-report-package-notices.mjs'; export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([ 'apple/snapshot-bridge/SnapshotBridge.m', 'apple/snapshot-bridge/SnapshotBridgeRuntime.m', @@ -72,69 +73,6 @@ export function collectNpmPack(root) { */ const BUNDLED_LICENSE_NOTICES_PATH = 'dist/src/THIRD_PARTY_NOTICES.txt'; -/** - * The heading that opens a numbered license section. The renderer writes every attribution it - * produces before the first one, so this line is also the boundary between the attributions the - * gate reads and the license text it preserves. - */ -const LICENSE_SECTION_HEADING = /^\[(\d+)\]$/; - -/** - * The ids of the numbered license sections that hold text. A section runs from its `[n]` heading to - * the next heading, so a license whose body carries blank lines keeps that body. A line shaped like a - * heading inside a license body only shortens the section it sits in; it cannot fill an empty one. - */ -function readLicenseTextIds(lines) { - const idsWithText = new Set(); - let openId; - let seenText = false; - for (const line of lines) { - const heading = line.match(LICENSE_SECTION_HEADING); - if (heading) { - if (seenText) idsWithText.add(openId); - openId = Number(heading[1]); - seenText = false; - } else if (openId !== undefined && line.trim()) { - seenText = true; - } - } - if (seenText) idsWithText.add(openId); - return idsWithText; -} - -/** - * The lines the attributions can live on: everything before the first numbered section, which is the - * block the renderer writes first. A section holds a dependency's license or NOTICE text verbatim — - * the renderer neither escapes nor indents it — so its content is upstream's, and prose there can - * carry a contact address or a pointer to "section [9]" shaped like an attribution line. Reading - * those as attributions would reject publishable bytes, and one such line would stand in for the - * real attributions a build failed to write. - */ -function readAttributionLines(lines) { - const firstSection = lines.findIndex((line) => LICENSE_SECTION_HEADING.test(line)); - return firstSection === -1 ? lines : lines.slice(0, firstSection); -} - -/** - * The `name@version (license): [n]` line the bundler writes, or undefined for any other line. The - * license field is whatever the plugin reports, wrapped in the renderer's own parentheses, so a - * grouped SPDX expression like `(MIT OR (Apache-2.0 AND CC0-1.0))` nests inside it: the field ends - * at the first `):` boundary, which only that closing parenthesis can reach. What follows the colon - * up to the renderer's optional `; Author: ` clause is the reference list — one package names - * several ids when it ships a NOTICE or source-file notices with its license. The author clause is - * attribution metadata: an author field holding a bracketed footnote must not read as a license - * reference, in either direction. - */ -function readAttribution(line) { - const attribution = line.match(/^(\S+@\S+ \((.+?)\)): ?(.*)$/); - if (!attribution) return undefined; - const references = attribution[3].split('; Author: ', 1)[0]; - return { - label: attribution[1], - references: [...references.matchAll(/\[(\d+)\]/g)].map((reference) => Number(reference[1])), - }; -} - function describeUnresolvedAttribution(attribution) { return ( `${attribution.label} -> ` + @@ -156,11 +94,7 @@ function describeUnresolvedAttribution(attribution) { * still gets the generic notice, and only this package is held to naming somebody. */ function assertLicenseNoticeContents(contents, context) { - const lines = contents.replaceAll('\r\n', '\n').split('\n'); - const licenseTextIds = readLicenseTextIds(lines); - const attributions = readAttributionLines(lines) - .map(readAttribution) - .filter((attribution) => attribution !== undefined); + const { attributions, licenseTextIds } = readLicenseNotices(contents); const unresolved = attributions.filter( (attribution) => attribution.references.length === 0 || From 50cc9f9f76e5ad5864e37fde1cfc48ef8d3572cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 07:37:41 +0100 Subject: [PATCH 26/26] fix(scripts): keep the notices format owner's exports to its consumers The empty-bundle notice is internal to the renderer, and the hand-written size-report-package-notices.d.mts joins the existing size-report-package.d.mts Fallow entry: its consumers resolve it through tsc, not an import edge. Co-Authored-By: Claude Opus 5.5 --- .fallowrc.json | 2 +- scripts/size-report-package-notices.d.mts | 1 - scripts/size-report-package-notices.mjs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 43f4763993..21d5b7eb20 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -75,7 +75,7 @@ // A hand-written type surface for a plain-Node .mjs the typechecked program (and Fallow's // graph) cannot import; its only consumer is the .mjs's real importers, resolved by tsc, not // by an import edge. Same shape as the help-conformance declaration files above. - "scripts/size-report-package.d.mts" + "scripts/size-report-package*.d.mts" ], "dynamicallyLoaded": [ "packages/daemon/src/execution/interaction/index.ts", diff --git a/scripts/size-report-package-notices.d.mts b/scripts/size-report-package-notices.d.mts index 765f57b18e..42d2b26324 100644 --- a/scripts/size-report-package-notices.d.mts +++ b/scripts/size-report-package-notices.d.mts @@ -1,5 +1,4 @@ // Type surface for the plain-Node notices format module; keep it in step with its exports. -export const NO_BUNDLED_DEPENDENCIES_NOTICE: string; export type NoticeDependency = Readonly<{ name: string; version: string; diff --git a/scripts/size-report-package-notices.mjs b/scripts/size-report-package-notices.mjs index 9ab2954184..d1546eb5b9 100644 --- a/scripts/size-report-package-notices.mjs +++ b/scripts/size-report-package-notices.mjs @@ -1,7 +1,7 @@ // The THIRD_PARTY_NOTICES.txt format: the build renders it and the pack gate reads it. It imports // nothing, so the standalone size-report copy runs it without the repository's dependencies. -export const NO_BUNDLED_DEPENDENCIES_NOTICE = 'No third-party dependencies are bundled.\n'; +const NO_BUNDLED_DEPENDENCIES_NOTICE = 'No third-party dependencies are bundled.\n'; /** * The heading that opens a numbered license section. Every attribution is written before the first