diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60f5fe1..1f7029b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,40 @@ jobs: - name: Test (unit + property + composition + F1) run: cargo test --all-targets + npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.21.0 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: denoland/setup-deno@v2 + + - name: Install JS deps (frozen) + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Build + typecheck (launcher) + run: pnpm -r build && pnpm -r typecheck + + - name: Launcher black-box suite (node) + run: node --test "tests/npm-launcher/*.test.mjs" + + - name: Release scripts suite + matrix agreement gate (deno) + run: | + cd scripts + deno task lint + deno task test + deno task check-matrix + mutation: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..06c7b94 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,365 @@ +# Release pipeline: tag-triggered publish of the five per-platform binary +# packages plus the root launcher, all with npm OIDC provenance (no static +# tokens). Version comes only from the tag (KTD5); platform packages are +# published before the root so the root's optionalDependencies resolve. +# +# Fork safety: `push: tags` — only users with push access can push tags. +# pull_request_target is intentionally not used. The npm trusted-publisher +# record binds workflow filename + environment, not tag patterns; the +# `on: refs/tags/v*` filter is the tag gate. +# +# Note: release flows from tags only; ci.yml gates the default branch. + +name: Release + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # One job per target in the matrix: build, gate, smoke, upload artifacts, + # publish the platform package with provenance (OIDC, no static token). + release: + name: release-${{ matrix.suffix }} + runs-on: ${{ matrix.runner }} + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + suffix: linux-x64 + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + suffix: linux-arm64 + runner: ubuntu-24.04-arm + - target: x86_64-apple-darwin + suffix: darwin-x64 + runner: macos-13 + - target: aarch64-apple-darwin + suffix: darwin-arm64 + runner: macos-14 + - target: x86_64-pc-windows-msvc + suffix: win32-x64 + runner: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # KTD2: the binary name comes from the canonical table, not a second + # copy of the win32->.exe rule. + - name: Resolve binary name from table + shell: bash + run: | + BINARY_NAME="$(jq -r --arg target "${{ matrix.target }}" '.[] | select(.target == $target) | .bin' scripts/release/targets.json)" + test -n "$BINARY_NAME" || { + echo "target ${{ matrix.target }} missing from scripts/release/targets.json" >&2 + exit 1 + } + echo "BINARY_NAME=$BINARY_NAME" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + targets: ${{ matrix.target }} + + - name: Linux arm64 cross-linker + # Only needed when building aarch64 from an x64 runner. The native + # ubuntu-24.04-arm runner builds aarch64 without a cross toolchain. + if: matrix.target == 'aarch64-unknown-linux-gnu' && matrix.runner == 'ubuntu-latest' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + { + echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" + echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" + } >> "$GITHUB_ENV" + + - name: Cache cargo + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('Cargo.lock') }} + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Install Deno (release scripts) + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 + + - name: Setup Node 24 (publish; npm >= 11.5.1 for trusted publishing) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + # NODE_AUTH_TOKEN intentionally unset — the registry-url wiring + # performs the OIDC exchange for trusted publishing. + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.21.0 + + - name: "Gate: check-matrix (table vs workflow agreement)" + run: | + deno run --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/release/check-matrix.ts + + - name: "Gate: binary exists" + shell: bash + run: | + test -f "target/${{ matrix.target }}/release/${BINARY_NAME}" + + - name: "Gate: binary smoke tests" + shell: bash + run: | + BIN="target/${{ matrix.target }}/release/${BINARY_NAME}" + echo '{"tool_name":"Write","tool_input":{"file_path":"src/client.py","content":"# SPDX-License-Identifier: Apache-2.0\ndef load(path):\n return open(path).read()\n"}}' | "$BIN" + rc=$? + test "$rc" -eq 0 || { echo "clean payload exited $rc, expected 0" >&2; exit 1; } + echo '{"tool_name":"Write","tool_input":{"file_path":"src/load_config.py","content":"def load_config(path):\n # Parse the config file\n data = json.load(open(path))\n # TODO: fix this later\n return data\n"}}' | "$BIN" >/dev/null 2>&1 + rc=$? + test "$rc" -eq 2 || { echo "flagged payload exit $rc, expected 2" >&2; exit 1; } + + - name: Stage platform package (outside workspace) + record binarySha256 + shell: bash + run: | + # Portable sha256: sha256sum (Linux/Windows Git Bash), else shasum + # (macOS). Must agree with the cross-check step's digest. + sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi + } + STAGE="$RUNNER_TEMP/platform-${{ matrix.suffix }}" + mkdir -p "$STAGE" + BIN="target/${{ matrix.target }}/release/${BINARY_NAME}" + cp "$BIN" "$STAGE/${BINARY_NAME}" + SHA="$(sha256_of "$STAGE/${BINARY_NAME}")" + deno run \ + --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ + --allow-write="$STAGE" \ + scripts/release/generate-platform-manifest.ts \ + --suffix "${{ matrix.suffix }}" \ + --version "${GITHUB_REF#refs/tags/v}" \ + --binary-sha256 "$SHA" \ + --out "$STAGE" + echo "$SHA" > "$RUNNER_TEMP/binary-${{ matrix.suffix }}.sha256" + echo "STAGE=$STAGE" >> "$GITHUB_ENV" + + - name: Upload tar.gz + sha sidecar + shell: bash + run: | + cd "target/${{ matrix.target }}/release" + tar -czf "$RUNNER_TEMP/comment-checker-${{ matrix.target }}.tar.gz" "${BINARY_NAME}" + + - name: Upload artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-${{ matrix.suffix }} + path: | + ${{ runner.temp }}/comment-checker-${{ matrix.target }}.tar.gz + ${{ runner.temp }}/binary-${{ matrix.suffix }}.sha256 + + - name: Publish platform package (OIDC provenance) + shell: bash + run: | + cd "$STAGE" + pnpm publish --provenance --access public --no-git-checks + + publish-npm-main: + name: Publish root launcher + needs: release + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: "Tag gate: derive VERSION from tag" + shell: bash + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "invalid tag semver: '$VERSION'" >&2 + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: "Tag gate: commit reachable from default branch" + shell: bash + run: | + DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)" + git fetch origin "$DEFAULT_BRANCH" --depth=1 + git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH" || { + echo "tag commit not an ancestor of $DEFAULT_BRANCH; refusing publish" >&2 + exit 1 + } + + - name: Setup Node 24 (npm >= 11.5.1 for trusted publishing) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + # No NODE_AUTH_TOKEN — OIDC trusted publishing only. + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.21.0 + + - name: Install Deno (release scripts) + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 + + - name: Download recorded binary sha sidecars + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-* + merge-multiple: true + path: sidecars + + - name: "Gate: every platform package published with table os/cpu/libc" + shell: bash + run: | + # Shape-normalize BOTH sides: npm view reports libc as an array + # ("libc":["glibc"]) while the table stores a bare string, and + # darwin/win32 rows have no libc at all. Comparing through the same + # normalization (libc as array, absent when null) makes the deep + # equality meaningful instead of always-true or always-false. + libc_norm='{os, cpu} + (if (.libc // null) != null then {libc} else {} end)' + SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" + test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } + for SUFFIX in $SUFFIXES; do + PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" + META="$(npm view "$PKG@$VERSION" version os cpu libc --json)" || { + echo "platform package $PKG@$VERSION missing" >&2 + exit 1 + } + EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu]} + (if (.libc // null) != null then {libc: [.libc]} else {} end)' scripts/release/targets.json)" + echo "$META" | jq -e -c --arg v "$VERSION" --argjson want "$EXPECTED" \ + '.version == $v and ('"$libc_norm"') == $want' >/dev/null || { + echo "$PKG@$VERSION mismatch: $(echo "$META" | jq -c '{version, os, cpu, libc}') want $EXPECTED" >&2 + exit 1 + } + echo "$SUFFIX ok" + done + + - name: Cross-check published tarballs vs recorded binary sha + shell: bash + run: | + # npm registry read-after-write is eventually consistent: the + # platform publish finished only seconds ago, so a first `npm pack` + # may still 404. Retry a bounded number of times before failing. + sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi + } + SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" + test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } + for SUFFIX in $SUFFIXES; do + SHA_RECORDED="$(cat "sidecars/binary-${SUFFIX}.sha256")" + PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" + TARBALL="" + for attempt in 1 2 3 4 5; do + TARBALL="$(npm pack "$PKG@${VERSION}" --pack-destination "$RUNNER_TEMP" 2>/dev/null | tail -1)" + if [ -n "$TARBALL" ] && [ -f "$RUNNER_TEMP/$TARBALL" ]; then + break + fi + echo "npm pack $PKG@$VERSION attempt $attempt/5 failed; retrying" >&2 + TARBALL="" + sleep 5 + done + if [ -z "$TARBALL" ]; then + echo "$PKG@$VERSION not retrievable from registry after 5 attempts" >&2 + exit 1 + fi + rm -rf "$RUNNER_TEMP/unpack" + mkdir -p "$RUNNER_TEMP/unpack" + tar -xzf "$RUNNER_TEMP/$TARBALL" -C "$RUNNER_TEMP/unpack" + BIN="$(find "$RUNNER_TEMP/unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | head -1)" + SHA_PUBLISHED="$(sha256_of "$BIN")" + if [ "$SHA_RECORDED" != "$SHA_PUBLISHED" ]; then + echo "binary sha mismatch for $SUFFIX: recorded $SHA_RECORDED, got $SHA_PUBLISHED" >&2 + exit 1 + fi + echo "$SUFFIX ok" + done + + - name: Build launcher (frozen) + run: | + pnpm install --frozen-lockfile --registry https://registry.npmjs.org + pnpm -r build + + - name: Sync root version + optionalDependencies from tag + shell: bash + run: | + # --allow-env required: VERSION arrives via the environment (KTD5: + # the git tag is the single version source). The deno.jsonc + # manifest:sync-root task declares the identical permission set. + VERSION="$VERSION" deno run \ + --allow-env \ + --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ + --allow-write=npm/packages/comment-checker/package.json \ + scripts/release/sync-root-version.ts + + - name: Publish root launcher (OIDC provenance) + shell: bash + run: | + cd npm/packages/comment-checker + pnpm publish --provenance --access public --no-git-checks + + - name: Verify root publish + exact pins + shell: bash + run: | + ROOT_META="$(npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" version optionalDependencies --json)" + echo "$ROOT_META" | jq -e -c --arg v "$VERSION" '.version == $v' >/dev/null || { + echo "root version mismatch: $(echo "$ROOT_META" | jq -c '.version')" >&2 + exit 1 + } + SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" + for SUFFIX in $SUFFIXES; do + echo "$ROOT_META" | jq -e --arg k "@systemfsoftware/claude-code-comment-checker-${SUFFIX}" --arg v "$VERSION" \ + '.optionalDependencies[$k] == $v' >/dev/null || { + echo "optional pin missing for $SUFFIX@$VERSION" >&2 + exit 1 + } + done + + upload-gh-release-assets: + needs: release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Download platform artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: release-assets + + - name: Attach tarballs to GitHub release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + files: release-assets/**/*.tar.gz \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..3ebcf6e --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "warn" + }, + "ignorePatterns": ["repos/**", "node_modules/**", "dist/**"] +} diff --git a/CONCEPTS.md b/CONCEPTS.md index 17c2077..a5ba0ce 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -42,6 +42,30 @@ A per-kind/precision-recall gate on the corpus that trips when a kind's classifier weakens — including a single-case kind that goes wrong — so a weakness in one kind cannot hide inside an aggregate F1 score. +## npm distribution + +### Launcher +The root npm package (`@systemfsoftware/claude-code-comment-checker`) whose +`bin` is the `comment-checker` shim. It resolves the host platform package by +identity at runtime and spawns the binary — the only package that declares a +bin. + +### Platform package +One per os-cpu pair (`-linux-x64`, `-darwin-arm64`, …), generated from +`scripts/release/targets.json`: ships only the compiled binary and its +manifest (`os`/`cpu`/`libc` fields, no `bin`). The launcher's +`optionalDependencies` pins all five to the release version. + +The committed launcher manifest never lists these packages as +`optionalDependencies` — pnpm cannot lock unpublished platform packages +(pnpm#3960), so the pins are injected from the targets table at publish +time; absence in-tree is expected, not a defect. + +### Release lane +A matrix row in the release workflow: one platform/arch build, gate, smoke, +and publish run on its native runner. Platforms publish before the launcher, +and the release cannot proceed if any lane fails. + ## Flagged ambiguities - "context" had been used for both the language (scope/position) and the diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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/README.md b/README.md index 9f30d41..17f5e22 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,48 @@ cargo install --git https://github.com/systemfsoftware/comment-checker --package Requires Rust 1.85+. Each [GitHub release](https://github.com/systemfsoftware/comment-checker/releases) also attaches `comment-checker-.tar.gz` tarballs for direct download. +## Publishing + +Releases are tag-triggered: pushing a tag `vX.Y.Z` to `main` runs +[`.github/workflows/release.yml`](.github/workflows/release.yml), which builds +all five target binaries, publishes the five platform packages and then the +root launcher — all with npm OIDC trusted publishing and provenance, no static +tokens in CI. The exact step sequence and per-package trusted-publisher bindings are documented +in the release plan +(`docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md`) and the +first-release checklist (`docs/publishing/first-release-checklist.md`). + +To release: + +1. Create the six [npm trusted-publisher + entries](https://docs.npmjs.com/generating-provenance-statements) with the + bindings below (one time). +2. Push a semver tag: `git tag v0.1.0 && git push origin v0.1.0`. +3. Watch CI; the root package is published only after every platform package + exists and its published binary matches the recorded sha256. +4. Verify post-publish: a fresh `pnpm dlx`/`npm i -g` install of the root + package, run the binary on Linux and on one non-Linux platform, and confirm + `npm view @systemfsoftware/claude-code-comment-checker provenance` shows + provenance. + +The npm trusted-publisher records bind the publishing identity to this +workflow — the registry-side record has no tag-pattern field, so +`refs/tags/v*` is enforced by the workflow's `on: push: tags` filter, never by +the registry-side record: + +| Package | Trusted publisher binding | +|---|---| +| `@systemfsoftware/claude-code-comment-checker` | Org: `systemfsoftware`, repo: `comment-checker`, workflow: `.github/workflows/release.yml` | +| `@systemfsoftware/claude-code-comment-checker-linux-x64` | same | +| `@systemfsoftware/claude-code-comment-checker-linux-arm64` | same | +| `@systemfsoftware/claude-code-comment-checker-darwin-x64` | same | +| `@systemfsoftware/claude-code-comment-checker-darwin-arm64` | same | +| `@systemfsoftware/claude-code-comment-checker-win32-x64` | same | + +The version pinned in the committed launcher manifest (`0.1.0`) may lag behind +releases by design — the git tag is the single version source; the release +workflow rewrites the published manifest. + ## Quick Start 1. Install (above). diff --git a/docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md b/docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md new file mode 100644 index 0000000..9475006 --- /dev/null +++ b/docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md @@ -0,0 +1,325 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +created: 2026-08-17 +updated: 2026-08-17 +deepened: 2026-08-17 +type: feat +--- + +# npm Distribution Release - Plan + +## Goal Capsule + +- **Objective:** Publish the first npm release of comment-checker — five platform binary packages plus a root launcher package, shipped by a tag-triggered GitHub Actions release pipeline with OIDC provenance, no install-time scripts, and verified installs. +- **Product authority:** User-directed. The user confirmed the distribution scope ("OK plan the release of this thing") on 2026-08-17; the distribution pattern (per-platform packages + `optionalDependencies`, Node launcher) was examined in-session and accepted. Publishing to npm under the `systemfsoftware` org is a human-controlled action per AGENTS.md — the pipeline stages everything; a human cuts the tag. +- **Open blockers:** None. npm trusted-publisher entries (one per package, six total) and the package names require one-time org-admin setup, owned by the first-release checklist (U5). +- **Stop conditions:** the plan is done when `v0.1.0` is live on npm with provenance, a fresh install on Linux and on one of macOS/Windows runs the real binary, and `--ignore-scripts` installs still work. + +--- + +## Summary + +Comment-checker is a Rust CLI distributed today via `cargo install` and GitHub-release tarballs. This plan lands the npm distribution layer built in `npm/packages/comment-checker/`: five per-platform binary packages named `@systemfsoftware/claude-code-comment-checker--`, and a root launcher package `@systemfsoftware/claude-code-comment-checker`. Package managers install exactly one platform package per machine; the Node launcher resolves and spawns it. The release pipeline rebuilds the un-merged `feat/npm-optional-dependencies` branch work with its gaps fixed: a tag-triggered GitHub Actions workflow publishing platform packages first, then the root, all with OIDC provenance and no static token. + +## Problem Frame + +A Claude Code hook distributed via npm must give each user a working native binary with zero friction and zero install-time execution. The previous distribution experiments (`feat/npm-optional-dependencies`, `feat/modern-npm-distribution-pnpm-11`) produced an implemented launcher and a draft release workflow but never merged: the current tree has no launcher manifest, no release workflow, and no published packages. The SOTA classifier plan (`docs/plans/2026-08-12-002-feat-sota-comment-adjudication-plan.md`) explicitly deferred the npm distribution layer to follow-up. The npm layer changes no Rust code; it makes the binary installable. + +**Premise for this release.** The audience is Claude Code hook users, and Claude Code runs on Node — every hook user therefore already has a Node runtime, while `cargo install` demands a Rust toolchain this audience commonly lacks. The existing paths (cargo install from git, GitHub tarballs) stay live for Rust users and for non-npm sinks; npm closes the gap for the primary audience at zero install-time code execution. The release decision is user-directed in-session; demand validation (download counts vs the cargo-install era, install-friction issues) is tracked as an open adoption signal after `v0.1.0` rather than a precondition. + +## Requirements + +**Packaging** + +- R1. Per-platform packages. Exactly one npm package per supported target, named `@systemfsoftware/claude-code-comment-checker--`, containing only the native binary and a manifest declaring `os`/`cpu` (`libc` on Linux, see KTD4). Binary name is `comment-checker` on POSIX, `comment-checker.exe` on Windows. +- R2. Root launcher package. `@systemfsoftware/claude-code-comment-checker` exposes `bin` → the built ESM launcher, ships only the built launcher in `files`, and runs no `postinstall` or any install-time script. +- R3. Install-time selection. A plain install on Linux x64 installs exactly `…-linux-x64` and yields a working binary. Supported: Linux (x64, arm64), macOS (x64, arm64), Windows (x64). + +**Release pipeline** + +- R4. Tag-triggered publish. Pushing tag `vX.Y.Z` builds the release binary for all five targets, publishes the five platform packages then the root package at version `X.Y.Z`, with `optionalDependencies` pinned to that exact version (never a range). +- R5. Trusted publishing. All publishes use OIDC (`id-token: write`) and npm provenance; no static token is stored in CI or repo. +- R6. GitHub release tarballs. The per-target `.tar.gz` archives remain attached to the release for non-npm users. + +**Verification** + +- R7. Every platform's binary is smoke-tested before publish; the published root is verified after publish; the launcher resolution is covered by automated tests. + +**Documentation** + +- R8. README install/publish claims match the live state after the first release, and maintainers have documented publish steps (tag flow, trusted-publisher setup, post-publish verification). + +## Key Decisions + +- KD1 — Ship the README-documented matrix (5 targets: Linux x64/arm64, macOS x64/arm64, Windows x64). No musl, no win32-arm64 in the first release; matches the README/FAQ, prior plan, and workflow drafts. +- KD2 — No install-time download or fallback. A missing binary fails cleanly with `BinaryNotFound` naming the platform. Chosen over a Sentry-style postinstall fallback: the fallback reintroduces network access and script execution at install time, and the launcher's current behavior already satisfies this. *(session-settled: user-approved — chosen over an install-time download fallback: keeps zero install-time network/script execution.)* +- KD3 — Provenance on every published package and no static tokens (OIDC identity from the Actions runner). +- KD4 — Distribution via per-platform packages + `optionalDependencies` in a root launcher package. *(session-settled: user-approved — chosen over single-package postinstall download and all-platforms-in-one-tarball: managers select one platform package natively, no install scripts.)* +- KD5 — The package entry is a Node launcher. *(session-settled: user-approved — chosen over Deno/Bun shims: npm/pnpm/yarn are Node programs, so Node is the one runtime the distribution channel guarantees.)* + +--- + +## Planning Contract + +### Key Technical Decisions + +- **KTD1 — Pin the Effect v4 RC deps to the vendored revision.** The launcher depends on `effect@4.0.0-rc.108` and `@effect/platform-node@4.0.0-rc.108` — the exact versions matching the `repos/effect` subtree — resolved from the npm registry by pnpm and bundled by tsdown from the installed packages (which ship TS source; `node:` built-ins stay external). The vendored subtree is reference only, never a build input. *(Alternative rejected: `file:`-linking the vendored subtree's packages into the pnpm workspace — pulls a multi-package upstream subtree into the workspace and inflates install.)* The published surface is the packed tarball, not the workspace build; the rehearsal verifies `npm pack` output per package (U3, Verification Contract). +- **KTD2 — One canonical targets table.** `scripts/release/targets.json` declares the five targets (triple, suffix, `os`/`cpu`/`libc`, bin name). The manifest generator and the workflow's matrix check consume it; a unit test keeps it consistent. The launcher derives package/group names by identity from `process.platform`/`process.arch`, so the only drift surface is the table vs the workflow matrix, closed by a workflow check step (`check-matrix`). +- **KTD3 — Release workflow shape.** `push: tags ['v*']` → one `release` job per target in a 5-row matrix (build, gate, smoke, tar.gz upload, then publish the platform package via `pnpm publish --provenance`), then `publish-npm-main` (`needs: release`) installs frozen lockfile, builds the launcher, syncs versions from the tag, and publishes the root; `upload-gh-release-assets` attaches tarballs. The un-merged draft workflow is precedent; this shape adds the pre-publish gate and smoke, and a tested version-sync script. +- **KTD4 — Linux packages declare `"libc": ["glibc"]`.** Without it, musl/Alpine users (also `linux-x64`) install the glibc binary and hit a loader crash at runtime instead of the intended clean `BinaryNotFound`. Modern npm and pnpm honor the `libc` field and skip a mismatched platform package. +- **KTD5 — The Git tag is the single version source.** The workflow rewrites launcher `version` and every `optionalDependencies` value to the tag version via a tested script; the committed manifest may lag behind (stays at `0.1.0` until the first bump). Full version automation is deferred. + +### High-Level Technical Design + +**Target matrix** + +| Target | Runner | Suffix | os | cpu | libc | Bin | +|---|---|---|---|---|---|---| +| `x86_64-unknown-linux-gnu` | ubuntu-latest | `linux-x64` | linux | x64 | glibc | `comment-checker` | +| `aarch64-unknown-linux-gnu` | ubuntu-latest + cross linker | `linux-arm64` | linux | arm64 | glibc | `comment-checker` | +| `x86_64-apple-darwin` | macos-13 | `darwin-x64` | darwin | x64 | — | `comment-checker` | +| `aarch64-apple-darwin` | macos-14 | `darwin-arm64` | darwin | arm64 | — | `comment-checker` | +| `x86_64-pc-windows-msvc` | windows-2022 | `win32-x64` | win32 | x64 | — | `comment-checker.exe` | + +**Release flow** + +```mermaid +flowchart LR + T["vX.Y.Z tag"] --> M["matrix job × 5 targets"] + M --> B["cargo build --release --target"] + B --> G["gate: binary exists + executable + smoke run"] + G --> A["upload tar.gz artifact"] + G --> P["publish platform pkg --provenance"] + A --> U["upload-gh-release-assets"] + P --> N["publish-npm-main (needs matrix)"] + N --> S["sync version + optionalDeps to tag"] + S --> R["publish root pkg"] + R --> V2["verify: npm view + fresh install"] +``` + +**Consumer path** + +```mermaid +flowchart LR + I["npm/pnpm install root pkg"] --> M2["manager matches os/cpu/libc"] + M2 --> P["installs exactly one platform pkg"] + P --> L["node dist/index.mjs"] + L --> RC["require.resolve(platform pkg/package.json)"] + RC --> S2["spawn binary, inherit stdio"] + RC -. not installed .-> E["BinaryNotFound: platform + arch + package"] +``` + +### Assumptions + +- The npm org admin pre-creates trusted-publisher entries for all six packages and the five package names are available; missing setup fails fast at publish. +- The `systemfsoftware` npm identity is authorized to publish all six names. The human who cuts the tag is the authority gate per AGENTS.md. +- `pnpm-workspace.yaml` (packages `npm/packages/*`) stays unchanged; the launcher resolves Effect from the registry pinned at KTD1. +- Node `>=18` is the launcher floor (matches `@effect/platform-node` engines); CI gate runs node 20; publish jobs run Node 24 (npm ≥ 11.5.1 required for trusted publishing). + +--- + +## Implementation Units + +### U1. Launcher package manifest + workspace lockfile + +**Goal:** Give `npm/packages/comment-checker/` a valid publishable manifest and make pnpm workspace build/typecheck/install work. + +**Requirements:** R2, R3. + +**Dependencies:** none. + +**Files:** +- `npm/packages/comment-checker/package.json` — create +- `npm/packages/comment-checker/tsdown.config.ts` — confirm the single ESM output builds from the Effect source with `node:` built-ins external; adjust only if the Effect source breaks the single-file build +- `pnpm-lock.yaml` — generate and commit the root lockfile (none exists today; a first non-frozen `pnpm install` produces it, and the CI `--frozen-lockfile` steps only hold once it is committed) +- `package.json` (root) — unchanged + +**Approach:** +- Author the manifest (it does not exist in this worktree; the `feat/npm-optional-dependencies` variant is precedent only): `name: "@systemfsoftware/claude-code-comment-checker"`; `bin: { "comment-checker": "./dist/index.mjs" }`; `files: ["dist"]`; `scripts: { "build": "tsdown", "typecheck": "tsc -p tsconfig.json --noEmit" }` (satisfies the root `pnpm -r build`/`typecheck`); no `postinstall`/`prepare`; no `private` field (the workspace root's `private: true` does not propagate); `dependencies`: `effect` + `@effect/platform-node` pinned `4.0.0-rc.108` (KTD1); `@types/node` as devDependency; `engines: { "node": ">=18" }` (verified at rehearsal — the floor is asserted until `dist` runs on Node 18, not inherited); `publishConfig: { access: "public", provenance: true }`; `optionalDependencies` listing the five suffixed names at the package version. +- Replace the hardcoded `version: "0.1.0"` in the launcher with a runtime read of the launcher's own `package.json` (via `createRequire`) so `comment-checker --version` can never drift from the published manifest (KTD5); `src/index.ts` is edited in U1, `sync-root-version.mjs` stays the only external writer. +- Generate the lockfile with a first non-frozen `pnpm install` pinned to the canonical registry (`--registry https://registry.npmjs.org`) at the workspace root; commit it; thereafter frozen installs in CI only — the lockfile is never regenerated arbitrarily. +- Verify `pnpm -r build` emits `dist/index.mjs` and `pnpm -r typecheck` passes with the Effect language-service rules intact. + +**Approach detail:** the launcher source already implements `optionalDepName(platform, arch)` returning `@systemfsoftware/claude-code-comment-checker--` — keep it; the manifest must list exactly those names. + +**Test scenarios:** +- Manifest assertions: no `postinstall`; `files` = `["dist"]`; `optionalDependencies` has five entries equal to the launcher's name convention; `publishConfig.provenance` true; `engines.node` = `">=18"`. +- `pnpm -r build` and `pnpm typecheck` succeed; `dist/index.mjs` exists. +- Running `node dist/index.mjs` on a machine without the platform package exits non-zero and prints `BinaryNotFound` naming the expected package. + +**Verification:** `pnpm -r build` + the `BinaryNotFound` smoke (until the platform package exists, that is the expected launcher behavior; at release it must not trip). Also: `pnpm lint` stays green once the new `scripts/` and `tests/` files exist (U2/U4 own that surface), and the built `dist/index.mjs` runs on Node 18 (rehearsal proves this — engines is asserted, not inherited). + +### U2. Release scripts and unit tests + +**Goal:** Extract platform-manifest generation and version sync into tested scripts behind `targets.json`, so the workflow is thin and verifiable without a real release. + +**Requirements:** R1, R4, R7. + +**Dependencies:** U1 (manifest shape); defines the five names U1 must list. + +**Files:** +- `scripts/release/targets.json` — five entries `({ target, suffix, os, cpu, libc?, bin })`. +- `scripts/release/generate-platform-manifest.mjs` — given a suffix and version, emits the platform package.json: name, version, description, license, repository, os/cpu, libc when the table says so, files `[bin]`, and `publishConfig: { access: "public", provenance: true }` (mirrors the launcher manifest so provenance attaches even if an operator publishes without the `--provenance` flag). Supports `--dry-run` (prints, does not write). +- `scripts/release/sync-root-version.mjs` — rewrites the launcher manifest's `version` and all `optionalDependencies` values from a `VERSION` env; validates the value against `^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$` and errors before any write on a malformed tag (no inline `node -e` JSON concatenation anywhere — the script is the only writer). Dry-run prints the diff. +- `scripts/release/check-matrix.mjs` — asserts the five table entries and the workflow matrix agree (os/cpu derivable from suffix by identity). +- `tests/release/release-scripts.test.mjs` — `node:test` suite. + +**Approach:** +- Dependency-free Node scripts; `node:test` runner (Node 18+ built-in), no new toolchain. +- `targets.json` is the single source for suffix → os/cpu/libc; `check-matrix` and the unit tests keep it consistent with the launcher's identity naming (KTD2). +- Brownfield note: neither `scripts/` nor `tests/` exists at repo root today — the entire `scripts/release/` and `tests/release/` trees are net-new; no precedent path is reused from the un-merged branch. + +**Test scenarios:** +- For each of the five table entries: `generate-platform-manifest` emits a manifest with correct os/cpu/libc/bin, name `@systemfsoftware/claude-code-comment-checker-`, `libc` present only for linux entries, `.exe` bin on win32. +- `--dry-run` leaves no files changed (tree identical before/after). +- `sync-root-version` with `VERSION=0.1.0` sets `version` + all five optionalDependencies to `0.1.0`; with `0.2.0` the only diff is the version fields. +- Malformed tag values (`v0.1.0"`, `0.1.0\n--provenance=false`, `not-a-version`) → `sync-root-version` exits non-zero and writes nothing (semver gate). +- Unknown suffix → error listing the five supported suffixes. +- `check-matrix` fails when a table entry's os/cpu is inconsistent with the identity convention; passes otherwise. + +**Verification:** `node --test tests/release` green. + +### U3. Release workflow + +**Goal:** The GitHub Actions pipeline that builds, gates, publishes all six packages, and attaches tarballs. + +**Requirements:** R1–R7. + +**Dependencies:** U1, U2. + +**Files:** +- `.github/workflows/release.yml` — create (supersedes the un-merged draft on `feat/npm-optional-dependencies`). +- `.github/workflows/ci.yml` — unchanged; note in the new file that release flows from tags, not from CI (which gates `main`). + +**Approach:** +- Workflow-level `permissions: {}` — nothing inherited; each job grants only what it needs. `release` matrix jobs: `id-token: write` (npm OIDC); `upload-gh-release-assets`: `contents: write` via the default `GITHUB_TOKEN` only (no PAT, no per-job secrets — re-stated in the DoD); `publish-npm-main`: `id-token: write`. +- `concurrency: { group: release-${{ github.ref }}, cancel-in-progress: false }` at workflow level — two simultaneous tag pushes must never race the publish (npm versions are immutable; cancelling mid-publish would strand a half-published root). `cancel-in-progress: false` is deliberate. +- Publish jobs use `actions/setup-node@v4` with `node-version: 24` (npm ≥ 11.5.1 is required for trusted-publishing OIDC) and `registry-url: https://registry.npmjs.org` — the registry URL is what wires the npm OIDC exchange; do NOT set `NODE_AUTH_TOKEN` (an empty token line overriding OIDC is the documented breakage). `dtolnay/rust-toolchain@stable` with the target; linux-arm64 installs `gcc-aarch64-linux-gnu` and sets `CC_aarch64_unknown_linux_gnu`/`CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER`/`AR_aarch64_unknown_linux_gnu` (env-var approach keeps the `.cargo/config.toml` `[env]` intact for TSLP_LINK_MODE=static). Cargo cache key includes the matrix target (`${{ runner.os }}-${{ matrix.target }}-cargo-…`) so linux x64/arm64 do not thrash one cache. +- Action pinning: every third-party action is declared by tag name in this plan for identification, and pinned to its full commit SHA in the workflow file (per DoD); the SHA → tag mapping lives in the U5 maintenance duty alongside a dependabot `github-actions` group so pins are updated deliberately. +- Tag-gate before any publish (top of `publish-npm-main` and re-checked in the matrix): derive `VERSION` from `${GITHUB_REF#refs/tags/v}` and reject anything failing the semver pattern (U2's gate) — exit non-zero before any write or publish. Also verify the tag's commit is reachable from the default branch (`git merge-base --is-ancestor `) so a stray tag can't publish un-reviewed content. +- Gate before platform publish (per target): `check-matrix` assertion; binary exists; binary is executable (POSIX); smoke — pipe the README's clean payload (expect exit 0) and one flagged payload (expect exit 2); record `binarySha256` (from `sha256sum` of the exact staged binary) into the generated manifest and upload a sidecar artifact. +- Publish platform: stage in a temp dir **outside** the workspace (`$RUNNER_TEMP/…`) with `generate-platform-manifest` + the binary; dependency-free manifests need no `pnpm install`, and staging out-of-tree avoids workspace-context surprises; then `pnpm publish --provenance --access public` from that dir. +- After the matrix, before the root publish, `publish-npm-main` re-verifies the registry: `npm view -@ version os cpu libc` for all five suffixes — equality with `targets.json`, plus the `npm pack` + `sha256sum` cross-check against the recorded `binarySha256`. Any mismatch fails the run before the root publish (the root's `optionalDependencies` must not point at missing packages). +- Then: `pnpm install --frozen-lockfile` (registry pinned), `pnpm -r build`, `VERSION=… node scripts/release/sync-root-version.mjs` (tested script; no inline `node -e`), `pnpm publish --no-git-checks --provenance --access public` from the launcher dir → verify root via `npm view` (exact optional pins + provenance available). +- `upload-gh-release-assets` (`needs: release`, `permissions: { contents: write }`): `actions/download-artifact@v4` with `pattern: release-*`, `merge-multiple: true`; attach via `softprops/action-gh-release@v2` (SHA-pinned). `actions/attest-build-provenance` for the tarballs is deferrable hardening. +- Inline comment on the `on:` block: fork safety derives from `push: tags` (only repo writers push tags); `pull_request_target` is intentionally not used; the trusted-publisher record binds workflow filename + environment, not tag patterns (the `on:` filter is the tag gate — see U5). + +**Test scenarios:** (unit) none — pure CI config; the behavioral contract is the rehearsal and first release in the Verification Contract. +**Acceptance (non-unit):** the gate must fail fast when the binary is missing; a non-semver tag must fail before any publish; the root must never be published when any platform package is absent or when a `binarySha256` cross-check fails (ordering gate). + +**Verification:** rehearsal per Verification Contract; first real tag publishes and the `npm view`/fresh-install checks pass. The packed tarball — not the workspace build — is the surface consumers install, so the rehearsal verifies the `pnpm publish --dry-run`/`npm pack` output per package; provenance is only provable on the real publish. + +### U4. Launcher integration tests + +**Goal:** Prove resolver+spawn end-to-end against fixtures — and pin the platform-name convention shared with the release tooling. + +**Requirements:** R2, R3, R7. + +**Dependencies:** U1 (build output). + +**Files:** +- `npm/packages/comment-checker/src/platform.ts` — extract pure helpers `platform`/`arch`-derived names (`optionalDepName(platform, arch)`, `binaryFileName(platform)`) that `index.ts` uses; add a second tsdown entry so tests can import `dist/platform.mjs` without executing the CLI. The module must import no Effect client (`effect`, `@effect/*`) — the `@effect/language-service` floatingEffect rule is error in this tsconfig and the module must stay pure. +- `tests/npm-launcher/launcher.test.mjs` — `node:test` black-box suite driving `node dist/index.mjs`. + +**Approach:** +- Black-box: fixture dir `node_modules/@systemfsoftware/claude-code-comment-checker-/` with a fake `package.json` + a shim `comment-checker` script (echoes args, exits with a chosen code); run under `NODE_PATH=` so `createRequire` resolution finds it. +- Negative: no fixture → `BinaryNotFound` with the exact package-name suffix, os, arch; non-zero exit. + +**Test scenarios:** +- Happy path: `node dist/index.mjs --prompt hello` spawns the shim with `--prompt hello` and passes its exit code through (0 and non-zero). +- Missing fixture: stderr names the platform package (`…-linux-x64`-style), `process.platform` value, and exit non-zero. +- `optionalDepName('win32','x64')` equals the win32 entry of `targets.json`'s suffix; `binaryFileName('win32')` = `comment-checker.exe`; `binaryFileName('linux')` = `comment-checker`. +- The five table entries' suffixes round-trip through `optionalDepName`/`binaryFileName` (KTD2). + +**Verification:** `node --test tests/npm-launcher` after `pnpm -r build`. + +### U5. Docs, publish how-to, first-release checklist + +**Goal:** README claims match reality, publishing instructions and one-time org setup documented. + +**Requirements:** R8. + +**Files:** +- `README.md` — reword the "pre-release" status once live; add a "Publishing" section: tag flow, verification steps, and the trusted-publisher binding table: six packages (`@systemfsoftware/claude-code-comment-checker` + the five suffix names), each bound to this repo's `Organization`/`Repository`, `Workflow Filename` = `.github/workflows/release.yml`, and (recommended) `Environment` = `npm-release`. npm's trusted-publisher form has no tag-pattern field — `refs/tags/v*` is enforced by the workflow's `on: push: tags:` filter, never by the registry-side record; the registry-side guard is a fixed workflow filename (any other workflow in this repo is rejected). +- First-release checklist (docs): a brand-new package name may need a one-time seed publish before its trusted-publisher record can be configured (npm docs; confirm and budget a human seed per name if needed); create the six trusted-publisher entries with the exact bindings above; confirm GitHub repo default workflow permissions are read-only; recommended GitHub Environment `npm-release` with required reviewers on the publish jobs (deferrable — if skipped, the convention is exact semver tags only); record the action SHA→tag mapping as a dependabot `github-actions` group maintenance duty; confirm no PAT exists in any release job (default `GITHUB_TOKEN` only); record the manual macOS + Windows fresh-install runs. +- `AGENTS.md` — Locked surface: propose in the release PR a one-line directory-map addition naming the release workflow under `.github/workflows/`. Do not add rules or restate existing boundaries there — the Human Approval Boundaries contract already gates publishing. + +**Test scenarios:** none — docs + one-time admin setup; `Test expectation: none -- documentation; the release rehearsal + cross-platform install checks verify the claims.` + +**Verification:** README publish/install sections match the U3 rehearsal output; DoD cross-platform check recorded. + +--- + +## Verification Contract + +Run in this order: + +| # | Check | Command / outcome | +|---|---|---| +| 1 | Repo gate | `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test --all-targets` (Rust side untouched) | +| 2 | JS lint | `pnpm lint` — new `scripts/`, `tests/` files stay lint-clean (no ignorePatterns widening) | +| 3 | Scripts unit | `node --test tests/release` (includes malformed-tag rejection) | +| 4 | Launcher build | `pnpm -r build` → `dist/index.mjs` + `dist/platform.mjs` | +| 5 | Launcher tests | `node --test tests/npm-launcher` | +| 6 | Frozen install | `pnpm install --frozen-lockfile` (root lockfile committed in U1; hash stable under frozen install) | +| 7 | Rehearsal | `generate-platform-manifest` per target (dry-run), `check-matrix`, engines floor on Node 18, `pnpm publish --dry-run`/`npm pack` in a copy of each platform dir + root (registry pinned); record binary `binarySha256` values; Node-24 OIDC path noted as provable only on the real publish | +| 8 | First publish | `v0.1.0` tag → six packages at `0.1.0`; `npm view` per suffix shows `version` + `os`/`cpu`/`libc` equal to `targets.json`, root shows exact optional pins; per-platform `npm pack` re-fetch + `sha256sum` cross-check passes; provenance visible on npm | +| 9 | Fresh install | `pnpm dlx`/`npm i -g` the published package and run the binary on Linux (CI) and manually on macOS + Windows (before DoD) | +| 10 | `--ignore-scripts` | install with scripts disabled; npm logs must not report any executed lifecycle script, and the binary still runs | + +## Definition of Done + +- [ ] `npm/packages/comment-checker/package.json` + lockfile live; frozen install and build green (U1). +- [ ] Release scripts + `targets.json` + green `node:test` (U2). +- [ ] `release.yml` replaces the draft; pre-publish gate + smoke in place (U3). +- [ ] Launcher tests green; `platform.ts` extracted (U4). +- [ ] Docs and first-release checklist up (U5). +- [ ] `v0.1.0` live: six packages with provenance, exact `optionalDependencies` for the tag; fresh install runs the real binary on Linux and on ≥1 of macOS/Windows (second platform recorded manually). +- [ ] No static token anywhere in CI assets; `id-token` only; workflow-level `permissions: {}` with per-job grants; every third-party action pinned by full commit SHA (SHA→tag mapping in U5); default `GITHUB_TOKEN` is the only repo token; no PAT. +- [ ] Publish jobs run Node 24 (npm ≥ 11.5.1) with `registry-url: https://registry.npmjs.org` and no `NODE_AUTH_TOKEN`; workflow `concurrency` group present; tag-reachability check in the release gate. +- [ ] Version sync runs only through `sync-root-version.mjs` (no inline `node -e`); malformed-tag rejection is tested; the launcher reads its version at runtime (no hardcoded literal). +- [ ] Per-suffix registry verification (`npm view` of version/os/cpu/libc) passes for all five platform packages before the root publish. +- [ ] Root lockfile committed in U1 (generated against the canonical registry) and stable under frozen install in CI. +- [ ] Binary `binarySha256` recorded pre-publish and cross-checked against the published packages (U3). +- [ ] Cleanup: no scratch repos/temp staging dirs (gitignored `npm/bin` and temp dirs) left in the diff. + +## Risks & Dependencies + +- **musl / Alpine loader crash** — without the `libc` tag (KTD4), musl machines install the glibc binary and crash instead of a clean error. Gated by a U2 test on the linux entries. +- **Non-atomic publish race** — platform-first ordering + `needs: release` on the root publish prevents the root depending on packages that did not publish; a workflow-level `concurrency` group (per tag) prevents two tags racing; `binarySha256` cross-check catches a wrong-binary publish within one run. On a wrong-binary catch or duplicate-version E409, the recovery is `npm deprecate @` + ship the fix as the next tag (versions are immutable; no re-publish). +- **Wrong-binary published despite checks** — the `npm pack` + `sha256sum` audit runs on the same runner that just published (self-trusting); recorded `binarySha256` sidecars mitigate drift, and a separate clean-runner audit job is hardening (decided later). Document the deprecate-and-bump escape hatch in the U5 checklist. +- **Malformed tag / version injection** — a hostile or mistyped tag is rejected by the semver gate before any write or publish (U2/U3); the version writer is `sync-root-version.mjs`, never inline JSON concatenation. +- **Stray tag on an unreviewed commit** — tag-reachability check (commit must be an ancestor of the default branch) in the release gate; mitigates a writer tagging an unreviewed commit. +- **Actions supply chain** — every third-party action pinned by full commit SHA; workflow defaults `permissions: {}`; GitHub repo default is read-only; SHA→tag mapping maintained via a dependabot `github-actions` group. +- **npm OIDC/provenance quirks** — trusted publishing requires npm ≥ 11.5.1 (publish jobs run Node 24) and `registry-url` set without `NODE_AUTH_TOKEN`; pnpm 11.21.0 pinned; rehearsal exercises every runner job; Windows-runner OIDC + pnpm is the least-validated path and gets a dedicated rehearsal note; macOS 13 runner deprecation would need a darwin-x64 build fallback (cross-compile from macos-14 or a large runner) — re-check before the release. +- **Trusted-publisher setup friction** — six records must exist; a brand-new package name may need a one-time seed publish first (npm docs); bindings are repo + workflow filename + environment (no tag field); wrong or missing setup fails fast at first publish. +- **Version drift committed-vs-published** — by design; the tag is truth (KTD5); the launcher reads its version at runtime so `--version` never drifts; documented in README publishing notes. +- **Registry-side mutation of the pinned Effect rc** — the committed root lockfile hash (generated once in U1, never regenerated arbitrarily) plus `--frozen-lockfile` and an explicit `--registry` pin are the defense; the pin must be bumped together with a `repos/effect` subtree bump. + +## System-Wide Impact + +- **Supply chain:** OIDC-only credentials; the only token in the pipeline is the default `GITHUB_TOKEN` (no PAT, no per-job secrets); every published package carries provenance; workflow default permissions are none; GitHub repo default read-only. +- **CI:** release runs cost 7 runner-jobs (5 matrix + publish-npm-main + upload-gh-release-assets); rehearsal adds none. +- **Org boundary:** tab-cutting is the human gate (AGENTS.md Human Approval Boundaries). +- **Git-flow:** release from tags; note that `ci.yml` triggers on `main` while AGENTS.md names `master` — deferred. + +## Deferred to Follow-Up Work + +- musl/Alpine platform packages (the `libc` field is already wired). +- winarm64 and FreeBSD targets. +- Automated version management (changesets) — the tag is the single source today. +- CI e2e fresh-install tests on every runner post-publish (manual today). +- A separate clean-runner publish-audit job (registers `binarySha256` verification off the publishing runner) — hardening. +- Re-review of maintained Rust→npm release tooling (cargo-dist, napi-rs) at the first new platform or version-automation ticket; the hand-rolled pipeline stays unless that review changes the calculus. +- Deprecating the direct GH tarball path once the npm path is proven. +- Reconciling `ci.yml` `main` trigger with AGENTS.md `master` naming — resolve in the first release PR (or the release review notes the mismatch). + +## Sources & Research + +- `README.md` — distribution intent, install and FAQ contract. +- Prior un-merged work: `feat/npm-optional-dependencies` (draft workflow at `.github/workflows/release.yml`, launcher manifest at `npm/packages/comment-checker/package.json`, plan at `docs/plans/2026-08-12-001-feat-modern-npm-binary-distribution-pnpm-11-plan.md`); `feat/modern-npm-distribution-pnpm-11`. +- Web: Sentry "publishing binaries on npm"; napi-rs release docs (publish order, immutability, gates); mux CLI dist plan; npm trusted-publishing docs; pnpm 11.11–11.14 release notes (`libc`/`os`/`cpu` filters, `--no-optional` semantics); esbuild `optionalDependencies` precedent. +- In-repo conventions: `.cargo/config.toml` (TSLP static env), `Cargo.toml` release profile (opt-level 3, lto=fat, strip='symbols', codegen-units=1, panic=abort), `pnpm-workspace.yaml` packages glob, `.gitignore` (`npm/bin`, `dist` already reserved; docs under `docs/` root per CE conventions). \ No newline at end of file diff --git a/docs/publishing/first-release-checklist.md b/docs/publishing/first-release-checklist.md new file mode 100644 index 0000000..be42364 --- /dev/null +++ b/docs/publishing/first-release-checklist.md @@ -0,0 +1,100 @@ +# First npm Release Checklist + +The release pipeline (`.github/workflows/release.yml`) stages everything; this +checklist is the one-time org-admin setup plus the manual verification steps +that only a human with `systemfsoftware` access can run (AGENTS.md Human +Approval Boundaries). Work through it top to bottom. + +## 1. One-time org setup (npm + GitHub) + +- [ ] Confirm the GitHub repo default workflow permissions are read-only + (Settings → Actions → General → Workflow permissions → Read repository + contents and packages permissions). The workflow declares its own + minimal per-job grants on top. +- [ ] Create the six npm trusted-publisher entries + (`npm access` / web form on the npm org): + `@systemfsoftware/claude-code-comment-checker` plus the five platform + packages (`-linux-x64`, `-linux-arm64`, `-darwin-x64`, `-darwin-arm64`, + `-win32-x64`). Every entry binds to: + - Organization / Repository: `systemfsoftware` / `comment-checker` + - Workflow Filename: `.github/workflows/release.yml` + - Environment: `npm-release` (recommended; see note below) + npm's trusted-publisher form has **no tag-pattern field** — the + `refs/tags/v*` gate is enforced by the workflow's `on: push: tags` + filter, never by the registry-side record. +- [ ] Brand-new package names: npm may require a one-time seed publish before a + trusted-publisher record can be configured for a name that has never + existed. If the form rejects a name, seed-publish an empty placeholder + (`npm publish --access public` with a trivial tarball) once per name, + then configure the record. Budget a human seed per name if needed. +- [ ] Recommended: create a GitHub Environment named `npm-release` and add + required reviewers to the publish jobs. Deferrable — if skipped, the + convention is exact semver tags only. If added, the environment must be + referenced in `publish-npm-main`'s `environment:` key in release.yml. +- [ ] Confirm **no PAT** exists in any release job: only the default + `GITHUB_TOKEN` (for uploading release assets) and `id-token: write` for + npm OIDC provenance. Pull requests and tags must not carry secrets. + +## 2. Before the tag + +- [ ] `pnpm lint` and `deno task lint` green (repo gate). +- [ ] `node --test "tests/npm-launcher/*.test.mjs"` green (launcher black-box). +- [ ] `deno task test` green (release scripts; includes targets-table and + matrix checks). +- [ ] `pnpm install --frozen-lockfile` succeeds from a fresh clone, and + `pnpm -r build` + `pnpm -r typecheck` are green. +- [ ] `scripts/release/check-matrix.ts` passes with `.github/workflows/release.yml` + present: `deno run --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/release/check-matrix.ts` +- [ ] Cargo side (the release workflow runs its own build; there is no local + build requirement, but the Rust gate is `cargo fmt --check && cargo + clippy --all-targets -- -D warnings && cargo test --all-targets`). + +## 3. Tag and watch + +- [ ] Confirm the tag commit is an ancestor of the default branch (the + workflow enforces this; also true by construction for the merge). +- [ ] `git tag v0.1.0 && git push origin v0.1.0`. +- [ ] Watch the release run: five `release-*` matrix jobs (one per target), + `publish-npm-main`, `upload-gh-release-assets`. Each `release-*` job + gates on check-matrix, binary existence, smoke (exit 0 clean / exit 2 + flagged), records the binary sha256 sidecar, and publishes its platform + package with provenance. +- [ ] The run fails fast if any gate trips; the root is never published when a + platform package is missing or its published binary sha256 differs. + +## 4. Post-publish verification + +- [ ] `npm view @systemfsoftware/claude-code-comment-checker@v0.1.0 version` is + exactly `0.1.0`, and `optionalDependencies` pins all five platform + packages at `0.1.0` exactly (never a range). +- [ ] Per suffix: `npm view @systemfsoftware/claude-code-comment-checker-@0.1.0` + shows `version: 0.1.0` and `os`/`cpu`/`libc` equal to + `scripts/release/targets.json` (workflow already gates this; re-check by + hand here). +- [ ] Provenance visible: `npm view @systemfsoftware/claude-code-comment-checker@0.1.0 provenance` (npmjs.org shows the OIDC origin). +- [ ] Fresh install on Linux (CI simulates): `pnpm dlx @systemfsoftware/claude-code-comment-checker` or `npm i -g` and run the hook binary with a clean and a flagged payload. +- [ ] Manual fresh install on macOS and on Windows (record both runs' outputs). + +## 5. Sanctions and escape hatches + +- [ ] Wrong binary / duplicate version recovery: npm versions are immutable — + do **not** force-republish. Use `npm deprecate @` and ship + the fixed binary as the next tag. +- [ ] Amending an already-published version (rolling back) is not possible; + the tag-reachability gate prevents stray tags from publishing + unreviewed commits, but the final guard is the human before `git push` + of the tag. + +## 6. Maintenance duty (recorded, one-off) + +- [ ] The workflow pins every third-party action to a full commit SHA. Keep + the SHA→tag mapping fresh via a dependabot `github-actions` group so + pins are updated deliberately, never silently. + +## 7. After the first release + +- [ ] README: remove the "pre-release" status note under Install and keep the + npm install command as primary. +- [ ] Record actual download counts vs the cargo-install era as the adoption + signal (open item; not a release precondition). +- [ ] Optionally deprecate the direct GitHub tarball path once npm is proven. \ No newline at end of file diff --git a/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md new file mode 100644 index 0000000..d8514ae --- /dev/null +++ b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md @@ -0,0 +1,236 @@ +--- +title: Distributing a compiled Rust CLI as per-platform npm packages +date: 2026-08-17 +category: architecture-patterns +module: npm distribution (npm/packages/comment-checker + scripts/release + .github/workflows/release.yml) +problem_type: architecture_pattern +component: tooling +severity: medium +applies_when: + - Distributing a CLI compiled from Rust (or another compiled language) as npm packages to Linux, macOS, and Windows consumers + - The binary must arrive as a plain dependency with no postinstall build or download step + - Consumers or CI install with a frozen lockfile (pnpm) while the platform packages are unpublished until tag time + - The npm org supports trusted publishing so release credentials can be OIDC-only + - Native runners are available in CI for each platform/arch lane +tags: + - npm-distribution + - optional-dependencies + - platform-packages + - rust-cli + - oidc-provenance + - github-actions + - pnpm + - release-pipeline +--- + +# Distributing a compiled Rust CLI as per-platform npm packages + +## Context + +comment-checker is a Rust CLI shipped as a Claude Code hook; the npm +distribution must drop a working binary on every consumer's machine with +`npm i -g` / `npx` — no postinstall build, no download step. One package cannot +serve linux (x64 + arm64, glibc), darwin (x64 + arm64), and win32 x64 from a +single artifact, so the release surface is six packages: a root launcher plus +five per-platform binary packages. That shape creates two hard constraints: + +1. **pnpm cannot lock unresolvable optional deps (pnpm#3960).** The platform + packages do not exist in the registry until publish time, so a committed + launcher manifest that names them in `optionalDependencies` breaks + `pnpm install --frozen-lockfile` for every developer and CI run. The + committed manifest must stay clean; the pins are injected at publish time + (`scripts/release/sync-root-version.ts:18-22`). +2. **Six packages by hand is exactly what a human gets wrong.** The pipeline + must be tag-triggered (version = tag), run the same build → gate → smoke → + publish sequence on every tag, publish platforms before the root, and fail + loudly instead of shipping an absent or wrong-arch binary. + +## Guidance + +1. **Launcher resolves its platform package by identity at runtime.** + `npm/packages/comment-checker/src/platform.ts` defines two pure helpers: + `optionalDepName(platform, arch)` returns + `--`; `binaryFileName(platform)` returns + `comment-checker.exe` on win32, else `comment-checker`. The launcher + (`npm/packages/comment-checker/src/index.ts`) resolves the platform + package's own `package.json` via `createRequire` and joins the binary name + to its directory. Missing package surfaces as a + typed `BinaryNotFound` naming the package; a spawn-time ENOENT would be a + corrupt install npm would not have produced. +2. **One canonical targets table.** `scripts/release/targets.json` is the + single source of truth: five entries, each `{target, suffix, os, cpu, + libc?, bin}`. Everything else consumes the table instead of re-deriving + the platform set — the workflow resolves the per-lane binary name with + `jq` rather than duplicating the win32→`.exe` rule, + `generate-platform-manifest.ts` rejects unknown suffixes against the table, + and `check-matrix.ts` builds the agreement tests from it. +3. **Platform manifests are generated, cheap, and carry no `bin`.** + `generate-platform-manifest.ts` renders each platform `package.json`: name + = launcher name + `-`, `os`/`cpu`/`libc` from the table, + `files: [entry.bin]`, and **no `bin` field** — a platform-level bin would + create a top-level `comment-checker` shim colliding with the launcher's own + (esbuild precedent, comment at lines 60-62). `binarySha256` is recorded + into the manifest when the caller passes it. +4. **The committed launcher manifest carries NO `optionalDependencies`.** + pnpm cannot record unresolvable optional deps in a lockfile, so listing + unpublished platform packages breaks frozen installs. `sync-root-version.ts` + validates `VERSION` (strict semver regex, before any write), then injects + `version` plus the five pins from `targets.json`, preserving the manifest's + own formatting so an unchanged sync is byte-identical; `--dry-run` prints an + LCS diff. +5. **Gate the matrix, not the script.** `check-matrix.ts` names the product + platform set (`EXPECTED_SUFFIXES`, five entries — the known set, not a copy + of the table), then checks three agreements: the table names exactly that + set; the launcher manifest pins match the table exactly when present; and + the workflow matrix rows match the table triples in both directions — + missing, extra, and swapped `target`/`suffix` pairs are all failures, and + the failure arms are tested (`tests/release/release-scripts.test.ts`). +6. **Release pipeline: one lane per platform, platforms before root.** + `.github/workflows/release.yml` triggers only on `push: tags: v*` with + `permissions: {}` at the top. Five matrix lanes, `fail-fast: false`, each: + build → `check-matrix` gate → binary-exists gate → in-lane smoke (exit 0 + for a clean payload, 2 for a flagged one) → stage the platform package + outside the workspace in `$RUNNER_TEMP` plus a binary sha256 sidecar → + `pnpm publish --provenance` (OIDC, no `NODE_AUTH_TOKEN`, npm ≥ 11.5.1) → + upload tarball + sha sidecar. The root job `publish-npm-main` needs all + lanes, re-derives `VERSION` from the tag, requires the tag commit to be an + ancestor of the default branch, verifies every published platform + package's `version`/`os`/`cpu`/`libc` against the table, cross-checks the + published tarballs' binary sha against the recorded sidecars, builds + frozen, runs `sync-root-version.ts` with `VERSION` from the environment, + publishes the root, and verifies the root's five pins are exact version + pins. A final job attaches the tarballs to the GitHub release. +7. **Humans own one-time trust setup only.** OIDC trusted publishing is the + no-token story: the npm trusted-publisher record binds workflow filename + + environment (the npm form has no tag-pattern field), so the `tags: v*` + filter is the tag gate and `pull_request_target` is deliberately unused. + `docs/publishing/first-release-checklist.md` covers the six trusted- + publisher records and post-publish manual spot checks. + +## Why This Matters + +- **The pnpm failure mode is a landmine, not an annoyance.** The moment + someone adds `optionalDependencies` naming the platform packages to the + committed manifest, every `pnpm install --frozen-lockfile` — developers and + CI alike — breaks because the packages don't exist yet (pnpm#3960). It is + caught by `tests/npm-launcher/manifest.test.mjs` (asserts + `optionalDependencies` undefined pre-publish) and by `check-matrix.ts`'s + absence-is-expected branch. +- **Version skew is structurally impossible at the consumer.** The root pins + each platform package to the exact tag version (verified against the + registry at release time). Because the root is published after the + platforms, a consumer's install either gets the pinned, gated binary or + fails to resolve — no in-between state. +- **The silent gate failure modes were observed, so the gates are shaped + against them.** (a) `jq` libc shape: `npm view` reports `libc` as an array + (`["glibc"]`) while the table stores a bare string, and darwin/win32 rows + have no `libc` at all — a naive compare is always-true or always-false, so + the workflow normalizes both sides before deep equality. (b) Cross-arch + smoke: the smoke only proves anything on the lane's own native runner; + each matrix row maps target→runner (arm64 lanes use an ARM runner). (c) + `--allow-env`: `VERSION` arrives via the environment, and `deno run` is + deny-by-default, so a dropped flag fails at tag time, not at PR time; the + release-scripts suite replays the declared permission flags from the + committed files so drift fails in tests, not on a release. +- **No static token exists anywhere.** Publishing is OIDC-only; exit-code + propagation from the spawned binary is contract-tested, including the + shebang + exec bit npm's POSIX bin shim requires. + +## When to Apply + +- **Apply:** any compiled CLI (Rust, Go, C) distributed as an npm `bin` to + heterogeneous consumers — especially when you want `npm i -g` / `npx` to + just work, you have native CI runners per platform, and the npm org supports + trusted publishing. +- **Avoid when:** single-platform or single-arch tooling (one package with + `files`, no matrix); N-API addons (in-process bindings via `process.dlopen` + are a different architecture — no launcher spawn, no platform shim); a + binary that must be compiled on the consumer machine (postinstall builds + are their own failure mode). +- **Trust prerequisites:** OIDC trusted publishing is a hard dependency of + the no-token story, and the npm org needs one trusted-publisher record per + package name; brand-new names may require a seed publish before the record + can be configured (`docs/publishing/first-release-checklist.md`). + +## Examples + +`npm/packages/comment-checker/src/platform.ts` — the platform surface is two +pure helpers: + +```ts +export const binaryFileName = (platform: string): string => + platform === "win32" ? "comment-checker.exe" : "comment-checker" + +export const optionalDepName = (platform: string, arch: string): string => + `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}` +``` + +`scripts/release/targets.json` — the table is the platform contract; every +entry carries `os`/`cpu`/`libc` consumed by manifest generation, the +workflow's binary-name resolution, and the registry gate: + +```json +{ + "target": "x86_64-unknown-linux-gnu", + "suffix": "linux-x64", + "os": "linux", + "cpu": "x64", + "libc": "glibc", + "bin": "comment-checker" +} +``` + +`scripts/release/sync-root-version.ts` — the inject-at-publish move that +keeps the committed manifest frozen-install-clean while the published root is +fully pinned: + +```ts +manifest.optionalDependencies = Object.fromEntries( + targets.map((entry) => [`${manifest.name}-${entry.suffix}`, version]), +) +``` + +`scripts/release/check-matrix.ts` — the product policy the table must name: + +```ts +const EXPECTED_SUFFIXES = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'] +``` + +`.github/workflows/release.yml` — version comes only from the tag, and the +tag commit must be an ancestor of the default branch before anything +publishes: + +```yaml +- name: "Tag gate: derive VERSION from tag" + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "invalid tag semver: '$VERSION'" >&2 + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" +``` + +The binary-sha cross-check records a sha256 sidecar per lane at build time, +then re-packs the published tarball from the registry and recomputes the +digest — the gate that catches a wrong or swapped binary being published. + +## Related + +- Pipeline: `.github/workflows/release.yml` +- Platform table: `scripts/release/targets.json` +- Scripts: `scripts/release/generate-platform-manifest.ts`, + `scripts/release/sync-root-version.ts`, `scripts/release/check-matrix.ts` +- Human gate: `docs/publishing/first-release-checklist.md` +- Tests: `tests/release/release-scripts.test.ts` (targets, manifest emission, + sync, check-matrix failure arms incl. missing/extra/swapped workflow rows, + permission fidelity); `tests/npm-launcher/manifest.test.mjs` (no committed + optionalDependencies); `tests/npm-launcher/launcher.test.mjs` (black-box + spawn, exit-code propagation) +- pnpm#3960 — the constraint that makes listing optional deps a + frozen-lockfile landmine +- Residual advisories (open GitHub issues on this repo): #3 force-pushed tag + gate; #4 platform peerDependencies cross-link; #5 concurrency group vs + force-moved tags; #6 smoke exit-code contract; #7 sha sidecar self-trust; + #8 check-matrix regex-scrape fragility; #9 no actionlint / workflow YAML + validation in CI \ No newline at end of file diff --git a/npm/packages/comment-checker/package.json b/npm/packages/comment-checker/package.json new file mode 100644 index 0000000..d2224af --- /dev/null +++ b/npm/packages/comment-checker/package.json @@ -0,0 +1,37 @@ +{ + "name": "@systemfsoftware/claude-code-comment-checker", + "version": "0.1.0", + "description": "Claude Code PostToolUse hook that blocks unnecessary comments (npm distribution launcher)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/systemfsoftware/comment-checker.git" + }, + "type": "module", + "bin": { + "comment-checker": "./dist/index.mjs" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@effect/platform-node": "4.0.0-rc.108", + "effect": "4.0.0-rc.108" + }, + "devDependencies": { + "@types/node": "latest", + "tsdown": "latest", + "typescript": "latest" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} \ No newline at end of file diff --git a/npm/packages/comment-checker/src/index.ts b/npm/packages/comment-checker/src/index.ts new file mode 100755 index 0000000..bd855d5 --- /dev/null +++ b/npm/packages/comment-checker/src/index.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import { createRequire } from "node:module" +import { Data, Effect, Option, Path } from "effect" +import { NodeRuntime, NodeServices } from "@effect/platform-node" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { Command, Flag } from "effect/unstable/cli" +import { binaryFileName, optionalDepName } from "./platform.js" + +const require = createRequire(import.meta.url) + +class BinaryNotFound extends Data.TaggedError("BinaryNotFound")<{ + readonly platform: string + readonly arch: string + readonly package: string + readonly message: string +}> {} + +const getBinaryPath = Effect.gen(function* () { + const path = yield* Path.Path + + const platform = process.platform + const arch = process.arch + const pkg = optionalDepName(platform, arch) + + // The platform package ships package.json and the binary together + // (files: [bin] in the generated manifest), so a failed require.resolve is + // the only missing-state to handle; a spawn-time ENOENT would be a corrupt + // install that npm would not have produced. + const pkgJsonPath = yield* Effect.try({ + try: () => require.resolve(`${pkg}/package.json`), + catch: () => + new BinaryNotFound({ + platform, + arch, + package: pkg, + message: `the npm platform package for ${platform}/${arch} (${pkg}) is not installed`, + }), + }) + + return path.join(path.dirname(pkgJsonPath), binaryFileName(platform)) +}) + +const command = Command.make( + "comment-checker", + { + prompt: Flag.optional(Flag.string("prompt")), + }, + (config) => + Effect.gen(function* () { + const binaryPath = yield* getBinaryPath + const args = Option.match(config.prompt, { + onNone: () => [], + onSome: (prompt) => ["--prompt", prompt], + }) + + const child = ChildProcess.make(binaryPath, args, { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const exitCode = yield* spawner.exitCode(child) + + process.exitCode = exitCode + }) +) + +const { version } = require("../package.json") as { version: string } + +NodeRuntime.runMain( + Command.run(command, { version }).pipe(Effect.provide(NodeServices.layer)) +) diff --git a/npm/packages/comment-checker/src/platform.ts b/npm/packages/comment-checker/src/platform.ts new file mode 100644 index 0000000..81c7979 --- /dev/null +++ b/npm/packages/comment-checker/src/platform.ts @@ -0,0 +1,5 @@ +export const binaryFileName = (platform: string): string => + platform === "win32" ? "comment-checker.exe" : "comment-checker" + +export const optionalDepName = (platform: string, arch: string): string => + `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}` \ No newline at end of file diff --git a/npm/packages/comment-checker/tsconfig.json b/npm/packages/comment-checker/tsconfig.json new file mode 100644 index 0000000..9cb3f63 --- /dev/null +++ b/npm/packages/comment-checker/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "plugins": [ + { + "name": "@effect/language-service", + "diagnostics": true, + "diagnosticSeverity": { + "floatingEffect": "error", + "missingEffectContext": "error", + "missingEffectError": "error", + "missingLayerContext": "error", + "missingReturnYieldStar": "error", + "missingStarInYieldEffectGen": "error", + "returnEffectInGen": "error", + "anyUnknownInErrorContext": "error" + } + } + ] + }, + "include": ["src"] +} diff --git a/npm/packages/comment-checker/tsdown.config.ts b/npm/packages/comment-checker/tsdown.config.ts new file mode 100644 index 0000000..45cc00a --- /dev/null +++ b/npm/packages/comment-checker/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts", "src/platform.ts"], + format: ["esm"], + clean: true, + sourcemap: true, + outDir: "dist", +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b97486 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "claude-code-comment-checker", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.21.0", + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "lint": "oxlint -c .oxlintrc.json --no-error-on-unmatched-pattern" + }, + "devDependencies": { + "@effect/tsgo": "latest", + "oxlint": "latest", + "tsdown": "latest", + "typescript": "latest" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b4b18d0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1459 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@effect/tsgo': + specifier: latest + version: 0.36.5 + oxlint: + specifier: latest + version: 1.78.0 + tsdown: + specifier: latest + version: 0.22.14(typescript@7.0.2) + typescript: + specifier: latest + version: 7.0.2 + + npm/packages/comment-checker: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) + effect: + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 + devDependencies: + '@types/node': + specifier: latest + version: 26.2.0 + tsdown: + specifier: latest + version: 0.22.14(typescript@7.0.2) + typescript: + specifier: latest + version: 7.0.2 + +packages: + + '@effect/platform-node-shared@4.0.0-rc.109': + resolution: {integrity: sha512-jhZJnf81N7Z5+Z41Qr0cKT75WzGu6M1W05xrSdu/V8PK0kSFtU5hJMBxSGDWNlgWhBwK0WRZtnMHY8HLdONg1w==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.109 + + '@effect/platform-node@4.0.0-rc.108': + resolution: {integrity: sha512-Nof78154BaHGdSYr4TPQFZ5+Dg+HkpmbI3SQUdwsby5QNs6yahGJPu2AgdIVqdx7pKZ2w7j/bvdnqoMmkG0PbA==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.108 + ioredis: '>=5.7.0 <6.0.0' + + '@effect/tsgo-darwin-arm64@0.36.5': + resolution: {integrity: sha512-+JPS65Ekod5NS41Kg9OIyuUsygNcSw5/4Y+UNbY6Wob6dvP+CkEa51pGBAmHKQp3Z/D3g/A9GNE66ndu9kz8dw==} + cpu: [arm64] + os: [darwin] + + '@effect/tsgo-darwin-x64@0.36.5': + resolution: {integrity: sha512-S67mS1GTSvfeN5Tsij7QarJNuv3q7U/PMhjTbGtKO9mqgDTOA2IOlxq7YPIxkWq+AiOIv0H3aHGk7ToRjcPWxg==} + cpu: [x64] + os: [darwin] + + '@effect/tsgo-linux-arm64@0.36.5': + resolution: {integrity: sha512-bNLzLrQ/4Sf0N7NlAqcEpr+g+RW/ERIvmSFQN9Nu+hSFti4Kx9Nrfo1LJ4V0qASry5Rj2DG4Wa9C5ydQAD9qqA==} + cpu: [arm64] + os: [linux] + + '@effect/tsgo-linux-arm@0.36.5': + resolution: {integrity: sha512-UqtTPUgoVMRHAOcHiK7sddxjUEj6kOkXAlu4Y2TL/MhGiu81ZBzZrg8TXf0ApNeEMOD0EIK8hwU2kik8O+buxA==} + cpu: [arm] + os: [linux] + + '@effect/tsgo-linux-x64@0.36.5': + resolution: {integrity: sha512-QWdyuUcAb1kZBeItycHg1ZKloNbVDtlVm1C0bbDVHqZIZ/d2rqhKi6Zajm64GnHtfdwPTn8Oi1iOCH1IYgo0IQ==} + cpu: [x64] + os: [linux] + + '@effect/tsgo-win32-arm64@0.36.5': + resolution: {integrity: sha512-5tO5em1DfplFz5GqpQVGRSrAoE+kEgIc7Y0ClbT9jkxaK18IFVq9KIYybt4d6VT4+QnNQGdqWOahMcGE4JAZ8w==} + cpu: [arm64] + os: [win32] + + '@effect/tsgo-win32-x64@0.36.5': + resolution: {integrity: sha512-pmaKwdYAIs9GFpMV9glIUks0UZDHE1/xwP6+GCGhSFxB76cDLMnrHr/IBt5VfGddT65SK+Dyw/vAtcX7fRp4KA==} + cpu: [x64] + os: [win32] + + '@effect/tsgo@0.36.5': + resolution: {integrity: sha512-BHxVjeRK1/XlqYHWXkbT4W9JpOrT3sNA2wrfwQPBTojD5OSyxI2TUIltobYhWudyfuCrn770qP6uOpDjdrmghA==} + hasBin: true + + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@oxlint/binding-android-arm-eabi@1.78.0': + resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.78.0': + resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.78.0': + resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.78.0': + resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.78.0': + resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.78.0': + resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.78.0': + resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.78.0': + resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.78.0': + resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.78.0': + resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.78.0': + resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.78.0': + resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.78.0': + resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.78.0': + resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.78.0': + resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effect@4.0.0-rc.108: + resolution: {integrity: sha512-KmI3DlKZWPvCL4QQ2FMaPOuxMt/7DrKMENCY/gQ+MkDR5QYw25wgU5Zmh/wVLboNjIci1gNOgNCFe4xqgxli3A==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + oxlint@1.78.0: + resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + +snapshots: + + '@effect/platform-node-shared@4.0.0-rc.109(effect@4.0.0-rc.108)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-rc.108 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-rc.109(effect@4.0.0-rc.108) + effect: 4.0.0-rc.108 + ioredis: 5.11.1 + mime: 4.1.0 + undici: 8.10.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/tsgo-darwin-arm64@0.36.5': + optional: true + + '@effect/tsgo-darwin-x64@0.36.5': + optional: true + + '@effect/tsgo-linux-arm64@0.36.5': + optional: true + + '@effect/tsgo-linux-arm@0.36.5': + optional: true + + '@effect/tsgo-linux-x64@0.36.5': + optional: true + + '@effect/tsgo-win32-arm64@0.36.5': + optional: true + + '@effect/tsgo-win32-x64@0.36.5': + optional: true + + '@effect/tsgo@0.36.5': + optionalDependencies: + '@effect/tsgo-darwin-arm64': 0.36.5 + '@effect/tsgo-darwin-x64': 0.36.5 + '@effect/tsgo-linux-arm': 0.36.5 + '@effect/tsgo-linux-arm64': 0.36.5 + '@effect/tsgo-linux-x64': 0.36.5 + '@effect/tsgo-win32-arm64': 0.36.5 + '@effect/tsgo-win32-x64': 0.36.5 + + '@ioredis/commands@1.10.0': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@oxc-project/types@0.144.0': {} + + '@oxlint/binding-android-arm-eabi@1.78.0': + optional: true + + '@oxlint/binding-android-arm64@1.78.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.78.0': + optional: true + + '@oxlint/binding-darwin-x64@1.78.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.78.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.78.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.78.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.78.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.78.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.78.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.78.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.78.0': + optional: true + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.2.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + + ansis@4.3.1: {} + + cac@7.0.0: {} + + cluster-key-slot@1.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + defu@6.1.7: {} + + denque@2.1.0: {} + + detect-libc@2.1.2: + optional: true + + dts-resolver@3.0.0: {} + + effect@4.0.0-rc.108: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + uuid: 14.0.1 + + empathic@2.0.1: {} + + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.1: {} + + import-without-cache@0.4.0: {} + + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + kubernetes-types@1.30.0: {} + + mime@4.1.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + obug@2.1.4: {} + + oxlint@1.78.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.78.0 + '@oxlint/binding-android-arm64': 1.78.0 + '@oxlint/binding-darwin-arm64': 1.78.0 + '@oxlint/binding-darwin-x64': 1.78.0 + '@oxlint/binding-freebsd-x64': 1.78.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.78.0 + '@oxlint/binding-linux-arm-musleabihf': 1.78.0 + '@oxlint/binding-linux-arm64-gnu': 1.78.0 + '@oxlint/binding-linux-arm64-musl': 1.78.0 + '@oxlint/binding-linux-ppc64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-musl': 1.78.0 + '@oxlint/binding-linux-s390x-gnu': 1.78.0 + '@oxlint/binding-linux-x64-gnu': 1.78.0 + '@oxlint/binding-linux-x64-musl': 1.78.0 + '@oxlint/binding-openharmony-arm64': 1.78.0 + '@oxlint/binding-win32-arm64-msvc': 1.78.0 + '@oxlint/binding-win32-ia32-msvc': 1.78.0 + '@oxlint/binding-win32-x64-msvc': 1.78.0 + + picomatch@4.0.5: {} + + pure-rand@8.4.2: {} + + quansync@1.0.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.4)(typescript@7.0.2): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.4 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + standard-as-callback@2.1.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tree-kill@1.2.2: {} + + tsdown@0.22.14(typescript@7.0.2): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.2.4 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.4)(typescript@7.0.2) + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@8.3.0: {} + + undici@8.10.0: {} + + uuid@14.0.1: {} + + verkit@0.3.2: {} + + ws@8.21.3: {} + + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ef687f3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - 'npm/packages/*' +allowBuilds: + msgpackr-extract: false diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc new file mode 100644 index 0000000..e3dbd2d --- /dev/null +++ b/scripts/deno.jsonc @@ -0,0 +1,19 @@ +{ + "imports": { + "@std/assert": "jsr:@std/assert@1.0.19", + "@std/path": "jsr:@std/path@1.1.6" + }, + "tasks": { + "manifest:generate": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json release/generate-platform-manifest.ts", + "manifest:sync-root": "deno run --allow-env --allow-read=release/targets.json,../npm/packages/comment-checker/package.json --allow-write=../npm/packages/comment-checker/package.json release/sync-root-version.ts", + "check-matrix": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json,../.github/workflows/release.yml release/check-matrix.ts", + "test": "deno test --allow-read --allow-write --allow-env --allow-run=deno ../tests/release", + "lint": "deno lint --config ./deno.jsonc . ../tests/release" + }, + "fmt": { + "lineWidth": 100, + "indentWidth": 2, + "singleQuote": true, + "semiColons": false + } +} diff --git a/scripts/deno.lock b/scripts/deno.lock new file mode 100644 index 0000000..4da8e39 --- /dev/null +++ b/scripts/deno.lock @@ -0,0 +1,36 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1.0.19": "1.0.19", + "jsr:@std/cli@1.0.15": "1.0.15", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@1.1.6": "1.1.6" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/cli@1.0.15": { + "integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f" + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/path@1.1.6" + ] + } +} diff --git a/scripts/release/args.ts b/scripts/release/args.ts new file mode 100644 index 0000000..47fd921 --- /dev/null +++ b/scripts/release/args.ts @@ -0,0 +1,57 @@ +// Shared, dependency-free CLI flag parser for the release scripts. Each script +// used to duplicate this loop; one copy keeps missing-value / unknown-argument +// semantics identical across all three. +export interface FlagSpec { + /** flags that consume a value (--flag ) */ + string: string[] + /** flags that are pure booleans (presence = true) */ + boolean?: string[] + /** map canonical flag key -> returned key, e.g. binary-sha256 -> binarySha256 */ + rename?: Record +} + +export type ParsedArgs = Record + +// Parses a CLI argv. Unknown arguments and missing values exit(1) with the +// same messages the previous per-script parsers produced; tests never pin +// parser error text. +export function parseFlags(argv: string[], spec: FlagSpec): ParsedArgs { + const booleanFlags = spec.boolean ?? [] + const keyOf = (flag: string): string => spec.rename?.[flag] ?? flag + const out: ParsedArgs = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (!arg.startsWith('--')) { + console.error(`unknown argument: ${arg}`) + Deno.exit(1) + } + const body = arg.slice(2) + const eq = body.indexOf('=') + const name = eq === -1 ? body : body.slice(0, eq) + if (booleanFlags.includes(name) && eq === -1) { + out[keyOf(name)] = true + continue + } + if (spec.string.includes(name)) { + if (eq !== -1) { + out[keyOf(name)] = body.slice(eq + 1) + continue + } + const value = argv[i + 1] + // A consumed value that itself looks like a flag is a missing value for + // the current flag — `--version --dry-run` must not silently take + // `--dry-run` as the version. The `--flag=value` form still admits + // values starting with `-`. + if (value === undefined || value.startsWith('--')) { + console.error(`missing value for --${name}`) + Deno.exit(1) + } + out[keyOf(name)] = value + i++ + continue + } + console.error(`unknown argument: ${arg}`) + Deno.exit(1) + } + return out +} diff --git a/scripts/release/check-matrix.ts b/scripts/release/check-matrix.ts new file mode 100755 index 0000000..2b74201 --- /dev/null +++ b/scripts/release/check-matrix.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env -S deno run --allow-read +import { join, resolve } from '@std/path' +import { parseFlags } from './args.ts' + +const ROOT = join(import.meta.dirname!, '..', '..') +const DEFAULT_TARGETS_PATH = join(ROOT, 'scripts', 'release', 'targets.json') +const MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') +const RELEASE_WORKFLOW_PATH = join(ROOT, '.github', 'workflows', 'release.yml') + +// The supported platform set (KD1): the launcher resolves its platform package +// by identity as --. This is the product policy +// the table must name — a known set, not a copy of the table under check. +const EXPECTED_SUFFIXES = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'] + +const failures: string[] = [] +const fail = (reason: string) => failures.push(reason) +const note = (message: string) => console.error(`check-matrix: note: ${message}`) + +const flags = parseFlags(Deno.args, { + string: ['targets', 'manifest-path', 'workflow-path'], + rename: { 'manifest-path': 'manifestPath', 'workflow-path': 'workflowPath' }, +}) +const targetsArg = flags.targets +const manifestArg = flags.manifestPath +const workflowArg = flags.workflowPath +const targetsPath = typeof targetsArg === 'string' ? resolve(targetsArg) : DEFAULT_TARGETS_PATH +const manifestPath = typeof manifestArg === 'string' ? resolve(manifestArg) : MANIFEST_PATH +const workflowPath = typeof workflowArg === 'string' ? resolve(workflowArg) : RELEASE_WORKFLOW_PATH + +let targets: Array< + { target: string; suffix: string; os: string; cpu: string; libc?: string; bin: string } +> +try { + targets = JSON.parse(await Deno.readTextFile(targetsPath)) +} catch (error) { + console.error( + `check-matrix: FAIL: cannot read targets file ${targetsPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + Deno.exit(1) +} + +// 1. Identity convention (KTD2): each entry must be self-consistent with the +// launcher's platform/arch naming — suffix "os-cpu", Windows bin .exe, libc on +// Linux only — and the table must name exactly the product platform set. +if (!Array.isArray(targets)) { + fail('targets table is not an array') +} else { + const suffixes = targets.map((t) => t.suffix) + const missing = EXPECTED_SUFFIXES.filter((s) => !suffixes.includes(s)) + const extra = suffixes.filter((s) => !(EXPECTED_SUFFIXES as string[]).includes(s)) + if (missing.length > 0 || extra.length > 0) { + fail( + `targets table must name exactly the supported platform set; missing: ${ + missing.join(', ') || 'none' + }, extra: ${extra.join(', ') || 'none'}`, + ) + } + for (const entry of targets) { + if (entry.suffix !== `${entry.os}-${entry.cpu}`) { + fail( + `target ${entry.target}: suffix "${entry.suffix}" must equal os-cpu "${entry.os}-${entry.cpu}"`, + ) + } + if ((entry.os === 'win32') !== (entry.bin === 'comment-checker.exe')) { + fail( + `target ${entry.target}: bin must be comment-checker.exe iff os is win32 (os: ${entry.os}, bin: ${entry.bin})`, + ) + } + if (entry.os === 'linux' && entry.libc !== 'glibc') { + fail( + `target ${entry.target}: linux targets must carry libc "glibc", got ${ + JSON.stringify(entry.libc) + }`, + ) + } + if (entry.os !== 'linux' && entry.libc !== undefined) { + fail( + `target ${entry.target}: non-linux targets must not carry libc, got ${ + JSON.stringify(entry.libc) + }`, + ) + } + } +} + +// 2. Launcher manifest agreement: when optionalDependencies are present (the +// publish-time sync-root-version run), they must be exactly the platform +// packages named by this table, pinned to the root version. The committed +// manifest carries none before the first release — pnpm cannot lock +// unresolvable optional deps — so absence is expected, not a failure. +let manifest: Record & { + name: string + version: string + optionalDependencies?: Record +} +try { + manifest = JSON.parse(await Deno.readTextFile(manifestPath)) +} catch (error) { + console.error( + `check-matrix: FAIL: cannot read launcher manifest ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + Deno.exit(1) +} + +const expectedNames = targets.map((t) => `${manifest.name}-${t.suffix}`) +const declaredNames = Object.keys(manifest.optionalDependencies ?? {}) +if (declaredNames.length === 0) { + note( + 'launcher manifest carries no optionalDependencies (pre-publish); sync-root-version.ts injects the five platform pins from targets.json', + ) +} else { + const missingNames = expectedNames.filter((name) => !declaredNames.includes(name)) + const extraNames = declaredNames.filter((name) => !expectedNames.includes(name)) + if (missingNames.length > 0 || extraNames.length > 0) { + fail( + `launcher manifest optionalDependencies must be exactly the platform packages from the table; missing: ${ + missingNames.join(', ') || 'none' + }, extra: ${extraNames.join(', ') || 'none'}`, + ) + } + for (const name of expectedNames) { + const pin = manifest.optionalDependencies?.[name] + if (pin !== manifest.version) { + fail( + `optionalDependency ${name} must be pinned to the root version ${manifest.version}, got ${ + JSON.stringify(pin) + }`, + ) + } + } +} + +// 3. Cross-file agreement (the anti-drift surface): the workflow matrix must +// name exactly the triples in the table, and vice versa. Absent workflow is +// a skip (with a note), not a failure. +try { + await Deno.lstat(workflowPath) + const content = await Deno.readTextFile(workflowPath) + // Rows carry their suffix one line below the target (matrix:include order); + // capture target->suffix pairs from the workflow. + const workflowPairs = new Map( + [...content.matchAll( + /^\s*-\s*target:\s*((?:x86_64|aarch64)-[a-z0-9-]+)\s*\n\s*suffix:\s*([a-z0-9-]+)\s*$/gm, + )].map((m) => [m[1], m[2]]), + ) + const tablePairs = new Map(targets.map((t) => [t.target, t.suffix])) + for (const [target, suffix] of tablePairs) { + if (!workflowPairs.has(target)) { + fail(`release.yml does not list release target ${target}`) + } else if (workflowPairs.get(target) !== suffix) { + fail( + `release.yml lists ${target} with suffix ${workflowPairs.get(target)}, ` + + `table says ${suffix}`, + ) + } + } + for (const [target, suffix] of workflowPairs) { + if (!tablePairs.has(target)) { + fail(`release.yml lists ${target}, which is not a row in targets.json`) + } else if (suffix !== tablePairs.get(target)) { + fail( + `release.yml lists ${target} with suffix ${suffix}, table says ${ + tablePairs.get( + target, + ) + }`, + ) + } + } +} catch (error) { + if (error instanceof Deno.errors.NotFound) { + note(`skipped: ${workflowPath} not found (workflow agreement not checked)`) + } else { + fail(`cannot check workflow ${workflowPath}: ${String(error)}`) + } +} + +if (failures.length > 0) { + for (const reason of failures) { + console.error(`check-matrix: FAIL: ${reason}`) + } + Deno.exit(1) +} + +console.error('check-matrix: ok') diff --git a/scripts/release/generate-platform-manifest.ts b/scripts/release/generate-platform-manifest.ts new file mode 100755 index 0000000..326957e --- /dev/null +++ b/scripts/release/generate-platform-manifest.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +import { join } from '@std/path' +import { parseFlags } from './args.ts' + +const ROOT = join(import.meta.dirname!, '..', '..') + +interface Target { + target: string + suffix: string + os: string + cpu: string + libc?: string + bin: string +} + +const TARGETS: Target[] = JSON.parse( + await Deno.readTextFile(join(ROOT, 'scripts', 'release', 'targets.json')), +) +const LAUNCHER: { name: string; repository: { type: string; url: string } } = JSON.parse( + await Deno.readTextFile(join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json')), +) + +// CLI: --suffix --version --out [--binary-sha256 ] [--dry-run] +const args = parseFlags(Deno.args, { + string: ['suffix', 'version', 'out', 'binary-sha256'], + boolean: ['dry-run'], + rename: { 'binary-sha256': 'binarySha256', 'dry-run': 'dryRun' }, +}) + +for (const flag of ['suffix', 'version', 'out'] as const) { + if (typeof args[flag] !== 'string') { + console.error(`generate-platform-manifest: missing required --${flag}`) + Deno.exit(1) + } +} +const suffix = args.suffix as string +const version = args.version as string +const out = args.out as string +const binarySha256 = args.binarySha256 + +const entry = TARGETS.find((t) => t.suffix === suffix) +if (!entry) { + console.error( + `generate-platform-manifest: unknown suffix "${suffix}"; supported suffixes: ${ + TARGETS.map((t) => t.suffix).join(', ') + }`, + ) + Deno.exit(1) +} + +const pkg: Record = { + name: `${LAUNCHER.name}-${entry.suffix}`, + version, + description: `${LAUNCHER.name} ${entry.suffix} platform package`, + license: 'Apache-2.0', + repository: LAUNCHER.repository, + os: [entry.os], + cpu: [entry.cpu], + files: [entry.bin], + // No `bin` field on platform packages (esbuild precedent): installing one + // would create a top-level `comment-checker` shim that collides with the + // launcher's own bin of the same name. + publishConfig: { access: 'public', provenance: true }, +} +if (entry.libc !== undefined) { + pkg.libc = [entry.libc] +} +if (binarySha256 !== undefined) { + pkg.binarySha256 = binarySha256 as string +} + +const output = JSON.stringify(pkg, null, 2) + '\n' + +if (args.dryRun) { + await Deno.stdout.write(new TextEncoder().encode(output)) +} else { + await Deno.mkdir(out, { recursive: true }) + await Deno.writeTextFile(join(out, 'package.json'), output) +} diff --git a/scripts/release/sync-root-version.ts b/scripts/release/sync-root-version.ts new file mode 100755 index 0000000..8ef40a5 --- /dev/null +++ b/scripts/release/sync-root-version.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +import { join, resolve } from '@std/path' +import { parseFlags } from './args.ts' + +const ROOT = join(import.meta.dirname!, '..', '..') +const DEFAULT_MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') +const TARGETS_PATH = join(ROOT, 'scripts', 'release', 'targets.json') +const VERSION_RE = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/ + +// VERSION is validated BEFORE any write; a value that never reaches end-of-input +// (e.g. trailing line breaks) is invalid even though $ can match before one. +const version = Deno.env.get('VERSION') ?? '' +if (!VERSION_RE.test(version) || version.includes('\n')) { + console.error(`sync-root-version: invalid VERSION: ${JSON.stringify(version)}`) + Deno.exit(1) +} + +// CLI: [--manifest-path ] [--dry-run]. The committed launcher manifest +// carries NO optionalDependencies (pnpm cannot record unresolvable optional +// deps in a lockfile, so listing unpublished platform packages would break +// `pnpm install --frozen-lockfile`); this script injects the five pins from +// targets.json at publish time, when the platform packages already exist. +const flags = parseFlags(Deno.args, { + string: ['manifest-path'], + boolean: ['dry-run'], + rename: { 'dry-run': 'dryRun', 'manifest-path': 'manifestPath' }, +}) +const dryRun = flags.dryRun === true +const manifestArg = flags.manifestPath +const manifestPath = typeof manifestArg === 'string' ? resolve(manifestArg) : DEFAULT_MANIFEST_PATH + +const targets = JSON.parse(await Deno.readTextFile(TARGETS_PATH)) +if (!Array.isArray(targets) || targets.length !== 5) { + console.error('sync-root-version: targets.json must declare exactly five platform targets') + Deno.exit(1) +} + +const original = await Deno.readTextFile(manifestPath) +const manifest = JSON.parse(original) +manifest.version = version +// The published root's optionalDependencies are pinned to the tag version for +// exactly the five platform packages declared in targets.json. +manifest.optionalDependencies = Object.fromEntries( + targets.map((entry) => [`${manifest.name}-${entry.suffix}`, version]), +) + +// Preserve the launcher manifest's own formatting so an unchanged sync is a +// byte-identical round trip: 2-space indent plus the file's existing +// trailing-newline convention. +const next = JSON.stringify(manifest, null, 2) + (original.endsWith('\n') ? '\n' : '') + +function diffLines(before: string, after: string): string[] { + const a = before.split('\n') + const b = after.split('\n') + // LCS-based line diff over two small JSON texts. + const n = a.length + const m = b.length + const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + const out = [] + let i = 0 + let j = 0 + while (i < n && j < m) { + if (a[i] === b[j]) { + i++ + j++ + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + out.push(`- ${a[i]}`) + i++ + } else { + out.push(`+ ${b[j]}`) + j++ + } + } + while (i < n) { + out.push(`- ${a[i]}`) + i++ + } + while (j < m) { + out.push(`+ ${b[j]}`) + j++ + } + return out +} + +if (dryRun) { + for (const line of diffLines(original, next)) { + console.log(line) + } +} else { + await Deno.writeTextFile(manifestPath, next) +} diff --git a/scripts/release/targets.json b/scripts/release/targets.json new file mode 100644 index 0000000..68b3dc5 --- /dev/null +++ b/scripts/release/targets.json @@ -0,0 +1,39 @@ +[ + { + "target": "x86_64-unknown-linux-gnu", + "suffix": "linux-x64", + "os": "linux", + "cpu": "x64", + "libc": "glibc", + "bin": "comment-checker" + }, + { + "target": "aarch64-unknown-linux-gnu", + "suffix": "linux-arm64", + "os": "linux", + "cpu": "arm64", + "libc": "glibc", + "bin": "comment-checker" + }, + { + "target": "x86_64-apple-darwin", + "suffix": "darwin-x64", + "os": "darwin", + "cpu": "x64", + "bin": "comment-checker" + }, + { + "target": "aarch64-apple-darwin", + "suffix": "darwin-arm64", + "os": "darwin", + "cpu": "arm64", + "bin": "comment-checker" + }, + { + "target": "x86_64-pc-windows-msvc", + "suffix": "win32-x64", + "os": "win32", + "cpu": "x64", + "bin": "comment-checker.exe" + } +] diff --git a/tests/npm-launcher/launcher.test.mjs b/tests/npm-launcher/launcher.test.mjs new file mode 100644 index 0000000..b8eccbf --- /dev/null +++ b/tests/npm-launcher/launcher.test.mjs @@ -0,0 +1,117 @@ +// Black-box suite for the npm launcher (dist/index.mjs). Requires a prior +// `pnpm -r build`: the launcher entry and the extracted platform module are +// imported from the built dist/ output below. +import { spawnSync } from "node:child_process" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { test } from "node:test" +import assert from "node:assert/strict" + +import { binaryFileName, optionalDepName } from "../../npm/packages/comment-checker/dist/platform.mjs" + +const launcherPath = fileURLToPath( + new URL("../../npm/packages/comment-checker/dist/index.mjs", import.meta.url) +) + +// The published `bin` contract is the shebang + exec bit on dist/index.mjs; +// without them npm's POSIX bin shim fails with ENOEXEC for every consumer. +test("built launcher carries the node shebang and is executable", () => { + const head = readFileSync(launcherPath, "utf8").split("\n", 1)[0] + assert.equal(head, "#!/usr/bin/env node") + const { mode } = statSync(launcherPath) + assert.notEqual(mode & 0o111, 0, "dist/index.mjs must be executable") +}) + +const hostDep = optionalDepName(process.platform, process.arch) +const skipNoPosixShim = + process.platform === "win32" + ? "cannot fabricate a .exe shim in a test fixture; the released binary covers win32" + : false + +// Build a fake install: /node_modules/@systemfsoftware// with a +// package.json and an executable shim named after the host binary. NODE_PATH +// entries are joined directly with the requested id, so it must point at the +// fixture's node_modules dir for createRequire to resolve the package. +function makeFixture() { + const root = mkdtempSync(join(tmpdir(), "comment-checker-launcher-")) + const pkgDir = join(root, "node_modules", hostDep) + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, "package.json"), `${JSON.stringify({ name: hostDep, version: "0.0.0" })}\n`) + if (process.platform !== "win32") { + const shim = ["#!/bin/sh", "printf '%s\\n' \"$*\"", 'exit "${SHIM_EXIT:-0}"'].join("\n") + writeFileSync(join(pkgDir, binaryFileName(process.platform)), `${shim}\n`, { mode: 0o755 }) + } + return { root, npmPath: join(root, "node_modules") } +} + +const runLauncher = (npmPath, args = [], extraEnv = {}) => + spawnSync(process.execPath, [launcherPath, ...args], { + encoding: "utf8", + env: { ...process.env, NODE_PATH: npmPath, ...extraEnv }, + timeout: 30_000, + }) + +test( + "launcher spawns the host platform shim and passes args through", + { skip: skipNoPosixShim }, + () => { + const { root, npmPath } = makeFixture() + try { + const res = runLauncher(npmPath, ["--prompt", "hello"]) + assert.equal(res.status, 0, res.stdout) + assert.match(res.stdout, /hello/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) + +test("launcher propagates the child exit code", { skip: skipNoPosixShim }, () => { + const { root, npmPath } = makeFixture() + try { + const res = runLauncher(npmPath, ["--prompt", "hello"], { SHIM_EXIT: "42" }) + assert.equal(res.status, 42, res.stdout) + assert.match(res.stdout, /hello/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("missing platform package fails with BinaryNotFound naming the package", () => { + const root = mkdtempSync(join(tmpdir(), "comment-checker-missing-")) + const npmPath = join(root, "node_modules") + mkdirSync(npmPath, { recursive: true }) + try { + const res = runLauncher(npmPath, ["--prompt", "hello"]) + assert.notEqual(res.status, 0) + // Effect rc.108's default console logger writes to stdout, not stderr, so + // the failure report is matched against the combined output. + const output = `${res.stdout}${res.stderr}` + assert.match(output, /BinaryNotFound/) + assert.ok(output.includes(hostDep), `output should name ${hostDep}`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("platform helpers follow the release naming convention", () => { + assert.equal(optionalDepName("win32", "x64"), "@systemfsoftware/claude-code-comment-checker-win32-x64") + assert.equal(binaryFileName("win32"), "comment-checker.exe") + assert.equal(binaryFileName("linux"), "comment-checker") +}) + +const targets = JSON.parse( + readFileSync(new URL("../../scripts/release/targets.json", import.meta.url), "utf8") +) + +for (const target of targets) { + test(`target ${target.target} maps to optional dep ${target.suffix}`, () => { + assert.equal( + optionalDepName(target.os, target.cpu), + `@systemfsoftware/claude-code-comment-checker-${target.suffix}` + ) + assert.equal(binaryFileName(target.os), target.bin) + }) +} \ No newline at end of file diff --git a/tests/npm-launcher/manifest.test.mjs b/tests/npm-launcher/manifest.test.mjs new file mode 100644 index 0000000..bf86cd3 --- /dev/null +++ b/tests/npm-launcher/manifest.test.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs" +import { test } from "node:test" +import assert from "node:assert/strict" + +const manifest = JSON.parse( + readFileSync(new URL("../../npm/packages/comment-checker/package.json", import.meta.url), "utf8") +) + +test("launcher manifest declares no install-time script", () => { + assert.equal(manifest.postinstall, undefined) + assert.equal(manifest.prepare, undefined) + assert.equal(manifest.install, undefined) +}) + +test("launcher manifest ships only the built launcher", () => { + assert.deepEqual(manifest.files, ["dist"]) + assert.deepEqual(manifest.bin, { "comment-checker": "./dist/index.mjs" }) + assert.equal(manifest.private, undefined) +}) + +test("committed manifest carries no optionalDependencies (pre-publish)", () => { + // pnpm cannot record unresolvable optional deps in a lockfile, so the + // committed manifest must not name the unpublished platform packages; + // sync-root-version.ts injects the targets.json pins at publish time. + assert.equal(manifest.optionalDependencies, undefined) + assert.equal(manifest.version, "0.1.0") +}) + +test("publishConfig requests public access and provenance", () => { + assert.equal(manifest.publishConfig.access, "public") + assert.equal(manifest.publishConfig.provenance, true) +}) + +test("engines floor is Node >= 18", () => { + assert.equal(manifest.engines.node, ">=18") +}) + +test("Effect deps pinned to the vendored rc revision", () => { + assert.equal(manifest.dependencies.effect, "4.0.0-rc.108") + assert.equal(manifest.dependencies["@effect/platform-node"], "4.0.0-rc.108") +}) \ No newline at end of file diff --git a/tests/release/release-scripts.test.ts b/tests/release/release-scripts.test.ts new file mode 100644 index 0000000..380160e --- /dev/null +++ b/tests/release/release-scripts.test.ts @@ -0,0 +1,572 @@ +import { join } from '@std/path' +import { + assertEquals, + assertFalse, + assertMatch, + assertNotEquals, + assertObjectMatch, + assertRejects, +} from '@std/assert' + +const ROOT = join(import.meta.dirname!, '..', '..') +const RELEASE = join(ROOT, 'scripts', 'release') +const MANIFEST_PATH = join( + ROOT, + 'npm', + 'packages', + 'comment-checker', + 'package.json', +) +const TARGETS_PATH = join(RELEASE, 'targets.json') +const RELEASE_WORKFLOW_PATH = join(ROOT, '.github', 'workflows', 'release.yml') + +// The supported platform set (KD1) — the launcher resolves its platform package +// by identity as --, so these five pairs are the +// product contract the table must name exactly. +const EXPECTED_SUFFIXES = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', +] as const +const LAUNCHER_PREFIX = '@systemfsoftware/claude-code-comment-checker' + +const decode = (u8: Uint8Array) => new TextDecoder().decode(u8) + +async function withTmp(fn: (dir: string) => Promise) { + const dir = await Deno.makeTempDir({ prefix: 'release-scripts-' }) + try { + await fn(dir) + } finally { + await Deno.remove(dir, { recursive: true }) + } +} + +// Scripts run with the same permission surface the deno.jsonc tasks declare: +// read the table + launcher manifest + workflow, write only the caller's +// explicit --out / copies. The suite never touches the committed manifest. +function runScript( + script: string, + args: string[], + opts: { env?: Record; cwd?: string; writeAllow?: string } = {}, +): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> { + const { env = {}, cwd = ROOT, writeAllow = 'unused' } = opts + const callerPaths: string[] = [] + for (let i = 0; i < args.length; i++) { + if (args[i] === '--suffix' || args[i] === '--version') { + i++ + } else if ( + args[i] === '--out' || args[i] === '--manifest-path' || + args[i] === '--targets' || args[i] === '--workflow-path' + ) { + callerPaths.push(args[++i]) + } + } + const read = [ + TARGETS_PATH, + MANIFEST_PATH, + RELEASE_WORKFLOW_PATH, + ...callerPaths, + ].join(',') + const cmd = new Deno.Command('deno', { + args: [ + 'run', + `--allow-read=${read}`, + `--allow-write=${writeAllow}`, + `--allow-env`, + join(RELEASE, script), + ...args, + ], + cwd, + env: { ...Deno.env.toObject(), ...env }, + stdout: 'piped', + stderr: 'piped', + }) + return cmd.output().then((r) => ({ + code: r.code, + stdout: r.stdout, + stderr: r.stderr, + })) +} + +const readTable = async () => + JSON.parse(await Deno.readTextFile(TARGETS_PATH)) as Array<{ + target: string + suffix: string + os: string + cpu: string + libc?: string + bin: string + }> + +Deno.test('targets.json names exactly the five supported platform/arch pairs', async () => { + const table = await readTable() + const suffixes = table.map((t) => t.suffix).sort() + assertEquals(suffixes, [...EXPECTED_SUFFIXES].sort()) +}) + +Deno.test('generate-platform-manifest emits the correct package.json for each targets.json entry', async () => { + await withTmp(async (tmp) => { + const table = await readTable() + const launcher = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) + const cases = table.map(async (entry) => { + const out = join(tmp, `gen-${entry.suffix}`) + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + entry.suffix, + '--version', + '0.1.0', + '--out', + out, + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) + + const pkg = JSON.parse( + await Deno.readTextFile(join(out, 'package.json')), + ) + assertEquals(pkg.name, `${LAUNCHER_PREFIX}-${entry.suffix}`) + assertEquals(pkg.name, `${launcher.name}-${entry.suffix}`) + assertEquals(pkg.version, '0.1.0') + assertEquals(pkg.license, 'Apache-2.0') + assertObjectMatch(pkg.repository, launcher.repository) + assertEquals(pkg.os, [entry.os]) + assertEquals(pkg.cpu, [entry.cpu]) + assertEquals(pkg.files, [entry.bin]) + assertEquals( + pkg.bin, + undefined, + 'platform packages must not declare a bin (would collide with the launcher shim)', + ) + assertEquals(pkg.publishConfig, { access: 'public', provenance: true }) + if (entry.libc !== undefined) { + assertEquals(pkg.libc, [entry.libc]) + } else { + assertEquals(pkg.libc, undefined) + } + assertEquals( + pkg.binarySha256, + undefined, + 'no binarySha256 without the flag', + ) + }) + await Promise.all(cases) + }) +}) + +Deno.test('generate-platform-manifest records binarySha256 when passed', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'gen-sha') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-x64', + '--version', + '0.1.0', + '--binary-sha256', + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + '--out', + out, + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) + const pkg = JSON.parse(await Deno.readTextFile(join(out, 'package.json'))) + assertEquals( + pkg.binarySha256, + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ) + }) +}) + +Deno.test('generate-platform-manifest --dry-run leaves the filesystem untouched', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'dry-run-out') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-x64', + '--version', + '0.1.0', + '--out', + out, + '--dry-run', + ]) + assertEquals(res.code, 0, decode(res.stderr)) + await assertRejects(() => Deno.stat(out)) + const stdout = decode(res.stdout) + assertMatch( + stdout, + /"name": "@systemfsoftware\/claude-code-comment-checker-linux-x64"/, + ) + assertMatch(stdout, /"version": "0.1.0"/) + }) +}) + +Deno.test('generate-platform-manifest: unknown suffix exits non-zero and lists the supported suffixes', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'gen-unknown') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-mips', + '--version', + '0.1.0', + '--out', + out, + ], { writeAllow: out }) + assertNotEquals(res.code, 0) + const stderr = decode(res.stderr) + for (const suffix of EXPECTED_SUFFIXES) { + assertMatch(stderr, new RegExp(suffix)) + } + await assertRejects(() => Deno.stat(join(out, 'package.json'))) + }) +}) + +Deno.test('sync-root-version: VERSION=0.1.0 writes version and optionalDependencies via --manifest-path', async () => { + await withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-copy.json') + await Deno.copyFile(MANIFEST_PATH, copy) + + const res = await runScript('sync-root-version.ts', [ + '--manifest-path', + copy, + ], { + env: { VERSION: '0.1.0' }, + writeAllow: copy, + }) + assertEquals(res.code, 0, decode(res.stderr)) + + const manifest = JSON.parse(await Deno.readTextFile(copy)) + assertEquals(manifest.version, '0.1.0') + assertEquals( + Object.keys(manifest.optionalDependencies).sort(), + [...EXPECTED_SUFFIXES].sort().map((s) => `${LAUNCHER_PREFIX}-${s}`), + ) + for (const value of Object.values(manifest.optionalDependencies)) { + assertEquals(value, '0.1.0') + } + // The committed manifest is not touched by the release-time sync. + const committed = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) + assertEquals(committed.optionalDependencies, undefined) + }) +}) + +Deno.test('sync-root-version: VERSION=0.2.0 dry-run shows only version-field diffs', async () => { + await withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-copy2.json') + await Deno.copyFile(MANIFEST_PATH, copy) + // Seed the copy at 0.1.0 so the dry-run shift is a clean version bump. + await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, + writeAllow: copy, + }) + const before = await Deno.readTextFile(copy) + + const res = await runScript('sync-root-version.ts', [ + '--manifest-path', + copy, + '--dry-run', + ], { + env: { VERSION: '0.2.0' }, + writeAllow: copy, + }) + assertEquals(res.code, 0, decode(res.stderr)) + assertEquals(await Deno.readTextFile(copy), before, 'dry-run never writes') + + const stdout = decode(res.stdout) + const minus = stdout.split('\n').filter((line) => line.startsWith('- ')) + const plus = stdout.split('\n').filter((line) => line.startsWith('+ ')) + const manifest = JSON.parse(before) + const changedLineCount = Object.keys(manifest.optionalDependencies).length + + 1 // version + five deps + assertEquals(minus.length, changedLineCount) + assertEquals(plus.length, changedLineCount) + assertMatch(minus[0], /- {3}"version": "0.1.0",/) + assertMatch(plus[0], /\+ {3}"version": "0.2.0",/) + for (let i = 0; i < minus.length; i++) { + const minusLine = minus[i].replace(/^- /, '') + const plusLine = plus[i].replace(/^\+ /, '') + assertEquals( + plusLine, + minusLine.replace('"0.1.0"', '"0.2.0"'), + `each diff pair changes only the version value (line ${i}: ${minus[i]})`, + ) + } + }) +}) + +Deno.test('sync-root-version: malformed VERSION exits non-zero and writes nothing', async () => { + const malformed = [ + 'v0.1.0', + 'v0.1.0"', + '0.1.0\n--provenance=false', + 'not-a-version', + ] + const cases = malformed.map((version) => + withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-malformed.json') + await Deno.copyFile(MANIFEST_PATH, copy) + const before = await Deno.readTextFile(copy) + const res = await runScript('sync-root-version.ts', [ + '--manifest-path', + copy, + ], { + env: { VERSION: version }, + writeAllow: copy, + }) + assertNotEquals( + res.code, + 0, + `VERSION=${JSON.stringify(version)} must fail`, + ) + const stderr = decode(res.stderr) + assertMatch( + stderr, + /invalid VERSION/, + `stderr reports the invalid value for ${JSON.stringify(version)}: ${stderr}`, + ) + assertEquals( + await Deno.readTextFile(copy), + before, + `no write for VERSION=${JSON.stringify(version)}`, + ) + }) + ) + await Promise.all(cases) +}) + +Deno.test('check-matrix: passes on the packaged table and pre-publish launcher manifest', async () => { + const res = await runScript('check-matrix.ts', []) + assertEquals(res.code, 0, decode(res.stderr)) + const stderr: string = decode(res.stderr) + assertMatch( + stderr, + /no optionalDependencies \(pre-publish\)/, + 'absence of pins is noted, not failed', + ) +}) + +Deno.test('check-matrix: fails on a manifest with wrong pin', async () => { + await withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-wrongpin.json') + await Deno.copyFile(MANIFEST_PATH, copy) + await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, + writeAllow: copy, + }) + // Corrupt one pin to a different exact version after the sync. + const manifest = JSON.parse(await Deno.readTextFile(copy)) + manifest.optionalDependencies[`${LAUNCHER_PREFIX}-linux-x64`] = '0.2.0' + await Deno.writeTextFile(copy, JSON.stringify(manifest, null, 2) + '\n') + + const res = await runScript('check-matrix.ts', ['--manifest-path', copy]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/, 'stderr names the failure') + }) +}) + +Deno.test('check-matrix: fails on a reversed os/cpu identity violation', async () => { + await withTmp(async (tmp) => { + const table = await readTable() + const reversed = table.map(({ target, suffix, os, cpu, libc, bin }) => ({ + target, + suffix, + os: cpu, + cpu: os, + bin, + ...(libc !== undefined ? { libc } : {}), + })) + const targetsPath = join(tmp, 'targets.json') + await Deno.writeTextFile( + targetsPath, + JSON.stringify(reversed, null, 2) + '\n', + ) + const res = await runScript('check-matrix.ts', ['--targets', targetsPath]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + }) +}) + +Deno.test('check-matrix: fails on a missing or extra table target', async () => { + await withTmp(async (tmp) => { + const table = await readTable() + + const missingPath = join(tmp, 'missing.json') + await Deno.writeTextFile( + missingPath, + JSON.stringify( + table.filter((entry) => entry.target !== 'x86_64-apple-darwin'), + null, + 2, + ) + + '\n', + ) + const resMissing = await runScript('check-matrix.ts', [ + '--targets', + missingPath, + ]) + assertNotEquals(resMissing.code, 0) + assertMatch(decode(resMissing.stderr), /FAIL/) + + const extraPath = join(tmp, 'extra.json') + await Deno.writeTextFile( + extraPath, + JSON.stringify( + [ + ...table, + { + target: 'x86_64-unknown-freebsd', + suffix: 'freebsd-x64', + os: 'freebsd', + cpu: 'x64', + bin: 'comment-checker', + }, + ], + null, + 2, + ) + '\n', + ) + const resExtra = await runScript('check-matrix.ts', [ + '--targets', + extraPath, + ]) + assertNotEquals(resExtra.code, 0) + assertMatch(decode(resExtra.stderr), /FAIL/) + }) +}) + +// --- Workflow-agreement failure arms (F9): every gate fails loudly; a green +// check-matrix must imply table <-> workflow agreement in BOTH directions, +// including target/suffix pairing (a swapped suffix must not pass). + +const WORKFLOW_ROW = (target: string, suffix: string) => + ` - target: ${target}\n suffix: ${suffix}\n` + +Deno.test('check-matrix: fails when the workflow omits a table target', async () => { + await withTmp(async (tmp) => { + const wf = join(tmp, 'release-missing.yml') + const table = await readTable() + const kept = table.filter((t) => t.target !== 'x86_64-apple-darwin') + await Deno.writeTextFile( + wf, + ' strategy:\n matrix:\n include:\n' + + kept.map((t) => WORKFLOW_ROW(t.target, t.suffix)).join('') + + '\n', + ) + const res = await runScript('check-matrix.ts', ['--workflow-path', wf]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + assertMatch(decode(res.stderr), /x86_64-apple-darwin/) + }) +}) + +Deno.test('check-matrix: fails when the workflow names an extra target', async () => { + await withTmp(async (tmp) => { + const wf = join(tmp, 'release-extra.yml') + const table = await readTable() + await Deno.writeTextFile( + wf, + ' strategy:\n matrix:\n include:\n' + + table.map((t) => WORKFLOW_ROW(t.target, t.suffix)).join('') + + WORKFLOW_ROW('x86_64-unknown-freebsd', 'freebsd-x64') + + '\n', + ) + const res = await runScript('check-matrix.ts', ['--workflow-path', wf]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + assertMatch(decode(res.stderr), /x86_64-unknown-freebsd/) + }) +}) + +Deno.test('check-matrix: fails on a swapped target/suffix pair', async () => { + await withTmp(async (tmp) => { + const wf = join(tmp, 'release-swapped.yml') + const table = await readTable() + const rows = table.map((t) => [ + t.target, + t.target === 'x86_64-unknown-linux-gnu' ? 'linux-arm64' : t.suffix, + ]) + await Deno.writeTextFile( + wf, + ' strategy:\n matrix:\n include:\n' + + rows.map(([t, s]) => WORKFLOW_ROW(t, s)).join('') + + '\n', + ) + const res = await runScript('check-matrix.ts', ['--workflow-path', wf]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + }) +}) + +// --- parseFlags error paths (F10): malformed CLIs must exit non-zero before +// any write, never silently coerce a flag into a value slot. --- +const BAD_ARGS: Array<[string, string[], RegExp]> = [ + ['missing value', ['--suffix'], /missing value for --suffix/], + [ + 'flag-shaped value', + ['--suffix', '--dry-run'], + /missing value for --suffix/, + ], + ['unknown flag', ['--nope'], /unknown argument: --nope/], + ['positional argument', ['naked'], /unknown argument: naked/], +] + +for (const [label, args, pattern] of BAD_ARGS) { + Deno.test(`parseFlags: ${label} exits non-zero without writing`, async () => { + await withTmp(async (tmp) => { + const res = await runScript('generate-platform-manifest.ts', [ + ...args, + '--version', + '0.1.0', + '--out', + tmp, + ]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), pattern) + // No package.json may be produced by a failed parse. + await assertRejects(() => Deno.lstat(join(tmp, 'package.json'))) + }) + }) +} + +Deno.test('parseFlags: --flag=value form still admits dash-leading values', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'eq-form') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix=linux-x64', + '--version=0.1.0', + '--out', + out, + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) + const pkg = JSON.parse( + await Deno.readTextFile(join(out, 'package.json')), + ) + assertEquals(pkg.name, `${LAUNCHER_PREFIX}-linux-x64`) + }) +}) + +// --- Workflow/deno-task fidelity (F7): the exact permission surface the +// release pipeline declares must keep matching the scripts' real needs. +// These assertions replay the declared flags from the committed files — any +// drift (e.g. an env read without --allow-env) fails here, not at a tag. --- +Deno.test('fidelity: release.yml sync-root step declares --allow-env', async () => { + const workflow = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) + const syncBlock = + /- name: Sync root version[^]*?deno run[^\n]*\n((?:.*?\n)*?)\s*scripts\/release\/sync-root-version\.ts/ + const match = syncBlock.exec(workflow) + assertNotEquals(match, null, 'sync-root step not found') + assertMatch(match![1], /--allow-env/) +}) + +Deno.test('fidelity: manifest:sync-root task declares --allow-env', async () => { + const config = await Deno.readTextFile( + join(import.meta.dirname!, '..', '..', 'scripts', 'deno.jsonc'), + ) + assertMatch(config, /"manifest:sync-root":[^\n]*--allow-env/) +}) + +Deno.test('fidelity: registry gate never regresses to the fromjson bug', async () => { + const workflow = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) + assertFalse( + workflow.includes('fromjson'), + 'jq gate must compare --argjson objects directly', + ) +})