diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..28eb5ac --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +# Refuse a push whose refs do not contain remote master. +# Local master is not the source of truth — fetch the remote tip. +# stderr from the script is the gate's product output. +set -e +./scripts/guards/check-remote-master.ts "$@" diff --git a/package.json b/package.json index 11f6851..e110196 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ "build": "turbo build", "typecheck": "turbo typecheck", "lint": "turbo lint", + "prepare": "husky", "mutants": "cargo mutants --file crates/comment-checker/src/classify.rs --timeout 90", "gate:mutants": "turbo run //#mutants" }, "devDependencies": { "@effect/tsgo": "latest", "@systemfsoftware/tsconfig": "^1.3.1", + "husky": "^9.1.7", "tsdown": "latest", "turbo": "^2.10.5", "typescript": "latest" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03cfb52..b76aa2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@systemfsoftware/tsconfig': specifier: ^1.3.1 version: 1.3.1 + husky: + specifier: ^9.1.7 + version: 9.1.7 tsdown: specifier: latest version: 0.22.14(typescript@7.0.2) @@ -37,7 +40,7 @@ importers: version: 4.0.0-rc.108 oxlint: specifier: latest - version: 1.78.0 + version: 1.79.0 tsdown: specifier: latest version: 0.22.14(typescript@7.0.2) @@ -135,124 +138,124 @@ packages: '@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==} + '@oxlint/binding-android-arm-eabi@1.79.0': + resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} 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==} + '@oxlint/binding-android-arm64@1.79.0': + resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} 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==} + '@oxlint/binding-darwin-arm64@1.79.0': + resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} 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==} + '@oxlint/binding-darwin-x64@1.79.0': + resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.78.0': - resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} + '@oxlint/binding-freebsd-x64@1.79.0': + resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} 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==} + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} 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==} + '@oxlint/binding-linux-arm-musleabihf@1.79.0': + resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} 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==} + '@oxlint/binding-linux-arm64-gnu@1.79.0': + resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} 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==} + '@oxlint/binding-linux-arm64-musl@1.79.0': + resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} 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==} + '@oxlint/binding-linux-ppc64-gnu@1.79.0': + resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} 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==} + '@oxlint/binding-linux-riscv64-gnu@1.79.0': + resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} 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==} + '@oxlint/binding-linux-riscv64-musl@1.79.0': + resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} 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==} + '@oxlint/binding-linux-s390x-gnu@1.79.0': + resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} 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==} + '@oxlint/binding-linux-x64-gnu@1.79.0': + resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} 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==} + '@oxlint/binding-linux-x64-musl@1.79.0': + resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} 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==} + '@oxlint/binding-openharmony-arm64@1.79.0': + resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} 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==} + '@oxlint/binding-win32-arm64-msvc@1.79.0': + resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} 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==} + '@oxlint/binding-win32-ia32-msvc@1.79.0': + resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} 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==} + '@oxlint/binding-win32-x64-msvc@1.79.0': + resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -718,6 +721,11 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -752,8 +760,8 @@ packages: 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==} + oxlint@1.79.0: + resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -982,61 +990,61 @@ snapshots: '@oxc-project/types@0.144.0': {} - '@oxlint/binding-android-arm-eabi@1.78.0': + '@oxlint/binding-android-arm-eabi@1.79.0': optional: true - '@oxlint/binding-android-arm64@1.78.0': + '@oxlint/binding-android-arm64@1.79.0': optional: true - '@oxlint/binding-darwin-arm64@1.78.0': + '@oxlint/binding-darwin-arm64@1.79.0': optional: true - '@oxlint/binding-darwin-x64@1.78.0': + '@oxlint/binding-darwin-x64@1.79.0': optional: true - '@oxlint/binding-freebsd-x64@1.78.0': + '@oxlint/binding-freebsd-x64@1.79.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.78.0': + '@oxlint/binding-linux-arm-musleabihf@1.79.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.78.0': + '@oxlint/binding-linux-arm64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.78.0': + '@oxlint/binding-linux-arm64-musl@1.79.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.78.0': + '@oxlint/binding-linux-ppc64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.78.0': + '@oxlint/binding-linux-riscv64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.78.0': + '@oxlint/binding-linux-riscv64-musl@1.79.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.78.0': + '@oxlint/binding-linux-s390x-gnu@1.79.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.78.0': + '@oxlint/binding-linux-x64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-x64-musl@1.78.0': + '@oxlint/binding-linux-x64-musl@1.79.0': optional: true - '@oxlint/binding-openharmony-arm64@1.78.0': + '@oxlint/binding-openharmony-arm64@1.79.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.78.0': + '@oxlint/binding-win32-arm64-msvc@1.79.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.78.0': + '@oxlint/binding-win32-ia32-msvc@1.79.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.78.0': + '@oxlint/binding-win32-x64-msvc@1.79.0': optional: true '@quansync/fs@1.0.0': @@ -1294,6 +1302,8 @@ snapshots: hookable@6.1.1: {} + husky@9.1.7: {} + import-without-cache@0.4.0: {} ioredis@5.11.1: @@ -1337,27 +1347,27 @@ snapshots: obug@2.1.4: {} - oxlint@1.78.0: + oxlint@1.79.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 + '@oxlint/binding-android-arm-eabi': 1.79.0 + '@oxlint/binding-android-arm64': 1.79.0 + '@oxlint/binding-darwin-arm64': 1.79.0 + '@oxlint/binding-darwin-x64': 1.79.0 + '@oxlint/binding-freebsd-x64': 1.79.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 + '@oxlint/binding-linux-arm-musleabihf': 1.79.0 + '@oxlint/binding-linux-arm64-gnu': 1.79.0 + '@oxlint/binding-linux-arm64-musl': 1.79.0 + '@oxlint/binding-linux-ppc64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-musl': 1.79.0 + '@oxlint/binding-linux-s390x-gnu': 1.79.0 + '@oxlint/binding-linux-x64-gnu': 1.79.0 + '@oxlint/binding-linux-x64-musl': 1.79.0 + '@oxlint/binding-openharmony-arm64': 1.79.0 + '@oxlint/binding-win32-arm64-msvc': 1.79.0 + '@oxlint/binding-win32-ia32-msvc': 1.79.0 + '@oxlint/binding-win32-x64-msvc': 1.79.0 picomatch@4.0.5: {} diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc index 98ea68b..7ca0562 100644 --- a/scripts/deno.jsonc +++ b/scripts/deno.jsonc @@ -3,7 +3,8 @@ "@libs/diff": "jsr:@libs/diff@4.0.0", "@std/cli": "jsr:@std/cli@1.0.15", "@std/path": "jsr:@std/path@1.1.6", - "@std/yaml": "jsr:@std/yaml@1.0.4" + "@std/yaml": "jsr:@std/yaml@1.0.4", + "arktype": "npm:arktype@2.2.3" }, "tasks": { "manifest:generate": "./tools/generate-platform-manifest.ts", diff --git a/scripts/deno.lock b/scripts/deno.lock index 6d6d177..f2c883d 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -5,7 +5,8 @@ "jsr:@std/cli@1.0.15": "1.0.15", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@1.1.6": "1.1.6", - "jsr:@std/yaml@1.0.4": "1.0.4" + "jsr:@std/yaml@1.0.4": "1.0.4", + "npm:arktype@2.2.3": "2.2.3" }, "jsr": { "@libs/diff@4.0.0": { @@ -27,12 +28,38 @@ "integrity": "f1e38fa5e110ff8550e938715e52c33347676dd00a1db858e092dd21a9df9941" } }, + "npm": { + "@ark/schema@0.56.2": { + "integrity": "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==", + "dependencies": [ + "@ark/util" + ] + }, + "@ark/util@0.56.2": { + "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==" + }, + "arkregex@0.0.8": { + "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", + "dependencies": [ + "@ark/util" + ] + }, + "arktype@2.2.3": { + "integrity": "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==", + "dependencies": [ + "@ark/schema", + "@ark/util", + "arkregex" + ] + } + }, "workspace": { "dependencies": [ "jsr:@libs/diff@4.0.0", "jsr:@std/cli@1.0.15", "jsr:@std/path@1.1.6", - "jsr:@std/yaml@1.0.4" + "jsr:@std/yaml@1.0.4", + "npm:arktype@2.2.3" ] } } diff --git a/scripts/guards/check-remote-master.ts b/scripts/guards/check-remote-master.ts new file mode 100755 index 0000000..718797d --- /dev/null +++ b/scripts/guards/check-remote-master.ts @@ -0,0 +1,241 @@ +#!/usr/bin/env -S deno run --allow-run=git --allow-read=.,/tmp --allow-write=/tmp +/** + * Pre-push gate: refuse when a pushed branch ref does not contain remote `master`. + * + * Local `master` is not consulted — it can lag. Query the remote tip; require + * it to be an ancestor of each refs/heads/* SHA. Tags and deletes are ignored. + * A failed query refuses. + * + * Permissions: git only; read cwd (the repo) and /tmp (test trees); write + * /tmp for tests. No --allow-env (git inherits). No --allow-net (git speaks). + */ +import { type } from 'arktype' + +const CommitSha = type('/^[0-9a-f]{40}([0-9a-f]{24})?$/#CommitSha') +const RefName = type('/^\\S+$/#RefName') +const ZeroSha = type('/^0+$/') + +const Delete = type({ kind: "'Delete'" }) +const Ignore = type({ kind: "'Ignore'" }) +const Contains = type({ kind: "'Contains'", name: RefName, sha: CommitSha }) +const Behind = type({ kind: "'Behind'", name: RefName, sha: CommitSha }) +const RefCheck = Delete.or(Ignore).or(Contains).or(Behind) +type RefCheck = typeof RefCheck.infer + +const NoRemoteMain = type({ kind: "'NoRemoteMain'" }) +const RemoteMain = type({ + kind: "'RemoteMain'", + sha: CommitSha, + refs: RefCheck.array(), +}) +const Check = NoRemoteMain.or(RemoteMain) +type Check = typeof Check.infer + +const AllowNoRemoteMain = type({ kind: "'AllowNoRemoteMain'" }) +const AllowContainsRemoteMain = type({ kind: "'AllowContainsRemoteMain'" }) +const RefuseBehindRemoteMain = type({ + kind: "'RefuseBehindRemoteMain'", + sha: CommitSha, + offenders: Behind.array().atLeastLength(1), +}) +const Verdict = AllowNoRemoteMain.or(AllowContainsRemoteMain).or(RefuseBehindRemoteMain) +type Verdict = typeof Verdict.infer + +const Update = type({ kind: "'Update'", name: RefName, sha: CommitSha }) +const PushUpdate = Delete.or(Ignore).or(Update) +type PushUpdate = typeof PushUpdate.infer + +const DecodeFailure = type({ kind: "'DecodeFailure'", detail: 'string' }) +type DecodeFailure = typeof DecodeFailure.infer + +type GateOutput = { + readonly code: 0 | 1 + readonly lines: readonly string[] +} + +const fail = (detail: string): DecodeFailure => DecodeFailure.assert({ kind: 'DecodeFailure', detail }) + +const decodeLsRemote = (stdout: string): Check | DecodeFailure => { + const line = stdout.trim() + if (line.length === 0) return NoRemoteMain.assert({ kind: 'NoRemoteMain' }) + const shaRaw = line.split(/[\t ]/)[0] ?? '' + if (!CommitSha.allows(shaRaw)) return fail('malformed ls-remote') + return RemoteMain.assert({ kind: 'RemoteMain', sha: CommitSha.assert(shaRaw), refs: [] }) +} + +const decodePushLine = (raw: string): PushUpdate | DecodeFailure => { + const fields = raw.trim().split(/[ \t]+/) + if (fields.length < 4) return fail('malformed push line') + const localRef = fields[0] ?? '' + const localSha = fields[1] ?? '' + if (ZeroSha.allows(localSha)) return Delete.assert({ kind: 'Delete' }) + if (!localRef.startsWith('refs/heads/')) return Ignore.assert({ kind: 'Ignore' }) + if (!RefName.allows(localRef)) return fail('not a ref') + if (!CommitSha.allows(localSha)) return fail('not a sha') + return Update.assert({ + kind: 'Update', + name: RefName.assert(localRef), + sha: CommitSha.assert(localSha), + }) +} + +const decodePushStdin = (stdin: string): readonly PushUpdate[] | DecodeFailure => { + const decoded: PushUpdate[] = [] + for (const line of stdin.split('\n').map((row) => row.trim()).filter((row) => row.length > 0)) { + const one = decodePushLine(line) + if (DecodeFailure.allows(one)) return one + decoded.push(one) + } + return decoded +} + +const decide = (check: Check): Verdict => { + if (NoRemoteMain.allows(check)) return AllowNoRemoteMain.assert({ kind: 'AllowNoRemoteMain' }) + const offenders = check.refs.filter((ref: RefCheck): ref is typeof Behind.infer => Behind.allows(ref)) + if (offenders.length === 0) return AllowContainsRemoteMain.assert({ kind: 'AllowContainsRemoteMain' }) + return RefuseBehindRemoteMain.assert({ + kind: 'RefuseBehindRemoteMain', + sha: check.sha, + offenders, + }) +} + +const shape = (verdict: Verdict): GateOutput => { + if (!RefuseBehindRemoteMain.allows(verdict)) return { code: 0, lines: [] } + return { + code: 1, + lines: [ + `pre-push: behind remote master (${verdict.sha}).`, + ...verdict.offenders.map((ref: typeof Behind.infer) => ` ${ref.name} ${ref.sha}`), + 'Fetch and rebase onto remote master before pushing.', + ], + } +} + +const refuseQuery = (remote: string, detail: string): GateOutput => ({ + code: 1, + lines: [`pre-push: could not query ${remote} master — ${detail}`], +}) + +const refuseFetch = (remote: string, detail: string): GateOutput => ({ + code: 1, + lines: [`pre-push: could not fetch ${remote} master — ${detail}`], +}) + +const refuseDecode = (failure: DecodeFailure): GateOutput => ({ + code: 1, + lines: [`pre-push: ${failure.detail}`], +}) + +const dec = new TextDecoder() + +type GitResult = { readonly success: boolean; readonly stdout: string; readonly stderr: string } + +const gitAt = (cwd: string, tally?: { n: number }) => async (args: readonly string[]): Promise => { + if (tally !== undefined) tally.n += 1 + const out = await new Deno.Command('git', { + args: [...args], + cwd, + stdout: 'piped', + stderr: 'piped', + }).output() + return { success: out.success, stdout: dec.decode(out.stdout), stderr: dec.decode(out.stderr) } +} + +const firstRemote = (stdout: string, pushRemote: string | undefined): string => { + const names = stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0) + if (names.includes('origin')) return 'origin' + if (pushRemote !== undefined && pushRemote.length > 0) return pushRemote + return names[0] ?? 'origin' +} + +const ensureObject = async ( + git: (args: readonly string[]) => Promise, + remote: string, + sha: typeof CommitSha.infer, +): Promise => { + const has = await git(['cat-file', '-e', `${sha}^{commit}`]) + if (has.success) return null + const bySha = await git(['fetch', '--quiet', remote, sha]) + if (bySha.success) return null + const byRef = await git(['fetch', '--quiet', remote, 'refs/heads/master']) + if (byRef.success) return null + return refuseFetch(remote, (byRef.stderr || bySha.stderr).trim() || 'fetch failed') +} + +const asRefCheck = async ( + git: (args: readonly string[]) => Promise, + trunkSha: typeof CommitSha.infer, + update: PushUpdate, +): Promise => { + if (Delete.allows(update)) return Delete.assert({ kind: 'Delete' }) + if (Ignore.allows(update)) return Ignore.assert({ kind: 'Ignore' }) + const ancestor = await git(['merge-base', '--is-ancestor', trunkSha, update.sha]) + return ancestor.success + ? Contains.assert({ kind: 'Contains', name: update.name, sha: update.sha }) + : Behind.assert({ kind: 'Behind', name: update.name, sha: update.sha }) +} + +const headUpdate = async ( + git: (args: readonly string[]) => Promise, +): Promise => { + const nameOut = await git(['rev-parse', '--abbrev-ref', 'HEAD']) + const shaOut = await git(['rev-parse', 'HEAD']) + const nameRaw = nameOut.stdout.trim() + const shaRaw = shaOut.stdout.trim() + if (!RefName.allows(nameRaw)) return fail('not a ref') + if (!CommitSha.allows(shaRaw)) return fail('not a sha') + return Update.assert({ + kind: 'Update', + name: RefName.assert(nameRaw), + sha: CommitSha.assert(shaRaw), + }) +} + +const run = async ( + git: (args: readonly string[]) => Promise, + pushRemote: string | undefined, + stdin: string, +): Promise => { + const remotes = await git(['remote']) + const remote = firstRemote(remotes.stdout, pushRemote) + const ls = await git(['ls-remote', remote, 'refs/heads/master']) + if (!ls.success) return refuseQuery(remote, ls.stderr.trim() || 'ls-remote failed') + + const listed = decodeLsRemote(ls.stdout) + if (DecodeFailure.allows(listed)) return refuseDecode(listed) + if (NoRemoteMain.allows(listed)) return shape(decide(listed)) + + const missing = await ensureObject(git, remote, listed.sha) + if (missing !== null) return missing + + const parsed = decodePushStdin(stdin) + if (DecodeFailure.allows(parsed)) return refuseDecode(parsed) + + const updates: PushUpdate[] = [...parsed] + if (updates.length === 0) { + const head = await headUpdate(git) + if (DecodeFailure.allows(head)) return refuseDecode(head) + updates.push(head) + } + + const refs: RefCheck[] = [] + for (const update of updates) { + refs.push(await asRefCheck(git, listed.sha, update)) + } + return shape(decide(RemoteMain.assert({ kind: 'RemoteMain', sha: listed.sha, refs }))) +} +const readStdin = async (): Promise => { + if (Deno.stdin.isTerminal()) return '' + return new TextDecoder().decode(await new Response(Deno.stdin.readable).arrayBuffer()) +} +if (import.meta.main) { + try { + const output = await run(gitAt(Deno.cwd()), Deno.args[0], await readStdin()) + for (const line of output.lines) console.error(line) + Deno.exitCode = output.code + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + Deno.exitCode = 1 + } +}