From d903a944458e21b57716d13ca25265fa3a33c31d Mon Sep 17 00:00:00 2001 From: naashw <32315153+naashw@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:07:51 +0200 Subject: [PATCH 01/89] chore(quality): add coverage, dead-code, commit-message and secret gates --- .github/workflows/quality.yml | 4 +- .gitignore | 4 + bun.lock | 253 +++++++++++++++++++++++- bunfig.toml | 91 +++++++++ commitlint.config.mjs | 52 +++++ knip.json | 60 ++++++ lefthook.yml | 27 ++- package.json | 5 + packages/cli/src/task-isolation.ts | 15 +- packages/cli/src/task-runner.ts | 3 + packages/web/src/composables/useDiff.ts | 4 + scripts/coverage-gate.mjs | 109 ++++++++++ 12 files changed, 617 insertions(+), 10 deletions(-) create mode 100644 bunfig.toml create mode 100644 commitlint.config.mjs create mode 100644 knip.json create mode 100644 scripts/coverage-gate.mjs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3befc7a..7087094 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -27,13 +27,15 @@ jobs: - run: bun run lint + - run: bun run knip + - run: bun run format:check - run: bun run typecheck - run: bun run build - - run: bun run test + - run: bun run test:coverage - run: bunx publint packages/cli diff --git a/.gitignore b/.gitignore index b7cc7b3..7e34147 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ dist/ web-dist/ .codesema/ *.log + +# only produced when the coverage run is asked for a file reporter (lcov); +# the default text reporter of scripts/coverage-gate.mjs writes nothing to disk +coverage/ .DS_Store .env .env.* diff --git a/bun.lock b/bun.lock index cc0a0c2..3202591 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,11 @@ "": { "name": "codesema-monorepo", "devDependencies": { + "@commitlint/cli": "21.2.2", + "@commitlint/config-conventional": "21.2.2", "@ianvs/prettier-plugin-sort-imports": "4.7.1", "@types/bun": "^1.3.14", + "knip": "6.32.2", "lefthook": "2.1.10", "oxlint": "1.74.0", "prettier": "3.9.5", @@ -16,7 +19,7 @@ }, "packages/cli": { "name": "codesema", - "version": "0.12.1", + "version": "0.13.0", "bin": { "codesema": "dist/index.mjs", }, @@ -29,7 +32,7 @@ }, "packages/contract": { "name": "@codesema/contract", - "version": "0.5.0", + "version": "0.6.0", "devDependencies": { "@types/node": "^26.1.1", "tsdown": "^0.22.5", @@ -73,9 +76,47 @@ "@codesema/web": ["@codesema/web@workspace:packages/web"], - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@commitlint/cli": ["@commitlint/cli@21.2.2", "", { "dependencies": { "@commitlint/config-conventional": "^21.2.2", "@commitlint/format": "^21.2.2", "@commitlint/lint": "^21.2.2", "@commitlint/load": "^21.2.2", "@commitlint/read": "^21.2.1", "@commitlint/types": "^21.2.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" } }, "sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@commitlint/config-conventional": ["@commitlint/config-conventional@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "conventional-changelog-conventionalcommits": "^10.0.0" } }, "sha512-NxA37SZviusFUEYOQZ5hNnZ1h7O/KiemPkxjOlpzKJNnWxThiwc6/SaZhaPa8fyLvfRBAywhQhJJk8XESHWlpQ=="], + + "@commitlint/config-validator": ["@commitlint/config-validator@21.2.0", "", { "dependencies": { "@commitlint/types": "^21.2.0", "ajv": "^8.11.0" } }, "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA=="], + + "@commitlint/ensure": ["@commitlint/ensure@21.2.0", "", { "dependencies": { "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0" } }, "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg=="], + + "@commitlint/execute-rule": ["@commitlint/execute-rule@21.0.1", "", {}, "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA=="], + + "@commitlint/format": ["@commitlint/format@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "picocolors": "^1.1.1" } }, "sha512-v6fvxZSc/AvVMROlr3H34+1766bZSYApRUSCAMjWamStPjKMvZ8GdvVA5YW/VQNgbFTmcMz6OYmSTJEvIjPrfA=="], + + "@commitlint/is-ignored": ["@commitlint/is-ignored@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "semver": "^7.6.0" } }, "sha512-9UoKNgfFE3LU7FrzierCvk3CdDfMDeVGC86qZiT/n0TIjfq/dmZ9MHuXd45OTNRa26ZanmJRxEtmiXk/lEJihg=="], + + "@commitlint/lint": ["@commitlint/lint@21.2.2", "", { "dependencies": { "@commitlint/is-ignored": "^21.2.2", "@commitlint/parse": "^21.2.2", "@commitlint/rules": "^21.2.2", "@commitlint/types": "^21.2.0" } }, "sha512-Fy8JxEBzdmsYWFude/61GxXu5O+wEymwiRK2z9GL9R8mCsXphCoGxAFc5iHn5mjlfcSrhiiONE+ksf4KOjnaPg=="], + + "@commitlint/load": ["@commitlint/load@21.2.2", "", { "dependencies": { "@commitlint/config-validator": "^21.2.0", "@commitlint/execute-rule": "^21.0.1", "@commitlint/resolve-extends": "^21.2.2", "@commitlint/types": "^21.2.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1" } }, "sha512-0Tt6wDPX167cjKC5D4zhm0+20wJJG+TN/TKovMOspfSe78rOnKX+MNzlVNiu6HyQPZChPJ8QBH31MVt6Bb8fCg=="], + + "@commitlint/message": ["@commitlint/message@21.2.0", "", {}, "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g=="], + + "@commitlint/parse": ["@commitlint/parse@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "conventional-changelog-angular": "^9.0.0", "conventional-commits-parser": "^7.0.0" } }, "sha512-MEkobPfvRp+z06Wro8HMG1BDGHzZmj82A1LH1nWeG3ipHpg/x4m6v3wEDvMBIKjRFUnfR3nBeFs3MVCr7UdAmg=="], + + "@commitlint/read": ["@commitlint/read@21.2.1", "", { "dependencies": { "@commitlint/top-level": "^21.2.0", "@commitlint/types": "^21.2.0", "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" } }, "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA=="], + + "@commitlint/resolve-extends": ["@commitlint/resolve-extends@21.2.2", "", { "dependencies": { "@commitlint/config-validator": "^21.2.0", "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" } }, "sha512-RPkJ/IFi7sMUUVbZLqwWFtWw/zRDcfFsmrPSiTMrt5wb7AdxOr86EGQFvmGzef5QKV5IPBWWCujqVTw1RWX44A=="], + + "@commitlint/rules": ["@commitlint/rules@21.2.2", "", { "dependencies": { "@commitlint/ensure": "^21.2.0", "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", "@commitlint/types": "^21.2.0" } }, "sha512-eplQzyYkBjYB1HyyRj8hkcK11Y9DU9nuBz7uOKEd6NpE9NGDytLFCAnlRE+OoiK/5sHEJsaz2RGhuWBvYzIbNA=="], + + "@commitlint/to-lines": ["@commitlint/to-lines@21.0.1", "", {}, "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw=="], + + "@commitlint/top-level": ["@commitlint/top-level@21.2.0", "", { "dependencies": { "escalade": "^3.2.0" } }, "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ=="], + + "@commitlint/types": ["@commitlint/types@21.2.0", "", { "dependencies": { "conventional-commits-parser": "^7.0.0", "picocolors": "^1.1.1" } }, "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw=="], + + "@conventional-changelog/git-client": ["@conventional-changelog/git-client@3.1.2", "", { "dependencies": { "@simple-libs/child-process-utils": "^2.0.0", "@simple-libs/stream-utils": "^2.0.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^6.0.1", "conventional-commits-parser": "^7.1.2" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A=="], + + "@conventional-changelog/template": ["@conventional-changelog/template@1.4.0", "", {}, "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg=="], + + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], @@ -91,7 +132,83 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.143.0", "", { "os": "android", "cpu": "arm" }, "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.143.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.143.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.143.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.143.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.143.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.143.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.143.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.143.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.143.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.143.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw=="], + + "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.24.2", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], @@ -167,6 +284,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@2.0.0", "", { "dependencies": { "@simple-libs/stream-utils": "^2.0.0" } }, "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A=="], + + "@simple-libs/stream-utils": ["@simple-libs/stream-utils@2.0.0", "", {}, "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -247,16 +368,40 @@ "@yuku-toolchain/types": ["@yuku-toolchain/types@0.5.43", "", {}, "sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "argue-cli": ["argue-cli@3.1.0", "", {}, "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "codesema": ["codesema@workspace:packages/cli"], + "conventional-changelog-angular": ["conventional-changelog-angular@9.4.0", "", { "dependencies": { "@conventional-changelog/template": "^1.4.0" } }, "sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w=="], + + "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@10.4.0", "", { "dependencies": { "@conventional-changelog/template": "^1.4.0" } }, "sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA=="], + + "conventional-commits-parser": ["conventional-commits-parser@7.1.2", "", { "dependencies": { "@simple-libs/stream-utils": "^2.0.0", "argue-cli": "^3.1.0" }, "bin": { "conventional-commits-parser": "./dist/cli/index.js" } }, "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ=="], + + "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + + "cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -267,26 +412,68 @@ "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-toolkit": ["es-toolkit@1.51.0", "", {}, "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], + + "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="], + "lefthook": ["lefthook@2.1.10", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.10", "lefthook-darwin-x64": "2.1.10", "lefthook-freebsd-arm64": "2.1.10", "lefthook-freebsd-x64": "2.1.10", "lefthook-linux-arm64": "2.1.10", "lefthook-linux-x64": "2.1.10", "lefthook-openbsd-arm64": "2.1.10", "lefthook-openbsd-x64": "2.1.10", "lefthook-windows-arm64": "2.1.10", "lefthook-windows-x64": "2.1.10" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nw+X8wRNDoUUV6WSteyKBbcLySq+fsmZt5WV/s50ZJpysmsDKJOUMln6SllNfP+60dzUahAO7REco/2633BsLg=="], @@ -333,6 +520,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -345,10 +534,18 @@ "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "oxc-parser": ["oxc-parser@0.143.0", "", { "dependencies": { "@oxc-project/types": "^0.143.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.143.0", "@oxc-parser/binding-android-arm64": "0.143.0", "@oxc-parser/binding-darwin-arm64": "0.143.0", "@oxc-parser/binding-darwin-x64": "0.143.0", "@oxc-parser/binding-freebsd-x64": "0.143.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", "@oxc-parser/binding-linux-arm64-musl": "0.143.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-musl": "0.143.0", "@oxc-parser/binding-openharmony-arm64": "0.143.0", "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", "@oxc-parser/binding-win32-x64-msvc": "0.143.0" } }, "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA=="], + + "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], + "oxlint": ["oxlint@1.74.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.74.0", "@oxlint/binding-android-arm64": "1.74.0", "@oxlint/binding-darwin-arm64": "1.74.0", "@oxlint/binding-darwin-x64": "1.74.0", "@oxlint/binding-freebsd-x64": "1.74.0", "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", "@oxlint/binding-linux-arm-musleabihf": "1.74.0", "@oxlint/binding-linux-arm64-gnu": "1.74.0", "@oxlint/binding-linux-arm64-musl": "1.74.0", "@oxlint/binding-linux-ppc64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-musl": "1.74.0", "@oxlint/binding-linux-s390x-gnu": "1.74.0", "@oxlint/binding-linux-x64-gnu": "1.74.0", "@oxlint/binding-linux-x64-musl": "1.74.0", "@oxlint/binding-openharmony-arm64": "1.74.0", "@oxlint/binding-win32-arm64-msvc": "1.74.0", "@oxlint/binding-win32-ia32-msvc": "1.74.0", "@oxlint/binding-win32-x64-msvc": "1.74.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA=="], "package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -363,6 +560,10 @@ "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], @@ -373,8 +574,16 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -387,6 +596,8 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -401,10 +612,40 @@ "vue-tsc": ["vue-tsc@3.3.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.7" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "yuku-ast": ["yuku-ast@0.1.7", "", { "dependencies": { "@yuku-toolchain/types": "0.5.43" } }, "sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA=="], "yuku-codegen": ["yuku-codegen@0.5.48", "", { "dependencies": { "@yuku-toolchain/types": "0.5.43" }, "optionalDependencies": { "@yuku-codegen/binding-darwin-arm64": "0.5.48", "@yuku-codegen/binding-darwin-x64": "0.5.48", "@yuku-codegen/binding-freebsd-x64": "0.5.48", "@yuku-codegen/binding-linux-arm-gnu": "0.5.48", "@yuku-codegen/binding-linux-arm-musl": "0.5.48", "@yuku-codegen/binding-linux-arm64-gnu": "0.5.48", "@yuku-codegen/binding-linux-arm64-musl": "0.5.48", "@yuku-codegen/binding-linux-x64-gnu": "0.5.48", "@yuku-codegen/binding-linux-x64-musl": "0.5.48", "@yuku-codegen/binding-win32-arm64": "0.5.48", "@yuku-codegen/binding-win32-x64": "0.5.48" } }, "sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw=="], "yuku-parser": ["yuku-parser@0.5.48", "", { "dependencies": { "@yuku-toolchain/types": "0.5.43" }, "optionalDependencies": { "@yuku-parser/binding-darwin-arm64": "0.5.48", "@yuku-parser/binding-darwin-x64": "0.5.48", "@yuku-parser/binding-freebsd-x64": "0.5.48", "@yuku-parser/binding-linux-arm-gnu": "0.5.48", "@yuku-parser/binding-linux-arm-musl": "0.5.48", "@yuku-parser/binding-linux-arm64-gnu": "0.5.48", "@yuku-parser/binding-linux-arm64-musl": "0.5.48", "@yuku-parser/binding-linux-x64-gnu": "0.5.48", "@yuku-parser/binding-linux-x64-musl": "0.5.48", "@yuku-parser/binding-win32-arm64": "0.5.48", "@yuku-parser/binding-win32-x64": "0.5.48" } }, "sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "cosmiconfig-typescript-loader/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + + "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..ffe34d1 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,91 @@ +# Runtime configuration for bun. Today this file exists for one reason only: +# making test coverage a measured, blocking gate instead of a number nobody +# looks at. +# +# Every key below was verified against the installed binary (bun 1.3.14) rather +# than taken from memory or from the docs — `strings $(which bun) | grep -i +# coverage` lists exactly seven coverage keys, and bun ignores anything else +# SILENTLY. A typo here does not error: it produces a gate that always passes. +# If you edit this file, re-run `bun run test:coverage` and check that the +# coverage table still prints. Silence is the failure mode, not a stack trace. + +[test] + +# --------------------------------------------------------------------------- +# There is deliberately NO `coverage = ...` key here. Do not add one. +# +# Writing `coverage = false` — the documented default, so it reads as a harmless +# no-op — actually OVERRIDES the `--coverage` CLI flag on bun 1.3.14. With that +# line present, `bun test --coverage` prints no table, measures nothing, and +# passes every threshold below. Omitting the key is what keeps coverage opt-in +# per run while leaving the flag functional. +# +# Opt-in is what we want: coverageThreshold is checked against every file loaded +# by the run, so turning coverage on globally would make `bun test packages/cli/ +# src/git` on a single file trip the gate constantly and train everyone to +# ignore it. Coverage belongs to the full-suite script and to CI. +# --------------------------------------------------------------------------- + +# Test files are not shipped code, and they are ~100% covered by construction +# (a test file runs start to finish), so counting them pads the numbers and +# hides regressions in the code that actually reaches users. bun already leaves +# them out of the table here, but this default has moved between versions — pin +# it rather than inherit whatever the next bun decides. +coverageSkipTestFiles = true + +# packages/contract is built (`build:contract`) before the suite runs, and both +# packages/cli and packages/web import the BUILT `packages/contract/dist/index.mjs` +# rather than the sources. Without this exclusion that bundle appears in the +# table as a ~2400-line generated file at ~59% lines: the same logic as +# packages/contract/src/*.ts, which is already measured at ~100%, just counted a +# second time in minified form. Measuring a build artifact says nothing about +# test quality — and because bun checks the threshold per file (see below), that +# one artifact alone would pin the floor to 59% forever. +coveragePathIgnorePatterns = ["**/dist/**"] + +# --------------------------------------------------------------------------- +# coverageThreshold is enforced PER FILE, not on the "All files" aggregate. +# +# This is the single most surprising thing about bun's coverage gate and it is +# not what the docs imply. Verified experimentally on bun 1.3.14 with a two-file +# fixture (one file at 100% functions, one at 50%, aggregate 75%): +# +# coverageThreshold = { functions = 0.6, lines = 0.0 } -> exit 1 +# coverageThreshold = { functions = 0.5, lines = 0.0 } -> exit 0 +# +# 0.6 fails even though the aggregate is 75%, and 0.5 passes because that is the +# WORST file. The effective threshold is therefore min(per-file), which for this +# repo is dictated by its least-tested module, not by its overall health. +# +# Second trap: a partial table fills the metrics you omit with the value you +# gave. `{ lines = 0.85 }` also applies 0.85 to functions and fails instantly on +# any file without full function coverage. ALWAYS write both keys explicitly. +# +# Values are fractions (0-1): 0.02 means 2%, not 2. +# --------------------------------------------------------------------------- +# +# Where these two numbers come from — full suite, 3600 tests across 93 files, +# build artifacts excluded, aggregate 86.55% functions / 85.94% lines: +# +# functions: five files sit at exactly 0.00% (open.ts, notify.ts, export.ts, +# show.ts, DiffView.vue — all untested today), so any non-zero +# per-file function floor fails the build on day one. 0.0 is the +# only value that passes right now. It gates nothing yet; it is +# here so the key exists and cannot be forgotten when those files +# get their first tests. Raise it the moment the worst one moves. +# +# lines: the worst file is useFixPrompt.ts at 2.94%, so 0.02 is the +# highest floor the repo currently clears. Weak, but not nothing: +# it blocks a brand-new module whose lines never execute at all. +# +# This is a ratchet, and its rungs are low on purpose so that it is green today. +# When you add tests to one of the files named above, raise the corresponding +# floor to the new worst-file value in the SAME pull request — that is the only +# thing keeping this from decaying into a rubber stamp. Never lower a value to +# turn a build green: that trades a one-line diff for the entire signal. +# +# Because these are per-file floors, they cannot catch a slow slide in overall +# coverage. That aggregate check lives in scripts/coverage-gate.mjs (run by the +# `test:coverage` script): it reads the "All files" row and fails below 86% +# functions / 85% lines. The two halves are complementary — keep both. +coverageThreshold = { functions = 0.0, lines = 0.02 } diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 0000000..3e10d16 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,52 @@ +/** + * Conventional Commits, appliqué au `commit-msg` par lefthook. + * + * Deux écarts au preset, tous deux calés sur ce que fait RÉELLEMENT ce repo + * plutôt que sur le défaut : + * + * - `header-max-length` monté à 140. Le défaut du preset est 100, et deux + * sujets de l'historique le dépassent déjà (132 et 117 caractères) : ce sont + * des sujets descriptifs assumés, pas des accidents. Un plafond qui refuse + * la pratique établie du repo serait changé ou contourné dès la première + * gêne — 140 reste une vraie borne tout en laissant passer ce qui s'écrit ici. + * + * - `merge` ajouté au `type-enum`. Les merges de contexte sont écrits à la main + * sous cette forme (`merge: main into feat/...`) et ne sont PAS attrapés par + * les ignores par défaut, qui ne reconnaissent que les formulations de git et + * des forges (« Merge pull request », « Merge branch X »). + * + * Les commits que codesema génère lui-même sont ignorés, cf. `ignores` ci-dessous. + */ +export default { + extends: ['@commitlint/config-conventional'], + /* + * `task(): — turn ` est le message que le task runner compose + * lui-même à chaque tour (packages/cli/src/task-runner.ts). Son sujet est le + * titre de tâche saisi par la personne qui l'a lancée : langue, casse et + * longueur sont hors de notre contrôle, et un hook qui refuse ces commits + * casserait l'outil dans son propre dépôt. On les laisse donc passer en bloc + * plutôt que de tordre les règles qui, elles, s'appliquent aux humains. + */ + ignores: [(message) => /^task\([0-9a-f]+\): /.test(message)], + rules: { + 'header-max-length': [2, 'always', 140], + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'merge', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], + }, +} diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..89fd926 --- /dev/null +++ b/knip.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + + // An export that is also referenced inside its own file is not dead code: the + // symbol is alive, only the `export` keyword is superfluous. This repo does + // that a lot on purpose — module-local constants and helpers are exported so + // they read as the module's documented vocabulary and so a test can reach for + // them without a refactor. Left on, that habit would report 64 exports and 87 + // types and bury the handful of symbols that are genuinely referenced from + // nowhere. Flip this to `false` for a one-off "what have we over-exported?" + // audit; it is not meant to run that way in the gate. + "ignoreExportsUsedInFile": true, + + // DEBT — real dead code, found by the very run that this config gates. Four + // symbols have ZERO references anywhere: not in a sibling module, not in a + // test, not in a .vue template. They are tagged `@deadcode` where they stand + // rather than ignored by file: a whole-file ignore would blind the gate to + // any NEW dead export in `task-isolation.ts` and `task-runner.ts`, the two + // largest files of the CLI. The tag is on the symbol, so the rest of each + // file stays checked, and whoever opens the file sees the debt. + // + // packages/cli/src/task-isolation.ts BUN_CLAUDE_INSTALL_COMMAND (const) + // packages/cli/src/task-isolation.ts hostClaudeVersion (function) + // packages/cli/src/task-runner.ts DEFAULT_MAX_PARALLEL_TASKS (const, + // documented INERT since T1.2) + // packages/web/src/composables/useDiff.ts SplitRowKind (type) + // + // Delete the symbol, delete its tag; when the last one goes, delete this too. + "tags": ["-deadcode"], + + // `entry` and `project` at the root of the config are IGNORED once + // `workspaces` exists — the root package is the workspace named ".". + "workspaces": { + ".": { + // DEBT — `unrun` sits in the root devDependencies and nothing uses it: no + // script, no source file, no CI job. Drop the dependency, then drop this. + "ignoreDependencies": ["unrun"] + }, + + "packages/cli": { + // The eval harness is started by hand (`bun eval/run.ts`), so no manifest + // script points at it and knip would call the whole harness unused. + // Nothing else needs declaring here: `src/index.ts` matches knip's default + // entry pattern, `scripts/*.mjs` are reached through the package's + // build/prepack scripts, and `*.test.ts` files are entry points already + // because the Bun plugin reads the root `test:only` script. + "entry": ["eval/run.ts"] + }, + + "packages/web": { + // `src/types.ts` mirrors the CLI contract by hand (see the file header). + // Its surface is dictated by the contract, not by whatever the app happens + // to read today, so a mirror type nobody imports yet is intended and not + // dead code. Scoped to that one file so the rest of the app stays checked. + "ignoreIssues": { + "src/types.ts": ["exports", "types"] + } + } + } +} diff --git a/lefthook.yml b/lefthook.yml index 21e0926..326eb5e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,10 +10,35 @@ pre-commit: glob: '*.{ts,mjs,vue}' run: bunx oxlint {staged_files} +commit-msg: + commands: + commitlint: + # Conventional Commits, cf. commitlint.config.mjs pour les deux écarts au + # preset et pour les commits que le task runner génère lui-même. + run: bunx commitlint --edit {1} + pre-push: commands: + secrets: + # Le pre-commit ne voit que l'index : un secret arrivé par un commit non + # passé par le hook (--no-verify, rebase, cherry-pick, commit d'un autre + # outil) ne serait rattrapé par rien avant la forge. On scanne donc ici + # ce qui est sur le point de partir, borné aux commits non poussés : + # l'historique antérieur a déjà été scanné aux pushs précédents et un + # full-scan croît avec le dépôt. Repli sur le full-scan quand la branche + # n'a pas encore d'upstream (premier push, `@{push}` irrésolu). + run: | + if remote_head="$(git rev-parse '@{push}' 2>/dev/null)"; then + gitleaks git --no-banner --redact --log-opts="$remote_head..HEAD" . + else + gitleaks git --no-banner --redact . + fi typecheck: run: bun run typecheck + knip: + # Unused files, exports and dependencies. Runs in about a second, so it + # sits here rather than waiting for CI to say it. + run: bun run knip test: # git exports its repo-location vars (GIT_DIR, GIT_WORK_TREE, ...) to the # hooks it runs; the test suite spawns git inside throwaway tmpdirs, and @@ -21,4 +46,4 @@ pre-push: # calls to the real repository (test branches, commits and config land in # the repo you are pushing from). Strip them before running the tests — # same doctrine as subprocessEnv in packages/cli/src/git.ts. - run: env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_COMMON_DIR -u GIT_PREFIX -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_QUARANTINE_PATH bun run test + run: env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_COMMON_DIR -u GIT_PREFIX -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_QUARANTINE_PATH bun run test:coverage diff --git a/package.json b/package.json index 55dab56..987bd3e 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "build:web": "bun run --cwd packages/web build", "build:cli": "bun run --cwd packages/cli build", "lint": "oxlint", + "knip": "knip", "typecheck": "bun run build:contract && bun run typecheck:only", "typecheck:only": "bun run --cwd packages/contract typecheck && bun run --cwd packages/cli typecheck && bun run --cwd packages/web typecheck", "test": "bun run build:contract && bun run test:only", "test:only": "bun test --timeout=20000 $(bun -e 'console.log(process.env.CI ? \"\" : \"--parallel=\" + Math.max(1, navigator.hardwareConcurrency >> 1))') packages/cli packages/web packages/contract", + "test:coverage": "bun run build:contract && bun scripts/coverage-gate.mjs", "verify": "bun run build:contract && bun run typecheck:only && bun run test:only", "prepare": "lefthook install || echo 'WARNING: lefthook install failed — git hooks are NOT active (no gitleaks secret scan, no format/lint on commit). Run `bun run prepare` again once git and lefthook are available.'", "format": "prettier --write . --cache", @@ -25,8 +27,11 @@ "node": ">=20" }, "devDependencies": { + "@commitlint/cli": "21.2.2", + "@commitlint/config-conventional": "21.2.2", "@ianvs/prettier-plugin-sort-imports": "4.7.1", "@types/bun": "^1.3.14", + "knip": "6.32.2", "lefthook": "2.1.10", "oxlint": "1.74.0", "prettier": "3.9.5", diff --git a/packages/cli/src/task-isolation.ts b/packages/cli/src/task-isolation.ts index 707d480..70ee383 100644 --- a/packages/cli/src/task-isolation.ts +++ b/packages/cli/src/task-isolation.ts @@ -92,7 +92,13 @@ export const CAGE_HOME_DIR = '/home/agent' /** Default install of the agent CLI into the image; injectable (tests, smoke runs). */ export const DEFAULT_CLAUDE_INSTALL_COMMAND = 'npm install -g @anthropic-ai/claude-code' -/** Bun bases have no npm: BUN_INSTALL puts the binary on the shared PATH. */ +/** + * Bun bases have no npm: BUN_INSTALL puts the binary on the shared PATH. + * + * @deadcode Nothing selects a bun base today, so nothing reads this. Kept as the + * counterpart of DEFAULT_CLAUDE_INSTALL_COMMAND until that path is either wired + * up or dropped; delete both this and the tag when the question is settled. + */ export const BUN_CLAUDE_INSTALL_COMMAND = 'BUN_INSTALL=/usr/local bun install -g @anthropic-ai/claude-code' @@ -1910,7 +1916,12 @@ export function hostAgentVersion(command: string, execFn?: IsolationExecFn): Pro return started } -/** Host claude version; wrapper around hostAgentVersion. */ +/** + * Host claude version; wrapper around hostAgentVersion. + * + * @deadcode Every caller went through hostAgentVersion directly. Delete this + * wrapper and its tag unless a caller comes back. + */ export function hostClaudeVersion(execFn?: IsolationExecFn): Promise { return hostAgentVersion('claude', execFn) } diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index 97c0015..044d4dc 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -108,6 +108,9 @@ import { * instead. This constant, `TaskSlotPool` and `TaskRunnerOptions.maxParallel` * /`.slots` stay exactly as inert as T1.2 left them, kept only so the * deprecated key still parses into a shape nothing reads for admission. + * + * @deadcode Inert by design since T1.2, per the note above — the tag records + * that knip is right, not that the constant is about to go. */ export const DEFAULT_MAX_PARALLEL_TASKS = 3 diff --git a/packages/web/src/composables/useDiff.ts b/packages/web/src/composables/useDiff.ts index a296f4f..49f8845 100644 --- a/packages/web/src/composables/useDiff.ts +++ b/packages/web/src/composables/useDiff.ts @@ -38,6 +38,10 @@ export type HunkGap = { gap: number } export type HunkBlock = { rows: HunkLine[] } | HunkGap +/** + * @deadcode No consumer left: the split view narrows on the row objects + * themselves. Delete this alias and its tag unless one comes back. + */ export type SplitRowKind = 'ctx' | 'chg' | 'note' export type SplitRow = diff --git a/scripts/coverage-gate.mjs b/scripts/coverage-gate.mjs new file mode 100644 index 0000000..b288c6a --- /dev/null +++ b/scripts/coverage-gate.mjs @@ -0,0 +1,109 @@ +/** + * Aggregate coverage gate. + * + * bunfig.toml already carries a `coverageThreshold`, but bun enforces that one + * PER FILE — the effective floor is the repo's least-tested module, and no + * per-file floor can notice the whole suite sliding down a point a month. This + * script is the other half: it runs the full suite with coverage, reads the + * "All files" row of bun's table, and fails below the floors declared here. + * + * The table is printed on STDERR, not stdout, which is why both streams are + * captured. Everything the child writes is forwarded as it arrives, so a run + * here looks exactly like a plain `bun test` run until the verdict at the end. + * + * Fail-closed by design: a missing or unparsable "All files" row is an error, + * never a pass. If bun changes its reporter format, this gate goes red and + * someone fixes the parser — the alternative is a gate that silently stops + * measuring, which is worse than having none. + */ +import { spawn } from 'node:child_process' + +/** + * Floors for the whole suite, in percent. Measured baseline at the time of + * writing: 86.55% functions / 85.94% lines over 3600 tests in 93 files. + * + * This is a ratchet. Raise these numbers when coverage improves, in the same + * pull request that improves it. Never lower one to turn a build green: the + * point of the gate is that the number cannot quietly go down. + */ +const FLOORS = { functions: 86, lines: 85 } + +/** + * Deliberately serial, unlike the `test:only` script which passes `--parallel` + * locally. Combining `--parallel` with `--coverage` on the full suite was tried + * here and did not finish within ten minutes, against roughly a minute serial; + * the cause was not chased down, because a gate that occasionally hangs is + * worse than a gate that takes a minute. If you reintroduce it, prove the full + * suite completes rather than trusting a single-package run. + */ +const TEST_ARGS = [ + 'test', + '--coverage', + '--timeout=20000', + 'packages/cli', + 'packages/web', + 'packages/contract', +] + +/** + * bun prints `All files | 86.55 | 85.94 |` — pipe-separated, functions + * then lines. Anchored on the row label so a source file whose name happens to + * start with "All" cannot be mistaken for the summary. + */ +function parseSummary(output) { + const row = output.split('\n').find((line) => /^All files\s*\|/.test(line)) + if (!row) { + return null + } + const [, functions, lines] = row.split('|') + const parsed = { functions: Number.parseFloat(functions), lines: Number.parseFloat(lines) } + return Number.isNaN(parsed.functions) || Number.isNaN(parsed.lines) ? null : parsed +} + +function runSuite() { + return new Promise((resolve, reject) => { + const child = spawn('bun', TEST_ARGS, { stdio: ['inherit', 'pipe', 'pipe'] }) + let captured = '' + for (const [stream, sink] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + stream.on('data', (chunk) => { + captured += chunk + sink.write(chunk) + }) + } + child.on('error', reject) + child.on('close', (code) => resolve({ code: code ?? 1, captured })) + }) +} + +const { code, captured } = await runSuite() + +// A failing suite is reported on its own terms: the coverage of a red run says +// nothing, and printing a gate verdict next to it only buries the real failure. +if (code !== 0) { + process.exit(code) +} + +const summary = parseSummary(captured) +if (!summary) { + console.error( + '\ncoverage gate ERROR: no parsable "All files" row in the output.\n' + + 'Either coverage did not run (check that bunfig.toml has no `coverage = false`,\n' + + 'which silently overrides --coverage) or bun changed its reporter format.', + ) + process.exit(1) +} + +const below = Object.entries(FLOORS).filter(([metric, floor]) => summary[metric] < floor) +const measured = `${summary.functions.toFixed(2)}% funcs / ${summary.lines.toFixed(2)}% lines` + +if (below.length > 0) { + const expected = `${FLOORS.functions}% funcs / ${FLOORS.lines}% lines` + console.error(`\ncoverage gate FAILED: ${measured} (floor ${expected})`) + console.error(`below floor: ${below.map(([metric]) => metric).join(', ')}`) + process.exit(1) +} + +console.log(`\ncoverage gate OK: ${measured}`) From 2173a6748f0ab23be6355493fba5ce8b4a6a3411 Mon Sep 17 00:00:00 2001 From: naashw <32315153+naashw@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:57:30 +0200 Subject: [PATCH 02/89] chore(quality): parallelise the pre-push gates and scope tests to the diff --- lefthook.yml | 74 ++++++++++++++++++++++++---------------- package.json | 1 + scripts/test-changed.mjs | 72 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 scripts/test-changed.mjs diff --git a/lefthook.yml b/lefthook.yml index 326eb5e..4c0684b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -18,32 +18,48 @@ commit-msg: run: bunx commitlint --edit {1} pre-push: - commands: - secrets: - # Le pre-commit ne voit que l'index : un secret arrivé par un commit non - # passé par le hook (--no-verify, rebase, cherry-pick, commit d'un autre - # outil) ne serait rattrapé par rien avant la forge. On scanne donc ici - # ce qui est sur le point de partir, borné aux commits non poussés : - # l'historique antérieur a déjà été scanné aux pushs précédents et un - # full-scan croît avec le dépôt. Repli sur le full-scan quand la branche - # n'a pas encore d'upstream (premier push, `@{push}` irrésolu). - run: | - if remote_head="$(git rev-parse '@{push}' 2>/dev/null)"; then - gitleaks git --no-banner --redact --log-opts="$remote_head..HEAD" . - else - gitleaks git --no-banner --redact . - fi - typecheck: - run: bun run typecheck - knip: - # Unused files, exports and dependencies. Runs in about a second, so it - # sits here rather than waiting for CI to say it. - run: bun run knip - test: - # git exports its repo-location vars (GIT_DIR, GIT_WORK_TREE, ...) to the - # hooks it runs; the test suite spawns git inside throwaway tmpdirs, and - # those inherited vars would silently redirect every one of these git - # calls to the real repository (test branches, commits and config land in - # the repo you are pushing from). Strip them before running the tests — - # same doctrine as subprocessEnv in packages/cli/src/git.ts. - run: env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_COMMON_DIR -u GIT_PREFIX -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_QUARANTINE_PATH bun run test:coverage + # Two phases. packages/contract/dist is built ONCE up front: both the + # typecheck and the test run need it, and two concurrent `build:contract` + # would race on the same output directory. Everything after that is + # independent, so it runs concurrently — the hook costs roughly what its + # slowest job costs instead of the sum of all four. + # + # The jobs below call the `:only` scripts on purpose: they are the build-free + # halves, and the build has already happened in the first phase. + piped: true + jobs: + - name: build-contract + run: bun run build:contract + - group: + parallel: true + jobs: + - name: secrets + # Le pre-commit ne voit que l'index : un secret arrivé par un commit non + # passé par le hook (--no-verify, rebase, cherry-pick, commit d'un autre + # outil) ne serait rattrapé par rien avant la forge. On scanne donc ici + # ce qui est sur le point de partir, borné aux commits non poussés : + # l'historique antérieur a déjà été scanné aux pushs précédents et un + # full-scan croît avec le dépôt. Repli sur le full-scan quand la branche + # n'a pas encore d'upstream (premier push, `@{push}` irrésolu). + run: | + if remote_head="$(git rev-parse '@{push}' 2>/dev/null)"; then + gitleaks git --no-banner --redact --log-opts="$remote_head..HEAD" . + else + gitleaks git --no-banner --redact . + fi + - name: typecheck + run: bun run typecheck:only + - name: knip + run: bun run knip + - name: test + # Only the test files affected by what is about to be pushed; the + # full suite and the coverage gate run in CI on every pull request. + # + # git exports its repo-location vars (GIT_DIR, GIT_WORK_TREE, ...) to + # the hooks it runs; the test suite spawns git inside throwaway + # tmpdirs, and those inherited vars would silently redirect every one + # of these git calls to the real repository (test branches, commits + # and config land in the repo you are pushing from). Strip them + # before running the tests — same doctrine as subprocessEnv in + # packages/cli/src/git.ts. + run: env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_COMMON_DIR -u GIT_PREFIX -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_QUARANTINE_PATH bun scripts/test-changed.mjs diff --git a/package.json b/package.json index 987bd3e..c6934f6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck:only": "bun run --cwd packages/contract typecheck && bun run --cwd packages/cli typecheck && bun run --cwd packages/web typecheck", "test": "bun run build:contract && bun run test:only", "test:only": "bun test --timeout=20000 $(bun -e 'console.log(process.env.CI ? \"\" : \"--parallel=\" + Math.max(1, navigator.hardwareConcurrency >> 1))') packages/cli packages/web packages/contract", + "test:changed": "bun run build:contract && bun scripts/test-changed.mjs", "test:coverage": "bun run build:contract && bun scripts/coverage-gate.mjs", "verify": "bun run build:contract && bun run typecheck:only && bun run test:only", "prepare": "lefthook install || echo 'WARNING: lefthook install failed — git hooks are NOT active (no gitleaks secret scan, no format/lint on commit). Run `bun run prepare` again once git and lefthook are available.'", diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs new file mode 100644 index 0000000..6287e71 --- /dev/null +++ b/scripts/test-changed.mjs @@ -0,0 +1,72 @@ +/** + * The pre-push test run: only the test files affected by what is about to be + * pushed, rather than all 93 of them. + * + * This is a speed trade, and it is only safe because it is not the last word. + * CI still runs the whole suite with the coverage gate on every pull request — + * if `--changed` misses an affected file, the pull request goes red, not the + * merge. What this buys is that the common case (a change to two modules) stops + * paying for the uncommon one. + * + * Coverage is deliberately absent here: `--coverage` combined with `--parallel` + * does not terminate on the full suite (see scripts/coverage-gate.mjs), and a + * serial coverage run is exactly the minute this script exists to avoid. + */ +import { execFileSync, spawn } from 'node:child_process' + +/** Compared against when the branch has no push destination yet — a first push. */ +const FALLBACK_BASE = 'origin/develop' + +function revParse(ref) { + try { + return execFileSync('git', ['rev-parse', '--verify', '--quiet', ref], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return null + } +} + +/** + * `@{push}` is where this branch would push, which is exactly the boundary we + * want: everything past it is new to the remote. It does not resolve on a + * branch that has never been pushed, hence the fallback to the trunk — and if + * even that is missing (a fresh clone with no remote, a renamed default + * branch), we say so and run everything rather than quietly test nothing. + */ +function resolveBase() { + return revParse('@{push}') ?? revParse(FALLBACK_BASE) +} + +/** Same policy as the `test:only` script: half the cores locally, serial in CI. */ +function parallelArgs() { + if (process.env.CI) { + return [] + } + return [`--parallel=${Math.max(1, navigator.hardwareConcurrency >> 1)}`] +} + +const base = resolveBase() +if (!base) { + console.log( + `no push destination and no ${FALLBACK_BASE} to compare against — running the full suite`, + ) +} + +const args = [ + 'test', + '--timeout=20000', + ...parallelArgs(), + ...(base ? [`--changed=${base}`] : []), + 'packages/cli', + 'packages/web', + 'packages/contract', +] + +const child = spawn('bun', args, { stdio: 'inherit' }) +child.on('error', (err) => { + console.error(err) + process.exit(1) +}) +child.on('close', (code) => process.exit(code ?? 1)) From cad5c6b31b1b6c346cd08336926260df1c9443ec Mon Sep 17 00:00:00 2001 From: naashw <32315153+naashw@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:26:26 +0200 Subject: [PATCH 03/89] chore(quality): pin out the advised transitives and gate on bun audit --- .github/workflows/quality.yml | 14 ++++++++++++++ bun.lock | 8 ++++++-- knip.json | 9 +++++++-- package.json | 5 +++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7087094..2509dab 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -29,6 +29,20 @@ jobs: - run: bun run knip + # Known advisories against the installed tree. Every package this can + # flag today is a BUILD-time dependency: `codesema` ships with no runtime + # dependencies at all, so nothing here reaches a user's machine by being + # installed. It still matters, because these packages execute during + # `bun run build` and that build produces the `dist` we publish. + # + # Two transitive packages are pinned past their advisories through the + # `overrides` block in the root package.json (postcss, and the nanoid it + # pulls). Their parents — vite, vue, @vue/compiler-sfc — still declare + # ranges that resolve to the vulnerable versions, so removing the override + # silently reintroduces both. Drop an entry only once its parent has moved + # on, and confirm with `bun run audit` rather than by reading the range. + - run: bun run audit + - run: bun run format:check - run: bun run typecheck diff --git a/bun.lock b/bun.lock index 3202591..29f94b9 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,10 @@ }, }, }, + "overrides": { + "nanoid": "^3.3.18", + "postcss": "^8.5.26", + }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -530,7 +534,7 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], @@ -552,7 +556,7 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], diff --git a/knip.json b/knip.json index 89fd926..2b842d3 100644 --- a/knip.json +++ b/knip.json @@ -32,8 +32,13 @@ // `workspaces` exists — the root package is the workspace named ".". "workspaces": { ".": { - // DEBT — `unrun` sits in the root devDependencies and nothing uses it: no - // script, no source file, no CI job. Drop the dependency, then drop this. + // NOT debt, despite how it looks: `unrun` is an OPTIONAL PEER of tsdown + // (`"unrun": "*"` in its peerDependencies), and installing it is what + // makes tsdown load tsdown.config.ts through it — the build log says so + // on every run: `config file: .../tsdown.config.ts (unrun)`. Nothing + // imports it, which is exactly why knip cannot see the link and calls it + // unused. Removing it does not break the build, it silently changes which + // config loader runs. Keep both the dependency and this line. "ignoreDependencies": ["unrun"] }, diff --git a/package.json b/package.json index c6934f6..103e840 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:cli": "bun run --cwd packages/cli build", "lint": "oxlint", "knip": "knip", + "audit": "bun audit --audit-level=high", "typecheck": "bun run build:contract && bun run typecheck:only", "typecheck:only": "bun run --cwd packages/contract typecheck && bun run --cwd packages/cli typecheck && bun run --cwd packages/web typecheck", "test": "bun run build:contract && bun run test:only", @@ -27,6 +28,10 @@ "engines": { "node": ">=20" }, + "overrides": { + "nanoid": "^3.3.18", + "postcss": "^8.5.26" + }, "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", From 60b3705ab00815bc2b09890c81b78efc4061e965 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 24 Aug 2026 10:10:47 +0200 Subject: [PATCH 04/89] feat(web): add tailwind v4 theme foundation --- bun.lock | 55 +++++++++++++++ packages/web/components.json | 22 ++++++ packages/web/package.json | 3 + packages/web/src/style.css | 118 +++++++++++++++++++++++++++++++++ packages/web/src/style.test.ts | 61 +++++++++++++++++ packages/web/tsconfig.json | 5 +- packages/web/vite.config.ts | 9 ++- 7 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 packages/web/components.json create mode 100644 packages/web/src/style.test.ts diff --git a/bun.lock b/bun.lock index 29f94b9..b02af25 100644 --- a/bun.lock +++ b/bun.lock @@ -46,7 +46,10 @@ "vue": "^3.5.39", }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.1", "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "vite": "^8.1.4", "vue-tsc": "^3.3.7", @@ -128,6 +131,8 @@ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], @@ -292,6 +297,36 @@ "@simple-libs/stream-utils": ["@simple-libs/stream-utils@2.0.0", "", {}, "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -420,6 +455,8 @@ "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -452,6 +489,8 @@ "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -588,6 +627,10 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -640,6 +683,18 @@ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cosmiconfig-typescript-loader/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], diff --git a/packages/web/components.json b/packages/web/components.json new file mode 100644 index 0000000..87c00ab --- /dev/null +++ b/packages/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/style.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide", + "pointer": false, + "rtl": false +} diff --git a/packages/web/package.json b/packages/web/package.json index e6132bf..ea95402 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,7 +12,10 @@ "vue": "^3.5.39" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.1", "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "vite": "^8.1.4", "vue-tsc": "^3.3.7" diff --git a/packages/web/src/style.css b/packages/web/src/style.css index 7b2b3f7..b8b7b58 100644 --- a/packages/web/src/style.css +++ b/packages/web/src/style.css @@ -1,3 +1,9 @@ +/* Granular Tailwind v4 import: theme.css + utilities.css only, NO preflight.css. + The 20+ existing components rely on browser default styles inside their own + scoped diff --git a/packages/web/src/components/mr/MrMetaRail.test.ts b/packages/web/src/components/mr/MrMetaRail.test.ts new file mode 100644 index 0000000..1e915ff --- /dev/null +++ b/packages/web/src/components/mr/MrMetaRail.test.ts @@ -0,0 +1,195 @@ +// Same harness as MrCard.test.ts / WorkspaceHeader.test.ts. +import { describe, expect, test } from 'bun:test' +import { createSSRApp } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' +import { t } from '../../i18n' +import type { ForgeMr } from '../../types' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +function baseMr(overrides: Partial = {}): ForgeMr { + return { + number: 42, + title: 'Add issue radar', + author: 'octocat', + sourceBranch: 'feat/issue-radar', + targetBranch: 'main', + updatedAt: '2026-08-20T10:00:00.000Z', + url: 'https://example.test/mr/42', + state: 'open', + isDraft: false, + labels: ['ui'], + additions: 120, + deletions: 40, + changedFiles: 6, + checks: { passed: 12, failed: 1, pending: 2, skipped: 0, truncated: false }, + reviewers: ['reviewer-a'], + assignees: ['assignee-a'], + milestone: 'v1', + mergeable: 'mergeable', + commits: 4, + body: 'A body', + ...overrides, + } +} + +async function renderRail(mr: ForgeMr): Promise { + const MrMetaRail = (await import('./MrMetaRail.vue')).default + const app = createSSRApp(MrMetaRail, { mr }) + return renderToString(app) +} + +describe('labels section', () => { + test('null hides the whole section', async () => { + const html = await renderRail(baseMr({ labels: null })) + expect(html).not.toContain(t('mrs.rail.labels')) + }) + + test('an empty list shows the empty state, not a blank section', async () => { + const html = await renderRail(baseMr({ labels: [] })) + expect(html).toContain(t('mrs.rail.labels')) + expect(html).toContain(t('mrs.rail.labelsEmpty')) + }) + + test('a populated list renders every label', async () => { + const html = await renderRail(baseMr({ labels: ['ui', 'backend'] })) + expect(html).toContain('ui') + expect(html).toContain('backend') + expect(html).not.toContain(t('mrs.rail.labelsEmpty')) + }) +}) + +describe('reviewers section', () => { + test('null hides the whole section', async () => { + const html = await renderRail(baseMr({ reviewers: null })) + expect(html).not.toContain(t('mrs.rail.reviewers')) + }) + + test('an empty list shows the empty state', async () => { + const html = await renderRail(baseMr({ reviewers: [] })) + expect(html).toContain(t('mrs.rail.reviewersEmpty')) + }) + + test('a populated list renders every reviewer', async () => { + const html = await renderRail(baseMr({ reviewers: ['alice', 'bob'] })) + expect(html).toContain('alice') + expect(html).toContain('bob') + }) +}) + +describe('assignees section', () => { + test('null hides the whole section', async () => { + const html = await renderRail(baseMr({ assignees: null })) + expect(html).not.toContain(t('mrs.rail.assignees')) + }) + + test('an empty list shows the empty state', async () => { + const html = await renderRail(baseMr({ assignees: [] })) + expect(html).toContain(t('mrs.rail.assigneesEmpty')) + }) + + test('a populated list renders every assignee', async () => { + const html = await renderRail(baseMr({ assignees: ['carol'] })) + expect(html).toContain('carol') + }) +}) + +describe('milestone section', () => { + test('null hides the section', async () => { + const html = await renderRail(baseMr({ milestone: null })) + expect(html).not.toContain(t('mrs.rail.milestone')) + }) + + test('a value renders', async () => { + const html = await renderRail(baseMr({ milestone: 'v2.0' })) + expect(html).toContain('v2.0') + }) +}) + +describe('mergeable section', () => { + test('null hides the section', async () => { + const html = await renderRail(baseMr({ mergeable: null })) + expect(html).not.toContain(t('mrs.rail.mergeable')) + }) + + test.each([ + ['mergeable' as const, 'mrs.rail.mergeableStateOk' as const], + ['conflicting' as const, 'mrs.rail.mergeableStateConflicting' as const], + ['unknown' as const, 'mrs.rail.mergeableStateUnknown' as const], + ])('%s renders its own distinct state', async (value, key) => { + const html = await renderRail(baseMr({ mergeable: value })) + expect(html).toContain(t(key)) + }) +}) + +describe('commits section', () => { + test('null hides the section', async () => { + const html = await renderRail(baseMr({ commits: null })) + expect(html).not.toContain(t('mrs.rail.commits')) + }) + + test('a measured zero renders, not silence', async () => { + const html = await renderRail(baseMr({ commits: 0 })) + expect(html).toContain(t('mrs.rail.commitsCount', { n: 0 }, 0)) + }) + + test('a plural count renders', async () => { + const html = await renderRail(baseMr({ commits: 5 })) + expect(html).toContain(t('mrs.rail.commitsCount', { n: 5 }, 5)) + }) +}) + +describe('auto-review: the section always renders, its four states are never confused', () => { + test('checks null: "unavailable", never "no checks"', async () => { + const html = await renderRail(baseMr({ checks: null })) + expect(html).toContain(t('mrs.rail.autoReview')) + expect(html).toContain(t('mrs.rail.autoReviewUnavailable')) + expect(html).not.toContain(t('mrs.rail.autoReviewNone')) + }) + + test('checks present but every bucket at zero, not truncated: "no checks", never "unavailable"', async () => { + const html = await renderRail( + baseMr({ checks: { passed: 0, failed: 0, pending: 0, skipped: 0, truncated: false } }), + ) + expect(html).toContain(t('mrs.rail.autoReviewNone')) + expect(html).not.toContain(t('mrs.rail.autoReviewUnavailable')) + }) + + test('checks present with real counts: passed collapses into one counter, failures and pending are spelled out', async () => { + const html = await renderRail( + baseMr({ checks: { passed: 42, failed: 2, pending: 1, skipped: 0, truncated: false } }), + ) + expect(html).toContain(t('mrs.checks.passed', { n: 42 }, 42)) + expect(html).toContain(t('mrs.checks.failed', { n: 2 }, 2)) + expect(html).toContain(t('mrs.checks.pending', { n: 1 }, 1)) + expect(html).not.toContain(t('mrs.checks.skipped', { n: 0 }, 0)) + }) + + test('skipped checks are spelled out too when present', async () => { + const html = await renderRail( + baseMr({ checks: { passed: 10, failed: 0, pending: 0, skipped: 3, truncated: false } }), + ) + expect(html).toContain(t('mrs.checks.passed', { n: 10 }, 10)) + expect(html).toContain(t('mrs.checks.skipped', { n: 3 }, 3)) + }) + + test('truncated falls back to an aggregate signal, never exact numbers', async () => { + const html = await renderRail( + baseMr({ checks: { passed: 34, failed: 1, pending: 0, skipped: 0, truncated: true } }), + ) + expect(html).toContain(t('mrs.checks.aggregateFailed')) + expect(html).not.toContain('34') + expect(html).not.toContain(t('mrs.checks.passed', { n: 34 }, 34)) + }) +}) diff --git a/packages/web/src/components/mr/MrMetaRail.vue b/packages/web/src/components/mr/MrMetaRail.vue new file mode 100644 index 0000000..6188641 --- /dev/null +++ b/packages/web/src/components/mr/MrMetaRail.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/packages/web/src/i18n.ts b/packages/web/src/i18n.ts index 0f96739..31138ea 100644 --- a/packages/web/src/i18n.ts +++ b/packages/web/src/i18n.ts @@ -181,6 +181,41 @@ const en = { 'mrs.runDualReview': 'Run dual review', 'mrs.reviewRunning': 'Reviewing…', + 'mrs.card.stateOpen': 'Open', + 'mrs.card.stateDraft': 'Draft', + 'mrs.card.stateMerged': 'Merged', + 'mrs.card.stateClosed': 'Closed', + 'mrs.card.filesChanged': '{n} file | {n} files', + 'mrs.card.diffBarLabel': '+{additions} −{deletions}', + + 'mrs.checks.passed': '{n} passed', + 'mrs.checks.failed': '{n} failed', + 'mrs.checks.pending': '{n} pending', + 'mrs.checks.skipped': '{n} skipped', + 'mrs.checks.aggregatePassed': 'checks passing', + 'mrs.checks.aggregateFailed': 'checks failing', + 'mrs.checks.aggregatePending': 'checks running', + 'mrs.checks.aggregateSkipped': 'checks skipped', + 'mrs.checks.aggregateUnknown': 'checks status unknown', + 'mrs.checks.partial': 'partial results', + + 'mrs.rail.labels': 'Labels', + 'mrs.rail.labelsEmpty': 'No labels', + 'mrs.rail.reviewers': 'Reviewers', + 'mrs.rail.reviewersEmpty': 'No reviewers assigned', + 'mrs.rail.assignees': 'Assignees', + 'mrs.rail.assigneesEmpty': 'No assignees', + 'mrs.rail.milestone': 'Milestone', + 'mrs.rail.mergeable': 'Mergeable', + 'mrs.rail.mergeableStateOk': 'Mergeable', + 'mrs.rail.mergeableStateConflicting': 'Conflicting', + 'mrs.rail.mergeableStateUnknown': 'Checking…', + 'mrs.rail.commits': 'Commits', + 'mrs.rail.commitsCount': '{n} commit | {n} commits', + 'mrs.rail.autoReview': 'Auto-review', + 'mrs.rail.autoReviewUnavailable': 'Checks status unavailable', + 'mrs.rail.autoReviewNone': 'No automated checks', + 'branches.title': 'Branches', 'branches.refresh': 'Refresh', 'branches.loading': 'Loading…', @@ -782,6 +817,41 @@ const fr: Record = { 'mrs.runDualReview': 'Lancer la revue double', 'mrs.reviewRunning': 'Revue en cours…', + 'mrs.card.stateOpen': 'Ouverte', + 'mrs.card.stateDraft': 'Brouillon', + 'mrs.card.stateMerged': 'Fusionnée', + 'mrs.card.stateClosed': 'Fermée', + 'mrs.card.filesChanged': '{n} fichier | {n} fichiers', + 'mrs.card.diffBarLabel': '+{additions} −{deletions}', + + 'mrs.checks.passed': '{n} réussi | {n} réussis', + 'mrs.checks.failed': '{n} échoué | {n} échoués', + 'mrs.checks.pending': '{n} en cours', + 'mrs.checks.skipped': '{n} ignoré | {n} ignorés', + 'mrs.checks.aggregatePassed': 'checks au vert', + 'mrs.checks.aggregateFailed': 'checks en échec', + 'mrs.checks.aggregatePending': 'checks en cours', + 'mrs.checks.aggregateSkipped': 'checks ignorés', + 'mrs.checks.aggregateUnknown': 'statut des checks inconnu', + 'mrs.checks.partial': 'résultat partiel', + + 'mrs.rail.labels': 'Labels', + 'mrs.rail.labelsEmpty': 'Aucun label', + 'mrs.rail.reviewers': 'Reviewers', + 'mrs.rail.reviewersEmpty': 'Aucun reviewer assigné', + 'mrs.rail.assignees': 'Assignés', + 'mrs.rail.assigneesEmpty': 'Aucun assigné', + 'mrs.rail.milestone': 'Jalon', + 'mrs.rail.mergeable': 'Fusionnable', + 'mrs.rail.mergeableStateOk': 'Fusionnable', + 'mrs.rail.mergeableStateConflicting': 'En conflit', + 'mrs.rail.mergeableStateUnknown': 'Vérification…', + 'mrs.rail.commits': 'Commits', + 'mrs.rail.commitsCount': '{n} commit | {n} commits', + 'mrs.rail.autoReview': 'Auto-review', + 'mrs.rail.autoReviewUnavailable': 'Statut des checks indisponible', + 'mrs.rail.autoReviewNone': 'Aucun check automatisé', + 'branches.title': 'Branches', 'branches.refresh': 'Rafraîchir', 'branches.loading': 'Chargement…', From 2bf976c22aaa70aa9ef1d41ee94e1a1a04bc0b4e Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 24 Aug 2026 10:29:36 +0200 Subject: [PATCH 08/89] fix(forge): bound the merge request list, its pages and its output buffer --- packages/cli/src/forge-mrs.test.ts | 208 +++++++++++++++++++++- packages/cli/src/forge-mrs.ts | 195 +++++++++++++++----- packages/cli/src/mr-review-runner.test.ts | 30 +++- packages/cli/src/preview.test.ts | 6 +- packages/cli/src/serve.test.ts | 1 + packages/web/src/types.ts | 9 +- 6 files changed, 395 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/forge-mrs.test.ts b/packages/cli/src/forge-mrs.test.ts index 562d2c1..2591ef2 100644 --- a/packages/cli/src/forge-mrs.test.ts +++ b/packages/cli/src/forge-mrs.test.ts @@ -1,10 +1,12 @@ import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, test } from 'bun:test' import { + FORGE_MR_MAX_BUFFER_BYTES, listOpenMrs, + MR_LIST_MAX, parseGhMrList, parseGlabMrDetail, parseGlabMrList, @@ -1099,4 +1101,208 @@ describe('listOpenMrs', () => { } }) }) + + describe('pagination and truncation', () => { + function ghMinimalMr(n: number): Record { + return { + number: n, + title: `mr ${n}`, + author: { login: 'a' }, + headRefName: `feat/${n}`, + baseRefName: 'main', + updatedAt: '2026-07-28T10:00:00Z', + url: `https://github.test/acme/repo/pull/${n}`, + } + } + + function ghListStdout(count: number): string { + return JSON.stringify(Array.from({ length: count }, (_, i) => ghMinimalMr(i + 1))) + } + + function glabMinimalMr(n: number): Record { + return { + iid: n, + title: `mr ${n}`, + author: { username: 'a' }, + source_branch: `feat/${n}`, + target_branch: 'main', + updated_at: '2026-07-28T09:30:00.123Z', + web_url: `https://gitlab.example.test/a/b/-/merge_requests/${n}`, + } + } + + function glabPageStdout(startIid: number, count: number): string { + return JSON.stringify(Array.from({ length: count }, (_, i) => glabMinimalMr(startIid + i))) + } + + test('gh is asked for one more than the cap, via --limit', async () => { + const repo = tempRepoWithRemote() + try { + let capturedArgs: string[] = [] + const execFn: ForgeMrsExecFn = (cli, args): Promise => { + if (cli === 'gh') { + capturedArgs = args + return Promise.resolve({ kind: 'ok', stdout: '[]' }) + } + return Promise.resolve({ kind: 'missing' }) + } + await listOpenMrs(repo, { execFn }) + const limitIndex = capturedArgs.indexOf('--limit') + expect(limitIndex).toBeGreaterThan(-1) + expect(capturedArgs[limitIndex + 1]).toBe(String(MR_LIST_MAX + 1)) + } finally { + cleanup() + } + }) + + test('glab is asked per page with the 100-item GitLab clamp, page number incrementing', async () => { + const repo = tempRepoWithRemote() + try { + const seenPages: (string | undefined)[] = [] + const execFn: ForgeMrsExecFn = (cli, args): Promise => { + if (cli === 'gh') { + return Promise.resolve({ kind: 'missing' }) + } + expect(args).toContain('--per-page') + expect(args[args.indexOf('--per-page') + 1]).toBe('100') + expect(args).toContain('--output') + seenPages.push(args[args.indexOf('--page') + 1]) + // A page shorter than 100 ends the walk immediately. + return Promise.resolve({ kind: 'ok', stdout: '[]' }) + } + await listOpenMrs(repo, { execFn }) + expect(seenPages).toEqual(['1']) + } finally { + cleanup() + } + }) + + test('truncated is true when gh returns more than MR_LIST_MAX entries', async () => { + const repo = tempRepoWithRemote() + try { + const execFn: ForgeMrsExecFn = (cli): Promise => { + if (cli === 'gh') { + return Promise.resolve({ kind: 'ok', stdout: ghListStdout(MR_LIST_MAX + 1) }) + } + return Promise.resolve({ kind: 'missing' }) + } + const result = await listOpenMrs(repo, { execFn }) + expect(result.available).toBe(true) + if (!result.available) { + throw new Error('expected available') + } + expect(result.truncated).toBe(true) + expect(result.mrs).toHaveLength(MR_LIST_MAX) + } finally { + cleanup() + } + }) + + test('truncated is false when gh returns exactly MR_LIST_MAX entries', async () => { + const repo = tempRepoWithRemote() + try { + const execFn: ForgeMrsExecFn = (cli): Promise => { + if (cli === 'gh') { + return Promise.resolve({ kind: 'ok', stdout: ghListStdout(MR_LIST_MAX) }) + } + return Promise.resolve({ kind: 'missing' }) + } + const result = await listOpenMrs(repo, { execFn }) + expect(result.available).toBe(true) + if (!result.available) { + throw new Error('expected available') + } + expect(result.truncated).toBe(false) + expect(result.mrs).toHaveLength(MR_LIST_MAX) + } finally { + cleanup() + } + }) + + test('glab walks pages and truncates once a multi-page answer exceeds MR_LIST_MAX', async () => { + const repo = tempRepoWithRemote() + try { + let calls = 0 + const execFn: ForgeMrsExecFn = (cli): Promise => { + if (cli === 'gh') { + return Promise.resolve({ kind: 'missing' }) + } + calls += 1 + if (calls === 1) { + return Promise.resolve({ kind: 'ok', stdout: glabPageStdout(1, 100) }) + } + if (calls === 2) { + return Promise.resolve({ kind: 'ok', stdout: glabPageStdout(101, 100) }) + } + // Short page (1 < 100): ends the walk at 201 total. + return Promise.resolve({ kind: 'ok', stdout: glabPageStdout(201, 1) }) + } + // Enrichment budget 0 so the per-MR enrichment fan-out (up to 200 + // calls) never fires and never pollutes the `calls` count this test + // asserts on: pagination is what is under test here, not enrichment. + const result = await listOpenMrs(repo, { execFn, glabEnrichBudgetMs: 0 }) + expect(result.available).toBe(true) + if (!result.available) { + throw new Error('expected available') + } + expect(calls).toBe(3) + expect(result.truncated).toBe(true) + expect(result.mrs).toHaveLength(MR_LIST_MAX) + } finally { + cleanup() + } + }) + + test('glab does not truncate when a multi-page answer stays at or under MR_LIST_MAX', async () => { + const repo = tempRepoWithRemote() + try { + let calls = 0 + const execFn: ForgeMrsExecFn = (cli): Promise => { + if (cli === 'gh') { + return Promise.resolve({ kind: 'missing' }) + } + calls += 1 + if (calls === 1) { + return Promise.resolve({ kind: 'ok', stdout: glabPageStdout(1, 100) }) + } + // Short page (99 < 100): ends the walk at 199 total. + return Promise.resolve({ kind: 'ok', stdout: glabPageStdout(101, 99) }) + } + const result = await listOpenMrs(repo, { execFn, glabEnrichBudgetMs: 0 }) + expect(result.available).toBe(true) + if (!result.available) { + throw new Error('expected available') + } + expect(calls).toBe(2) + expect(result.truncated).toBe(false) + expect(result.mrs).toHaveLength(199) + } finally { + cleanup() + } + }) + }) + + describe('output buffer', () => { + test('a payload past the default 1 MiB is not killed: FORGE_MR_MAX_BUFFER_BYTES covers it', async () => { + const repo = mkdtempSync(join(tmpdir(), 'codesema-forge-mrs-buf-')) + try { + execFileSync('git', ['init', '-b', 'main', repo], { stdio: 'ignore' }) + // Past node's 1 MiB default maxBuffer: `commits` (the full commit + // list) and `body` (the full Markdown description) are exactly the + // fields in this contract that can grow this large in a real answer. + const bigSize = 2_000_000 + expect(bigSize).toBeLessThan(FORGE_MR_MAX_BUFFER_BYTES) + writeFileSync(join(repo, 'big.txt'), 'x'.repeat(bigSize)) + const sha = execFileSync('git', ['hash-object', '-w', 'big.txt'], { + cwd: repo, + encoding: 'utf8', + }).trim() + const outcome = await runForgeCli('git', ['cat-file', '-p', sha], repo, 5000) + expect(outcome.kind).toBe('ok') + expect(outcome.kind === 'ok' && outcome.stdout.length).toBe(bigSize) + } finally { + rmSync(repo, { recursive: true, force: true }) + } + }) + }) }) diff --git a/packages/cli/src/forge-mrs.ts b/packages/cli/src/forge-mrs.ts index 554fb0f..9a1133f 100644 --- a/packages/cli/src/forge-mrs.ts +++ b/packages/cli/src/forge-mrs.ts @@ -35,21 +35,65 @@ export type ForgeMr = { body: string | null } +/** + * `truncated` is never optional on the success branch (same invariant as + * forge-issues.ts's `ForgeIssuesResult`): a caller cannot hold a capped list + * without being handed the fact that it is capped. + */ export type ForgeMrsResult = - | { available: true; mrs: ForgeMr[] } + | { available: true; mrs: ForgeMr[]; truncated: boolean } | { available: false; reason: 'no-remote' | 'no-cli' | 'cli-error' } // prettier-ignore const GH_JSON_FIELDS = 'number,title,author,headRefName,baseRefName,updatedAt,url,state,isDraft,labels,additions,deletions,changedFiles,statusCheckRollup,reviewRequests,assignees,milestone,mergeable,commits,body' const EXEC_TIMEOUT_MS = 8000 +/** + * Hard cap on one `listOpenMrs` answer, same doctrine as forge-issues.ts's + * `ISSUE_LIST_MAX`: both `gh pr list` and `glab mr list` stop at 30 items by + * default (gh `-L/--limit`, glab `-P/--per-page`), exactly the SILENT + * truncation this cap replaces. The size is now asked for explicitly, and + * whatever the cap leaves out is reported as `truncated: true`, never dropped + * in silence. Same magnitude as `ISSUE_LIST_MAX` for the same reason: open + * MRs are typically far fewer than an issue backlog, but the cap exists to + * bound a pathological repo, not to describe a normal one. + */ +export const MR_LIST_MAX = 200 +/** One MR past the cap is what proves there are more; the extra one is never returned. */ +const GH_MR_LIMIT = String(MR_LIST_MAX + 1) +/** + * GitLab's REST API clamps `per_page` at 100 whatever is asked for (same + * quirk forge-issues.ts documents for issues), so glab cannot answer "give me + * 201" in one go: it pages. + */ +const GLAB_PAGE_SIZE = 100 +const GLAB_PER_PAGE = String(GLAB_PAGE_SIZE) +const GLAB_MAX_PAGES = Math.ceil((MR_LIST_MAX + 1) / GLAB_PAGE_SIZE) + +/** + * Same budget and reasoning as forge-issues.ts's `FORGE_MAX_BUFFER_BYTES`. + * Node's default `maxBuffer` is 1 MiB, and this contract's `commits` (the + * full commit list) and `body` (the full Markdown description) are its + * heaviest fields by far: a `pr list`/`mr list` answer can blow past the + * default as soon as MRs carry real descriptions. Past it, `execFile` kills + * the process, `runForgeCli` maps that to `{ kind: 'error' }`, and the WHOLE + * list would be reported unavailable even though the data existed: exactly + * the whole-list rejection the field-by-field degradation doctrine forbids. + */ +export const FORGE_MR_MAX_BUFFER_BYTES = 64 * 1024 * 1024 + /** * Bounds for the GitLab per-MR enrichment fan-out (see `enrichGlabMrs`). * `mr list` does not carry diff size or CI status the way `gh pr list --json` * does, so both need one `glab api` call per MR. CONCURRENCY caps how many of * those calls run at once; BUDGET_MS caps how long the whole enrichment phase - * may run before the remaining MRs are returned unenriched rather than making - * one slow repo stall the entire `/api/mrs` request. + * may run. CALL_TIMEOUT_MS is each call's own ceiling, but a call is never + * allowed to outlive the phase: every dispatch is timed with + * `min(CALL_TIMEOUT_MS, remaining budget)`, so a call started near the + * deadline gets a short leash instead of the full 5s, and the phase as a + * whole never runs meaningfully past BUDGET_MS. Whatever the deadline leaves + * unenriched is returned as-is, rather than making one slow repo stall the + * entire `/api/mrs` request. */ const GLAB_ENRICH_CONCURRENCY = 4 const GLAB_ENRICH_CALL_TIMEOUT_MS = 5000 @@ -594,29 +638,39 @@ export function runForgeCli( timeoutMs: number, ): Promise { return new Promise((resolve) => { - execFile(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs }, (err, stdout) => { - if (err) { - resolve( - (err as NodeJS.ErrnoException).code === 'ENOENT' - ? { kind: 'missing' } - : { kind: 'error' }, - ) - return - } - resolve({ kind: 'ok', stdout }) - }) + execFile( + cmd, + args, + { cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: FORGE_MR_MAX_BUFFER_BYTES }, + (err, stdout) => { + if (err) { + resolve( + (err as NodeJS.ErrnoException).code === 'ENOENT' + ? { kind: 'missing' } + : { kind: 'error' }, + ) + return + } + resolve({ kind: 'ok', stdout }) + }, + ) }) } const defaultExec: ForgeMrsExecFn = (cmd, args, cwd, timeoutMs) => runForgeCli(cmd, args, cwd, timeoutMs) -async function enrichOneGlabMr(mr: ForgeMr, cwd: string, execFn: ForgeMrsExecFn): Promise { +async function enrichOneGlabMr( + mr: ForgeMr, + cwd: string, + execFn: ForgeMrsExecFn, + timeoutMs: number, +): Promise { const outcome = await execFn( 'glab', ['api', `projects/:fullpath/merge_requests/${mr.number}`], cwd, - GLAB_ENRICH_CALL_TIMEOUT_MS, + timeoutMs, ) if (outcome.kind !== 'ok') { // Nothing measured for this MR: it is returned as-is, changedFiles/checks @@ -638,9 +692,12 @@ type EnrichBudget = { concurrency: number; budgetMs: number } * call per MR, bounded by `budget.concurrency` workers and a global * `budget.budgetMs` wall-clock deadline (GLAB_ENRICH_CONCURRENCY / * GLAB_ENRICH_BUDGET_MS in production; overridable only from - * ForgeMrsOptions, which only tests use). An MR whose call fails, times out, - * or is never reached before the deadline simply keeps those two fields - * null: it is never removed from the returned list. + * ForgeMrsOptions, which only tests use). Each dispatch is timed with + * `min(GLAB_ENRICH_CALL_TIMEOUT_MS, time left before the deadline)`, so a + * call started near the deadline cannot itself outlive it: the phase as a + * whole never runs meaningfully past `budget.budgetMs`. An MR whose call + * fails, times out, or is never reached before the deadline simply keeps + * those two fields null: it is never removed from the returned list. */ async function enrichGlabMrs( mrs: ForgeMr[], @@ -659,12 +716,14 @@ async function enrichGlabMrs( if (index >= mrs.length) { return } - if (Date.now() >= deadline) { + const remaining = deadline - Date.now() + if (remaining <= 0) { return } const mr = mrs[index] if (mr !== undefined) { - results[index] = await enrichOneGlabMr(mr, cwd, execFn) + const callTimeoutMs = Math.min(GLAB_ENRICH_CALL_TIMEOUT_MS, remaining) + results[index] = await enrichOneGlabMr(mr, cwd, execFn, callTimeoutMs) } } } @@ -674,10 +733,18 @@ async function enrichGlabMrs( return results } +type MrPage = { mrs: ForgeMr[]; truncated: boolean } + +/** One MR past the cap is what proves there are more; the extra one is never returned. */ +function capMrs(all: ForgeMr[]): MrPage { + return { mrs: all.slice(0, MR_LIST_MAX), truncated: all.length > MR_LIST_MAX } +} + +type CandidateOutcome = { kind: 'ok'; value: MrPage } | { kind: 'missing' } | { kind: 'error' } + type Candidate = { cli: string - args: string[] - parse: (raw: string) => ForgeMr[] | null + run: (execFn: ForgeMrsExecFn, cwd: string) => Promise enrich?: ( mrs: ForgeMr[], cwd: string, @@ -686,6 +753,55 @@ type Candidate = { ) => Promise } +/** gh paginates internally: ask for one more than the cap and count what comes back. */ +function ghMrCandidate(): Candidate { + return { + cli: 'gh', + run: async (execFn, cwd) => { + const args = ['pr', 'list', '--json', GH_JSON_FIELDS, '--limit', GH_MR_LIMIT] + const outcome = await execFn('gh', args, cwd, EXEC_TIMEOUT_MS) + if (outcome.kind !== 'ok') { + return outcome + } + const mrs = parseGhMrList(outcome.stdout) + return mrs === null ? { kind: 'error' } : { kind: 'ok', value: capMrs(mrs) } + }, + } +} + +/** glab cannot: GitLab clamps a page at 100, so walk pages until one comes back short. */ +function glabMrCandidate(): Candidate { + return { + cli: 'glab', + run: async (execFn, cwd) => { + const all: ForgeMr[] = [] + for (let page = 1; page <= GLAB_MAX_PAGES; page += 1) { + const paging = ['--per-page', GLAB_PER_PAGE, '--page', String(page), '--output', 'json'] + const args = ['mr', 'list', ...paging] + const outcome = await execFn('glab', args, cwd, EXEC_TIMEOUT_MS) + if (outcome.kind !== 'ok') { + return outcome + } + const batch = parseGlabMrList(outcome.stdout) + if (batch === null) { + return { kind: 'error' } + } + all.push(...batch) + if (batch.length < GLAB_PAGE_SIZE) { + // A short page is the end of the list, but the pages before it can + // already have overshot the cap, so the cap is applied HERE too. + return { kind: 'ok', value: capMrs(all) } + } + if (all.length > MR_LIST_MAX) { + break + } + } + return { kind: 'ok', value: capMrs(all) } + }, + enrich: enrichGlabMrs, + } +} + export async function listOpenMrs( cwd: string, options: ForgeMrsOptions = {}, @@ -699,24 +815,15 @@ export async function listOpenMrs( const hint = detectForgeHint(cwd) const candidates: Candidate[] = [] if (hint !== 'gitlab') { - candidates.push({ - cli: 'gh', - args: ['pr', 'list', '--json', GH_JSON_FIELDS], - parse: parseGhMrList, - }) + candidates.push(ghMrCandidate()) } if (hint !== 'github') { - candidates.push({ - cli: 'glab', - args: ['mr', 'list', '--output', 'json'], - parse: parseGlabMrList, - enrich: enrichGlabMrs, - }) + candidates.push(glabMrCandidate()) } let sawCliError = false for (const candidate of candidates) { - const outcome = await execFn(candidate.cli, candidate.args, cwd, EXEC_TIMEOUT_MS) + const outcome = await candidate.run(execFn, cwd) if (outcome.kind === 'missing') { continue } @@ -724,18 +831,14 @@ export async function listOpenMrs( sawCliError = true continue } - const mrs = candidate.parse(outcome.stdout) - if (mrs === null) { - sawCliError = true - continue + const budget: EnrichBudget = { + concurrency: options.glabEnrichConcurrency ?? GLAB_ENRICH_CONCURRENCY, + budgetMs: options.glabEnrichBudgetMs ?? GLAB_ENRICH_BUDGET_MS, } - const enriched = candidate.enrich - ? await candidate.enrich(mrs, cwd, execFn, { - concurrency: options.glabEnrichConcurrency ?? GLAB_ENRICH_CONCURRENCY, - budgetMs: options.glabEnrichBudgetMs ?? GLAB_ENRICH_BUDGET_MS, - }) - : mrs - return { available: true, mrs: enriched } + const mrs = candidate.enrich + ? await candidate.enrich(outcome.value.mrs, cwd, execFn, budget) + : outcome.value.mrs + return { available: true, mrs, truncated: outcome.value.truncated } } return { available: false, reason: sawCliError ? 'cli-error' : 'no-cli' } } diff --git a/packages/cli/src/mr-review-runner.test.ts b/packages/cli/src/mr-review-runner.test.ts index b0b5428..eb8de48 100644 --- a/packages/cli/src/mr-review-runner.test.ts +++ b/packages/cli/src/mr-review-runner.test.ts @@ -142,7 +142,11 @@ describe('createMrReviewRunner (MR source)', () => { session: createSession(), agentCommand: agentScriptFor(cwd, REVIEW), timeoutMs: 15000, - listMrs: async (): Promise => ({ available: true, mrs: [mr] }), + listMrs: async (): Promise => ({ + available: true, + mrs: [mr], + truncated: false, + }), }) const result = await runner.start({ kind: 'mr', number: mr.number }, 'bogus' as never) @@ -157,7 +161,11 @@ describe('createMrReviewRunner (MR source)', () => { session: createSession(), agentCommand: agentScriptFor(cwd, REVIEW), timeoutMs: 15000, - listMrs: async (): Promise => ({ available: true, mrs: [mr] }), + listMrs: async (): Promise => ({ + available: true, + mrs: [mr], + truncated: false, + }), }) const result = await runner.start({ kind: 'mr', number: 999 }, 'simple') @@ -186,7 +194,11 @@ describe('createMrReviewRunner (MR source)', () => { session, agentCommand: agentScriptFor(cwd, REVIEW), timeoutMs: 15000, - listMrs: async (): Promise => ({ available: true, mrs: [mr] }), + listMrs: async (): Promise => ({ + available: true, + mrs: [mr], + truncated: false, + }), }) expect(runner.status()).toEqual({ available: true, phase: 'idle' }) @@ -215,7 +227,11 @@ describe('createMrReviewRunner (MR source)', () => { session, agentCommand: agentScriptFor(cwd, '', 1), timeoutMs: 15000, - listMrs: async (): Promise => ({ available: true, mrs: [mr] }), + listMrs: async (): Promise => ({ + available: true, + mrs: [mr], + truncated: false, + }), }) await runner.start({ kind: 'mr', number: mr.number }, 'simple') @@ -232,7 +248,11 @@ describe('createMrReviewRunner (MR source)', () => { session: createSession(), agentCommand: agentScriptFor(cwd, REVIEW), timeoutMs: 15000, - listMrs: async (): Promise => ({ available: true, mrs: [mr] }), + listMrs: async (): Promise => ({ + available: true, + mrs: [mr], + truncated: false, + }), }) const first = await runner.start({ kind: 'mr', number: mr.number }, 'simple') diff --git a/packages/cli/src/preview.test.ts b/packages/cli/src/preview.test.ts index e378520..6946e12 100644 --- a/packages/cli/src/preview.test.ts +++ b/packages/cli/src/preview.test.ts @@ -285,7 +285,11 @@ describe('buildPreview', () => { commits: null, body: null, } - const listMrs = async (): Promise => ({ available: true, mrs: [mr] }) + const listMrs = async (): Promise => ({ + available: true, + mrs: [mr], + truncated: false, + }) // fetchBranch (git fetch origin ...) is skipped here: there is no origin remote, so it would throw; // instead we exercise resolvePreviewRefs directly against a source branch reachable without a remote. await expect(buildPreview(repo, { kind: 'mr', number: 999 }, { listMrs })).rejects.toThrow( diff --git a/packages/cli/src/serve.test.ts b/packages/cli/src/serve.test.ts index 8873146..9a9c3e8 100644 --- a/packages/cli/src/serve.test.ts +++ b/packages/cli/src/serve.test.ts @@ -726,6 +726,7 @@ describe('project-scoped repo routes (?project=)', () => { body: null, }, ], + truncated: false, } function runGit(cwd: string, args: string[]): void { diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 9b37965..8235c43 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -201,8 +201,15 @@ export type ForgeMr = { */ export type ForgeUnavailableReason = 'no-remote' | 'no-cli' | 'cli-error' +/** + * `truncated` is never optional on the success branch, for the same reason as + * on the issue list: both forge porcelains stop at their own page size, and a + * caller that cannot tell a whole list from a capped one will present the cap + * as the total. + */ export type ForgeMrsResult = - { available: true; mrs: ForgeMr[] } | { available: false; reason: ForgeUnavailableReason } + | { available: true; mrs: ForgeMr[]; truncated: boolean } + | { available: false; reason: ForgeUnavailableReason } // Mirrors packages/cli/src/forge-issues-parse.ts and the /api/issues endpoint. From a045b6779b51c735adfad44c3f5bd9b68b8b6805 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 24 Aug 2026 10:42:37 +0200 Subject: [PATCH 09/89] feat(web): add the issue radar screen to the workspace focus zone --- packages/web/src/components/WorkspaceView.vue | 27 +- .../src/components/radar/IssueCard.test.ts | 64 ++++ .../web/src/components/radar/IssueCard.vue | 92 +++++ .../src/components/radar/IssueRadar.test.ts | 305 ++++++++++++++++ .../web/src/components/radar/IssueRadar.vue | 345 ++++++++++++++++++ .../src/components/radar/LabelChips.test.ts | 63 ++++ .../web/src/components/radar/LabelChips.vue | 78 ++++ .../components/radar/RadarAccordion.test.ts | 110 ++++++ .../src/components/radar/RadarAccordion.vue | 115 ++++++ .../src/components/radar/RadarLogic.test.ts | 152 ++++++++ .../web/src/components/radar/RadarLogic.ts | 98 +++++ .../src/components/radar/RadarPrefs.test.ts | 104 ++++++ .../web/src/components/radar/RadarPrefs.ts | 112 ++++++ packages/web/src/i18n.ts | 48 +++ 14 files changed, 1712 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/radar/IssueCard.test.ts create mode 100644 packages/web/src/components/radar/IssueCard.vue create mode 100644 packages/web/src/components/radar/IssueRadar.test.ts create mode 100644 packages/web/src/components/radar/IssueRadar.vue create mode 100644 packages/web/src/components/radar/LabelChips.test.ts create mode 100644 packages/web/src/components/radar/LabelChips.vue create mode 100644 packages/web/src/components/radar/RadarAccordion.test.ts create mode 100644 packages/web/src/components/radar/RadarAccordion.vue create mode 100644 packages/web/src/components/radar/RadarLogic.test.ts create mode 100644 packages/web/src/components/radar/RadarLogic.ts create mode 100644 packages/web/src/components/radar/RadarPrefs.test.ts create mode 100644 packages/web/src/components/radar/RadarPrefs.ts diff --git a/packages/web/src/components/WorkspaceView.vue b/packages/web/src/components/WorkspaceView.vue index 3249eed..06740f4 100644 --- a/packages/web/src/components/WorkspaceView.vue +++ b/packages/web/src/components/WorkspaceView.vue @@ -29,6 +29,7 @@ import { isPinned, type FocusDeck, } from '../composables/useFocusDeck' +import { useIssues } from '../composables/useIssues' import { buildProjectTree, countProjectActivity, @@ -50,6 +51,7 @@ import { taskKey, useTasks, type CreateTaskInput, type TaskState } from '../comp import { t } from '../i18n' import type { AgentOption, ForgeMr, ReviewRecord } from '../types' import ProjectsNav from './ProjectsNav.vue' +import IssueRadar from './radar/IssueRadar.vue' import RepoSettings from './RepoSettings.vue' import ReviewShell from './ReviewShell.vue' import TaskComposer from './TaskComposer.vue' @@ -94,6 +96,10 @@ const { preview, } = useTasks(props.token) +// Issue Radar (C3): lazy per-project issue fetch, same trigger as the MR/branch +// lazy-fetch policy above, see selectFilter. +const issues = useIssues() + const agents = ref([]) const currentAgent = ref('') @@ -163,6 +169,7 @@ function selectFilter(id: string | null): void { filter.value = id if (id !== null) { selectProject(id) + issues.load(id) } } @@ -722,7 +729,18 @@ async function onRemoveProject(id: string): Promise { - + +
+ +
+ +

{{ projects.length === 0 ? t('workspace.noProject') : t('workspace.focusEmpty') }} @@ -928,6 +946,13 @@ async function onRemoveProject(id: string): Promise { white-space: nowrap; } +/* ── Issue Radar ──────────────────────────────────────────────────────── */ +.ws-radar { + flex: 1; + min-height: 0; + overflow-y: auto; +} + /* ── Empty focus ──────────────────────────────────────────────────────── */ .ws-empty-focus { flex: 1; diff --git a/packages/web/src/components/radar/IssueCard.test.ts b/packages/web/src/components/radar/IssueCard.test.ts new file mode 100644 index 0000000..8587b0d --- /dev/null +++ b/packages/web/src/components/radar/IssueCard.test.ts @@ -0,0 +1,64 @@ +// Same harness as MrCard.test.ts / WorkspaceHeader.test.ts. +import { expect, test } from 'bun:test' +import { createSSRApp } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' +import { t } from '../../i18n' +import type { ForgeIssue } from '../../types' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +function baseIssue(overrides: Partial = {}): ForgeIssue { + return { + number: 7, + title: 'Add issue radar screen', + body: '', + state: 'open', + labels: ['ui', 'radar'], + author: 'octocat', + createdAt: '2026-08-14T00:00:00.000Z', + updatedAt: '2026-08-20T10:00:00.000Z', + url: 'https://example.test/issues/7', + ...overrides, + } +} + +async function renderCard(issue: ForgeIssue): Promise { + const IssueCard = (await import('./IssueCard.vue')).default + const app = createSSRApp(IssueCard, { issue }) + return renderToString(app) +} + +test('renders the number, title and author', async () => { + const html = await renderCard(baseIssue()) + expect(html).toContain(t('mrs.number', { n: 7 })) + expect(html).toContain('Add issue radar screen') + expect(html).toContain('octocat') +}) + +test('renders every label', async () => { + const html = await renderCard(baseIssue({ labels: ['ui', 'radar'] })) + expect(html).toContain('ui') + expect(html).toContain('radar') +}) + +test('an empty label list renders no label chip', async () => { + const html = await renderCard(baseIssue({ labels: [] })) + expect(html).not.toContain('isc-label"') +}) + +test('the empty string body is not rendered as a degradation of any kind', async () => { + const html = await renderCard(baseIssue({ body: '' })) + expect(html).not.toContain('undefined') + expect(html).not.toContain('null') +}) diff --git a/packages/web/src/components/radar/IssueCard.vue b/packages/web/src/components/radar/IssueCard.vue new file mode 100644 index 0000000..76fe7a0 --- /dev/null +++ b/packages/web/src/components/radar/IssueCard.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/web/src/components/radar/IssueRadar.test.ts b/packages/web/src/components/radar/IssueRadar.test.ts new file mode 100644 index 0000000..21176cb --- /dev/null +++ b/packages/web/src/components/radar/IssueRadar.test.ts @@ -0,0 +1,305 @@ +// Same harness as MrCard.test.ts / WorkspaceHeader.test.ts. Interactivity +// (clicking a filter/sort/label chip, toggling an accordion) is covered at +// the pure-logic level (RadarLogic.test.ts, RadarPrefs.test.ts) and by +// seeding the persisted prefs blob below: this file only checks what a given +// prop bag (and, for the fold state, a given localStorage) renders. +import { describe, expect, test } from 'bun:test' +import { createSSRApp } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' +import type { ProjectIssuesState } from '../../composables/useIssues' +import { t } from '../../i18n' +import type { ForgeIssue, ForgeIssuesResult, ForgeMr, WorkspaceInfo } from '../../types' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +function issue(overrides: Partial = {}): ForgeIssue { + return { + number: 1, + title: 'first issue', + body: '', + state: 'open', + labels: [], + author: 'octocat', + createdAt: '2026-08-14T00:00:00.000Z', + updatedAt: '2026-08-14T00:00:00.000Z', + url: 'https://example.test/issues/1', + ...overrides, + } +} + +function mr(overrides: Partial = {}): ForgeMr { + return { + number: 42, + title: 'first mr', + author: 'octocat', + sourceBranch: 'feat/x', + targetBranch: 'main', + updatedAt: '2026-08-14T00:00:00.000Z', + url: 'https://example.test/mr/42', + state: 'open', + isDraft: false, + labels: [], + additions: null, + deletions: null, + changedFiles: null, + checks: null, + reviewers: null, + assignees: null, + milestone: null, + mergeable: null, + commits: null, + body: null, + ...overrides, + } +} + +function issuesState(overrides: Partial = {}): ProjectIssuesState { + return { result: null, loading: false, error: null, ...overrides } +} + +function available(issues: ForgeIssue[], truncated = false): ForgeIssuesResult { + return { available: true, truncated, issues } +} + +function workspace(overrides: Partial = {}): WorkspaceInfo { + return { + isolation_available: true, + isolation_default: 'container', + isolation_reason: 'ok', + ...overrides, + } +} + +async function render(props: { + issuesState: ProjectIssuesState + mrs: ForgeMr[] + workspace: WorkspaceInfo | null +}): Promise { + const IssueRadar = (await import('./IssueRadar.vue')).default + const app = createSSRApp(IssueRadar, props) + return renderToString(app) +} + +/** The Issues accordion's own markup, cut off before the Pull requests one: + * `ra-count`/`ra-truncated` etc. are shared class names between both + * sections, so an assertion about ONE section must not read the other's. */ +function issuesSectionOf(html: string): string { + return html.slice(0, html.indexOf('Pull requests')) +} + +describe('issues: loading / error / unavailable / empty / list', () => { + test('no result yet: shows the loading message, no count badge', async () => { + const html = await render({ + issuesState: issuesState({ loading: true }), + mrs: [], + workspace: null, + }) + expect(html).toContain(t('radar.loading')) + expect(issuesSectionOf(html)).not.toContain('ra-count') + }) + + test('a transport error shows the error and a retry action, not a reason', async () => { + const html = await render({ + issuesState: issuesState({ error: 'HTTP 500' }), + mrs: [], + workspace: null, + }) + expect(html).toContain(t('radar.transportError', { error: 'HTTP 500' })) + expect(html).toContain(t('radar.retry')) + }) + + test.each([ + ['no-remote', 'radar.issuesReasonNoRemote'], + ['no-cli', 'radar.issuesReasonNoCli'], + ['cli-error', 'radar.issuesReasonCliError'], + ['invalid-input', 'radar.issuesReasonInvalidInput'], + ['unsupported', 'radar.issuesReasonUnsupported'], + ] as const)('forge unavailable (%s) renders its own distinct message', async (reason, key) => { + const html = await render({ + issuesState: issuesState({ result: { available: false, reason } }), + mrs: [], + workspace: null, + }) + expect(html).toContain(t(key)) + // Never confused with "no open issue" (a success), nor with the retry flow. + expect(html).not.toContain(t('radar.issuesEmpty')) + expect(html).not.toContain(t('radar.retry')) + }) + + test('an empty, AVAILABLE list is a success: "no open issue", never a reason', async () => { + const html = await render({ + issuesState: issuesState({ result: available([]) }), + mrs: [], + workspace: null, + }) + expect(html).toContain(t('radar.issuesEmpty')) + expect(html).toContain('>0<') // count badge shows the measured zero + expect(html).not.toContain(t('radar.issuesReasonNoRemote')) + }) + + test('an unavailable result shows no count badge at all (unknown, not zero)', async () => { + const html = await render({ + issuesState: issuesState({ result: { available: false, reason: 'no-remote' } }), + mrs: [], + workspace: null, + }) + expect(issuesSectionOf(html)).not.toContain('ra-count') + }) + + test('a truncated list says so explicitly, with the number actually shown', async () => { + const html = await render({ + issuesState: issuesState({ + result: available([issue({ number: 1 }), issue({ number: 2 })], true), + }), + mrs: [], + workspace: null, + }) + expect(html).toContain(t('radar.truncatedHint', { n: 2 })) + }) + + test('a non-truncated list carries no truncation caveat', async () => { + const html = await render({ + issuesState: issuesState({ result: available([issue()], false) }), + mrs: [], + workspace: null, + }) + expect(html).not.toContain('ra-truncated') + }) + + test('default sort is most-recently-updated first', async () => { + const older = issue({ number: 1, title: 'older', updatedAt: '2026-01-01T00:00:00Z' }) + const newer = issue({ number: 2, title: 'newer', updatedAt: '2026-06-01T00:00:00Z' }) + const html = await render({ + issuesState: issuesState({ result: available([older, newer]) }), + mrs: [], + workspace: null, + }) + expect(html.indexOf('newer')).toBeLessThan(html.indexOf('older')) + }) + + test('each issue links to its forge URL and carries the open-in-forge aria-label', async () => { + const html = await render({ + issuesState: issuesState({ + result: available([issue({ url: 'https://example.test/issues/9' })]), + }), + mrs: [], + workspace: null, + }) + expect(html).toContain('href="https://example.test/issues/9"') + expect(html).toContain('target="_blank"') + }) + + test('label chips render from the loaded issues, hidden on an empty list', async () => { + const withLabels = await render({ + issuesState: issuesState({ result: available([issue({ labels: ['bug'] })]) }), + mrs: [], + workspace: null, + }) + expect(withLabels).toContain('bug') + + const empty = await render({ + issuesState: issuesState({ result: available([]) }), + mrs: [], + workspace: null, + }) + expect(empty).not.toContain('lc-chip') + }) +}) + +describe('pull requests: forge unavailable vs empty vs list', () => { + test('forge unavailable (workspace overlay) hides the list and its controls', async () => { + const html = await render({ + issuesState: issuesState(), + mrs: [mr()], + workspace: workspace({ forge_available: false, forge_reason: 'no-cli' }), + }) + expect(html).toContain(t('workspace.forgeReasonNoCli')) + expect(html).not.toContain('ir-filters') + }) + + test('an unknown workspace (null) never claims unavailability', async () => { + const html = await render({ issuesState: issuesState(), mrs: [], workspace: null }) + expect(html).toContain(t('radar.mrsEmpty')) + }) + + test('an empty, available MR list renders "no open merge request"', async () => { + const html = await render({ issuesState: issuesState(), mrs: [], workspace: workspace() }) + expect(html).toContain(t('radar.mrsEmpty')) + }) + + test('a non-empty list renders the count, the status filter chips and each MR', async () => { + const html = await render({ + issuesState: issuesState(), + mrs: [mr({ number: 1 }), mr({ number: 2 })], + workspace: workspace(), + }) + expect(html).toContain('>2<') + expect(html).toContain(t('radar.filterAll')) + expect(html).toContain(t('radar.filterDraft')) + expect(html).toContain(t('radar.filterReady')) + expect(html).toContain(t('mrs.number', { n: 1 })) + expect(html).toContain(t('mrs.number', { n: 2 })) + }) + + test('each MR links to its forge URL', async () => { + const html = await render({ + issuesState: issuesState(), + mrs: [mr({ url: 'https://example.test/mr/99' })], + workspace: workspace(), + }) + expect(html).toContain('href="https://example.test/mr/99"') + }) + + test('never claims a truncation the mrsByProject cache cannot report', async () => { + const html = await render({ + issuesState: issuesState(), + mrs: [mr(), mr({ number: 2 })], + workspace: workspace(), + }) + expect(html).not.toContain('ra-truncated') + }) +}) + +describe('the two accordions fold independently, from the persisted prefs blob', () => { + test('issuesOpen: false hides the issues body while the MR accordion stays open', async () => { + const store = new Map() + store.set( + 'codesema-ws-radar-prefs', + JSON.stringify({ + issuesOpen: false, + mrsOpen: true, + issuesSort: 'updated', + mrsSort: 'updated', + mrsFilter: 'all', + issuesLabels: [], + mrsLabels: [], + }), + ) + const stub = { getItem: (key: string) => store.get(key) ?? null, setItem: () => {} } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + try { + globals.localStorage = stub + const html = await render({ + issuesState: issuesState({ result: available([issue({ title: 'HIDDEN_ISSUE' })]) }), + mrs: [mr({ title: 'SHOWN_MR' })], + workspace: workspace(), + }) + expect(html).not.toContain('HIDDEN_ISSUE') + expect(html).toContain('SHOWN_MR') + } finally { + globals.localStorage = previous + } + }) +}) diff --git a/packages/web/src/components/radar/IssueRadar.vue b/packages/web/src/components/radar/IssueRadar.vue new file mode 100644 index 0000000..a95957d --- /dev/null +++ b/packages/web/src/components/radar/IssueRadar.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/packages/web/src/components/radar/LabelChips.test.ts b/packages/web/src/components/radar/LabelChips.test.ts new file mode 100644 index 0000000..2a04512 --- /dev/null +++ b/packages/web/src/components/radar/LabelChips.test.ts @@ -0,0 +1,63 @@ +// Same harness as MrCard.test.ts / WorkspaceHeader.test.ts. +import { describe, expect, test } from 'bun:test' +import { createSSRApp } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' +import type { LabelCount } from './RadarLogic' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +async function render(counts: LabelCount[], selected: string[]): Promise { + const LabelChips = (await import('./LabelChips.vue')).default + const app = createSSRApp(LabelChips, { counts, selected }) + return renderToString(app) +} + +describe('LabelChips', () => { + test('renders one chip per label with its count', async () => { + const html = await render( + [ + { label: 'bug', count: 3 }, + { label: 'ui', count: 1 }, + ], + [], + ) + expect(html).toContain('bug') + expect(html).toContain('>3<') + expect(html).toContain('ui') + expect(html).toContain('>1<') + }) + + test('a selected label is marked aria-pressed=true, others false', async () => { + const html = await render( + [ + { label: 'bug', count: 3 }, + { label: 'ui', count: 1 }, + ], + ['bug'], + ) + const bugAt = html.indexOf('bug') + const uiAt = html.indexOf('ui') + // Each chip's own button tag carries its aria-pressed just before its label text. + const bugTag = html.lastIndexOf(' { + const html = await render([], []) + expect(html).not.toContain('lc-chip') + expect(html).not.toContain('lc-root') + }) +}) diff --git a/packages/web/src/components/radar/LabelChips.vue b/packages/web/src/components/radar/LabelChips.vue new file mode 100644 index 0000000..e8da4a4 --- /dev/null +++ b/packages/web/src/components/radar/LabelChips.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/web/src/components/radar/RadarAccordion.test.ts b/packages/web/src/components/radar/RadarAccordion.test.ts new file mode 100644 index 0000000..99181b0 --- /dev/null +++ b/packages/web/src/components/radar/RadarAccordion.test.ts @@ -0,0 +1,110 @@ +// Same harness as MrCard.test.ts / WorkspaceHeader.test.ts, extended with `h` +// to pass slots: createSSRApp's root options accept a `render` function that +// hands the child its slots directly (no `mount`, still no DOM). +import { describe, expect, test } from 'bun:test' +import { createSSRApp, h } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +type Props = { + label: string + count: number | null + truncatedHint: string | null + open: boolean +} + +async function render( + props: Props, + slots: { default?: () => string; filters?: () => string; labels?: () => string } = {}, +): Promise { + const RadarAccordion = (await import('./RadarAccordion.vue')).default + const app = createSSRApp({ + render: () => h(RadarAccordion, props, slots), + }) + return renderToString(app) +} + +describe('fold state', () => { + test('closed: chevron points right, body and slots are not rendered', async () => { + const html = await render( + { label: 'Issues', count: 3, truncatedHint: null, open: false }, + { default: () => 'BODY_MARK', filters: () => 'FILTERS_MARK', labels: () => 'LABELS_MARK' }, + ) + expect(html).toContain('▸') + expect(html).not.toContain('▾') + expect(html).toContain('aria-expanded="false"') + expect(html).not.toContain('BODY_MARK') + expect(html).not.toContain('FILTERS_MARK') + expect(html).not.toContain('LABELS_MARK') + }) + + test('open: chevron points down, body and slots render', async () => { + const html = await render( + { label: 'Issues', count: 3, truncatedHint: null, open: true }, + { default: () => 'BODY_MARK', filters: () => 'FILTERS_MARK', labels: () => 'LABELS_MARK' }, + ) + expect(html).toContain('▾') + expect(html).toContain('aria-expanded="true"') + expect(html).toContain('BODY_MARK') + expect(html).toContain('FILTERS_MARK') + expect(html).toContain('LABELS_MARK') + }) +}) + +describe('count', () => { + test('a known count renders as a badge', async () => { + const html = await render({ label: 'Issues', count: 12, truncatedHint: null, open: true }) + expect(html).toContain('>12<') + }) + + test('a null count (unknown: loading, error, unavailable) renders no badge at all', async () => { + const html = await render({ label: 'Issues', count: null, truncatedHint: null, open: true }) + expect(html).not.toContain('ra-count') + }) + + test('a measured zero still renders, distinct from null', async () => { + const html = await render({ label: 'Issues', count: 0, truncatedHint: null, open: true }) + expect(html).toContain('ra-count') + expect(html).toContain('>0<') + }) +}) + +describe('truncation caveat', () => { + test('absent when truncatedHint is null', async () => { + const html = await render({ label: 'Issues', count: 5, truncatedHint: null, open: true }) + expect(html).not.toContain('ra-truncated') + }) + + test('shown verbatim when truncatedHint is set', async () => { + const html = await render({ + label: 'Issues', + count: 5, + truncatedHint: '5 shown, the forge has more.', + open: true, + }) + expect(html).toContain('5 shown, the forge has more.') + }) +}) + +describe('optional controls slots', () => { + test('no controls row at all when neither filters nor labels slot is given', async () => { + const html = await render( + { label: 'Issues', count: 5, truncatedHint: null, open: true }, + { default: () => 'BODY_MARK' }, + ) + expect(html).not.toContain('ra-controls') + expect(html).toContain('BODY_MARK') + }) +}) diff --git a/packages/web/src/components/radar/RadarAccordion.vue b/packages/web/src/components/radar/RadarAccordion.vue new file mode 100644 index 0000000..7f2b5ac --- /dev/null +++ b/packages/web/src/components/radar/RadarAccordion.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/web/src/components/radar/RadarLogic.test.ts b/packages/web/src/components/radar/RadarLogic.test.ts new file mode 100644 index 0000000..f5f72e3 --- /dev/null +++ b/packages/web/src/components/radar/RadarLogic.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'bun:test' +import { + matchesLabels, + matchesMrStateFilter, + radarFilterByLabels, + radarFilterMrsByState, + radarLabelCounts, + radarSort, + sortByTitle, + sortByUpdated, + type LabelCount, +} from './RadarLogic' + +type Item = { title: string; updatedAt: string; labels: readonly string[] | null } + +function item(title: string, updatedAt: string, labels: readonly string[] | null = []): Item { + return { title, updatedAt, labels } +} + +describe('sortByUpdated', () => { + test('most recently updated first', () => { + const items = [item('a', '2026-01-01T00:00:00Z'), item('b', '2026-03-01T00:00:00Z')] + expect(sortByUpdated(items).map((i) => i.title)).toEqual(['b', 'a']) + }) + + test('equal timestamps keep their original relative order', () => { + const items = [item('a', '2026-01-01T00:00:00Z'), item('b', '2026-01-01T00:00:00Z')] + expect(sortByUpdated(items).map((i) => i.title)).toEqual(['a', 'b']) + }) +}) + +describe('sortByTitle', () => { + test('alphabetical order', () => { + const items = [item('Zebra', '2026-01-01T00:00:00Z'), item('Apple', '2026-01-01T00:00:00Z')] + expect(sortByTitle(items).map((i) => i.title)).toEqual(['Apple', 'Zebra']) + }) + + test('equal titles keep their original relative order', () => { + const items = [item('same', '2026-01-01T00:00:00Z'), item('same', '2026-02-01T00:00:00Z')] + expect(sortByTitle(items).map((i) => i.updatedAt)).toEqual([ + '2026-01-01T00:00:00Z', + '2026-02-01T00:00:00Z', + ]) + }) +}) + +describe('radarSort', () => { + test('dispatches to sortByUpdated for "updated"', () => { + const items = [item('a', '2026-01-01T00:00:00Z'), item('b', '2026-03-01T00:00:00Z')] + expect(radarSort(items, 'updated').map((i) => i.title)).toEqual(['b', 'a']) + }) + + test('dispatches to sortByTitle for "title"', () => { + const items = [item('Zebra', '2026-01-01T00:00:00Z'), item('Apple', '2026-01-01T00:00:00Z')] + expect(radarSort(items, 'title').map((i) => i.title)).toEqual(['Apple', 'Zebra']) + }) +}) + +describe('radarLabelCounts', () => { + test('tallies labels across items, descending by count then alphabetically', () => { + const items = [ + item('a', '2026-01-01T00:00:00Z', ['bug', 'ui']), + item('b', '2026-01-01T00:00:00Z', ['bug']), + item('c', '2026-01-01T00:00:00Z', ['ui']), + item('d', '2026-01-01T00:00:00Z', ['docs']), + ] + const counts = radarLabelCounts(items) + expect(counts).toEqual([ + { label: 'bug', count: 2 }, + { label: 'ui', count: 2 }, + { label: 'docs', count: 1 }, + ]) + }) + + test('null labels contribute nothing, never a fabricated "unknown" bucket', () => { + const items = [ + item('a', '2026-01-01T00:00:00Z', null), + item('b', '2026-01-01T00:00:00Z', ['x']), + ] + expect(radarLabelCounts(items)).toEqual([{ label: 'x', count: 1 }]) + }) + + test('no items, no labels: an empty list, not an error', () => { + expect(radarLabelCounts([])).toEqual([]) + }) +}) + +describe('matchesLabels / radarFilterByLabels', () => { + test('an empty selection matches everything, including null labels', () => { + expect(matchesLabels(null, [])).toBe(true) + expect(matchesLabels(['a'], [])).toBe(true) + }) + + test('a single selected label matches items carrying it', () => { + expect(matchesLabels(['bug', 'ui'], ['bug'])).toBe(true) + expect(matchesLabels(['ui'], ['bug'])).toBe(false) + }) + + test('cumulative selection is AND: every selected label must be present', () => { + expect(matchesLabels(['bug', 'ui'], ['bug', 'ui'])).toBe(true) + expect(matchesLabels(['bug'], ['bug', 'ui'])).toBe(false) + }) + + test('null labels never match a non-empty selection', () => { + expect(matchesLabels(null, ['bug'])).toBe(false) + }) + + test('radarFilterByLabels narrows the list accordingly', () => { + const items = [ + item('a', '2026-01-01T00:00:00Z', ['bug', 'ui']), + item('b', '2026-01-01T00:00:00Z', ['bug']), + item('c', '2026-01-01T00:00:00Z', null), + ] + expect(radarFilterByLabels(items, ['bug', 'ui']).map((i) => i.title)).toEqual(['a']) + expect(radarFilterByLabels(items, []).map((i) => i.title)).toEqual(['a', 'b', 'c']) + }) +}) + +describe('matchesMrStateFilter / radarFilterMrsByState', () => { + test('"all" matches every draft state, including unknown', () => { + expect(matchesMrStateFilter({ isDraft: true }, 'all')).toBe(true) + expect(matchesMrStateFilter({ isDraft: false }, 'all')).toBe(true) + expect(matchesMrStateFilter({ isDraft: null }, 'all')).toBe(true) + }) + + test('"draft" matches only an explicit true', () => { + expect(matchesMrStateFilter({ isDraft: true }, 'draft')).toBe(true) + expect(matchesMrStateFilter({ isDraft: false }, 'draft')).toBe(false) + expect(matchesMrStateFilter({ isDraft: null }, 'draft')).toBe(false) + }) + + test('"ready" matches false and unknown, mirroring MrCard\'s own badge reading', () => { + expect(matchesMrStateFilter({ isDraft: false }, 'ready')).toBe(true) + expect(matchesMrStateFilter({ isDraft: null }, 'ready')).toBe(true) + expect(matchesMrStateFilter({ isDraft: true }, 'ready')).toBe(false) + }) + + test('these two filters are exclusive: no MR ever matches both draft and ready', () => { + for (const isDraft of [true, false, null] as const) { + const draft = matchesMrStateFilter({ isDraft }, 'draft') + const ready = matchesMrStateFilter({ isDraft }, 'ready') + expect(draft && ready).toBe(false) + } + }) + + test('radarFilterMrsByState narrows the list accordingly', () => { + const mrs = [{ isDraft: true }, { isDraft: false }, { isDraft: null }] + expect(radarFilterMrsByState(mrs, 'draft')).toEqual([{ isDraft: true }]) + expect(radarFilterMrsByState(mrs, 'ready')).toEqual([{ isDraft: false }, { isDraft: null }]) + expect(radarFilterMrsByState(mrs, 'all')).toEqual(mrs) + }) +}) diff --git a/packages/web/src/components/radar/RadarLogic.ts b/packages/web/src/components/radar/RadarLogic.ts new file mode 100644 index 0000000..5537fe3 --- /dev/null +++ b/packages/web/src/components/radar/RadarLogic.ts @@ -0,0 +1,98 @@ +// Pure sort/filter/count logic shared by the two Issue Radar lists (issues +// and merge requests). Split out so it is testable on its own, with no DOM +// and no fetch: IssueRadar.vue only composes these functions. + +import type { ForgeMr } from '../../types' + +/** The two sort criteria offered on both lists: both fields exist, unconditionally, + * on ForgeIssue and ForgeMr alike, so neither can produce a "null" ordering gap. */ +export type RadarSortKey = 'updated' | 'title' + +/** Stable descending sort on `updatedAt`: ties (equal timestamps) keep their + * original relative order, per `toSorted`'s stability guarantee. */ +export function sortByUpdated(items: readonly T[]): T[] { + return items.toSorted((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) +} + +/** Stable alphabetical sort on `title`, locale-aware; ties keep their original order. */ +export function sortByTitle(items: readonly T[]): T[] { + return items.toSorted((a, b) => a.title.localeCompare(b.title)) +} + +export function radarSort( + items: readonly T[], + key: RadarSortKey, +): T[] { + return key === 'title' ? sortByTitle(items) : sortByUpdated(items) +} + +/** + * One label's tally over the currently loaded (and, for MRs, state-filtered) + * items: never a hardcoded catalog, always derived from what is on screen. + * Sorted by descending count, ties broken alphabetically for a stable list. + */ +export type LabelCount = { label: string; count: number } + +export function radarLabelCounts( + items: readonly T[], +): LabelCount[] { + const counts = new Map() + for (const item of items) { + for (const label of item.labels ?? []) { + counts.set(label, (counts.get(label) ?? 0) + 1) + } + } + return [...counts.entries()] + .map(([label, count]) => ({ label, count })) + .toSorted((a, b) => b.count - a.count || a.label.localeCompare(b.label)) +} + +/** + * Cumulable label selection: AND semantics, each additional label narrows the + * result further (the usual reading of "cumulable" filters, mirroring + * GitHub's own multi-label filter). `null` labels (the forge did not report + * any) never match a non-empty selection: an unknown is not a match. + */ +export function matchesLabels( + itemLabels: readonly string[] | null, + selected: readonly string[], +): boolean { + if (selected.length === 0) { + return true + } + const labels = itemLabels ?? [] + return selected.every((label) => labels.includes(label)) +} + +export function radarFilterByLabels( + items: readonly T[], + selected: readonly string[], +): T[] { + return items.filter((item) => matchesLabels(item.labels, selected)) +} + +/** + * MR-only exclusive filter: the one field that actually discriminates inside + * an OPEN-only corpus (issues carry no such field once narrowed to `open`). + * `ready` mirrors MrCard's own badge reading of `isDraft`: `false` and `null` + * (unknown) both show as "not a draft", so the filter agrees with what the + * card itself displays rather than hiding an unknown-draft MR from both sides. + */ +export type MrStateFilter = 'all' | 'draft' | 'ready' + +export function matchesMrStateFilter(mr: Pick, filter: MrStateFilter): boolean { + if (filter === 'all') { + return true + } + if (filter === 'draft') { + return mr.isDraft === true + } + return mr.isDraft !== true +} + +export function radarFilterMrsByState>( + items: readonly T[], + filter: MrStateFilter, +): T[] { + return items.filter((item) => matchesMrStateFilter(item, filter)) +} diff --git a/packages/web/src/components/radar/RadarPrefs.test.ts b/packages/web/src/components/radar/RadarPrefs.test.ts new file mode 100644 index 0000000..7b53bd5 --- /dev/null +++ b/packages/web/src/components/radar/RadarPrefs.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test' +import { + DEFAULT_RADAR_PREFS, + parseRadarPrefs, + RADAR_PREFS_STORAGE_KEY, + readRadarPrefs, + serializeRadarPrefs, + writeRadarPrefs, + type RadarPrefs, +} from './RadarPrefs' + +describe('parseRadarPrefs', () => { + test('absent (null) falls back to the defaults', () => { + expect(parseRadarPrefs(null)).toEqual(DEFAULT_RADAR_PREFS) + }) + + test('an empty object falls back to the defaults for every field', () => { + expect(parseRadarPrefs('{}')).toEqual(DEFAULT_RADAR_PREFS) + }) + + test('a partial blob keeps its known fields and defaults the rest', () => { + const partial = parseRadarPrefs(JSON.stringify({ issuesSort: 'title', mrsLabels: ['ui'] })) + expect(partial).toEqual({ + ...DEFAULT_RADAR_PREFS, + issuesSort: 'title', + mrsLabels: ['ui'], + }) + }) + + test('a corrupted (non-JSON) blob falls back to the defaults', () => { + expect(parseRadarPrefs('{not json')).toEqual(DEFAULT_RADAR_PREFS) + }) + + test('a JSON value that is not an object falls back to the defaults', () => { + expect(parseRadarPrefs('42')).toEqual(DEFAULT_RADAR_PREFS) + expect(parseRadarPrefs('null')).toEqual(DEFAULT_RADAR_PREFS) + expect(parseRadarPrefs('"hello"')).toEqual(DEFAULT_RADAR_PREFS) + expect(parseRadarPrefs('[1,2,3]')).toEqual(DEFAULT_RADAR_PREFS) + }) + + test('a mistyped field is ignored in favor of its default, others still honored', () => { + const parsed = parseRadarPrefs( + JSON.stringify({ issuesOpen: 'yes', mrsFilter: 'draft', issuesLabels: ['a', 2, 'b'] }), + ) + expect(parsed.issuesOpen).toBe(DEFAULT_RADAR_PREFS.issuesOpen) + expect(parsed.mrsFilter).toBe('draft') + expect(parsed.issuesLabels).toEqual(DEFAULT_RADAR_PREFS.issuesLabels) + }) + + test('an unknown sort key or filter value falls back to its default', () => { + const parsed = parseRadarPrefs( + JSON.stringify({ issuesSort: 'popularity', mrsFilter: 'closed' }), + ) + expect(parsed.issuesSort).toBe(DEFAULT_RADAR_PREFS.issuesSort) + expect(parsed.mrsFilter).toBe(DEFAULT_RADAR_PREFS.mrsFilter) + }) + + test('round-trips through serializeRadarPrefs', () => { + const prefs: RadarPrefs = { + issuesOpen: false, + mrsOpen: true, + issuesSort: 'title', + mrsSort: 'updated', + mrsFilter: 'ready', + issuesLabels: ['bug'], + mrsLabels: [], + } + expect(parseRadarPrefs(serializeRadarPrefs(prefs))).toEqual(prefs) + }) +}) + +describe('readRadarPrefs / writeRadarPrefs (localStorage wrappers)', () => { + test('reads and writes through a working localStorage, and survives a hostile one', () => { + const store = new Map() + const stub = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + try { + globals.localStorage = stub + expect(readRadarPrefs()).toEqual(DEFAULT_RADAR_PREFS) + + const prefs: RadarPrefs = { ...DEFAULT_RADAR_PREFS, issuesSort: 'title', mrsOpen: false } + writeRadarPrefs(prefs) + expect(store.get(RADAR_PREFS_STORAGE_KEY)).toBe(JSON.stringify(prefs)) + expect(readRadarPrefs()).toEqual(prefs) + + globals.localStorage = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('denied') + }, + } + expect(readRadarPrefs()).toEqual(DEFAULT_RADAR_PREFS) + expect(() => writeRadarPrefs(prefs)).not.toThrow() + } finally { + globals.localStorage = previous + } + }) +}) diff --git a/packages/web/src/components/radar/RadarPrefs.ts b/packages/web/src/components/radar/RadarPrefs.ts new file mode 100644 index 0000000..6219a3c --- /dev/null +++ b/packages/web/src/components/radar/RadarPrefs.ts @@ -0,0 +1,112 @@ +// One JSON blob of Issue Radar UI preferences (accordion open/closed, sort, +// filters, selected labels), persisted in localStorage. Same doctrine as +// useProjects.ts / useIsolation.ts: a pure parse function tested on its own, +// tolerant of an absent, empty, partial or corrupted blob, plus a thin +// try/catch wrapper around the real localStorage for the impure edges. + +import type { MrStateFilter, RadarSortKey } from './RadarLogic' + +export type RadarPrefs = { + issuesOpen: boolean + mrsOpen: boolean + issuesSort: RadarSortKey + mrsSort: RadarSortKey + mrsFilter: MrStateFilter + issuesLabels: string[] + mrsLabels: string[] +} + +export const DEFAULT_RADAR_PREFS: RadarPrefs = { + issuesOpen: true, + mrsOpen: true, + issuesSort: 'updated', + mrsSort: 'updated', + mrsFilter: 'all', + issuesLabels: [], + mrsLabels: [], +} + +export const RADAR_PREFS_STORAGE_KEY = 'codesema-ws-radar-prefs' + +const SORT_KEYS: readonly RadarSortKey[] = ['updated', 'title'] +const MR_FILTERS: readonly MrStateFilter[] = ['all', 'draft', 'ready'] + +function isSortKey(value: unknown): value is RadarSortKey { + return typeof value === 'string' && (SORT_KEYS as readonly string[]).includes(value) +} + +function isMrFilter(value: unknown): value is MrStateFilter { + return typeof value === 'string' && (MR_FILTERS as readonly string[]).includes(value) +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean' +} + +/** Keeps `value` when `guard` accepts it, falls back to `fallback` otherwise: + * the one branch every field of the blob shares, factored out so the parse + * function itself is a flat list of fields rather than a chain of ternaries. */ +function pick(value: unknown, guard: (v: unknown) => v is T, fallback: T): T { + return guard(value) ? value : fallback +} + +/** + * Tolerant parse: any field missing, mistyped, or the whole blob unreadable + * (not JSON, not an object) falls back to its own default rather than + * rejecting the whole blob: a partial preference set is still worth honoring. + */ +export function parseRadarPrefs(raw: string | null): RadarPrefs { + if (raw === null) { + return DEFAULT_RADAR_PREFS + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return DEFAULT_RADAR_PREFS + } + if (typeof parsed !== 'object' || parsed === null) { + return DEFAULT_RADAR_PREFS + } + const p = parsed as Partial + return { + issuesOpen: pick(p.issuesOpen, isBoolean, DEFAULT_RADAR_PREFS.issuesOpen), + mrsOpen: pick(p.mrsOpen, isBoolean, DEFAULT_RADAR_PREFS.mrsOpen), + issuesSort: pick(p.issuesSort, isSortKey, DEFAULT_RADAR_PREFS.issuesSort), + mrsSort: pick(p.mrsSort, isSortKey, DEFAULT_RADAR_PREFS.mrsSort), + mrsFilter: pick(p.mrsFilter, isMrFilter, DEFAULT_RADAR_PREFS.mrsFilter), + issuesLabels: pick(p.issuesLabels, isStringArray, DEFAULT_RADAR_PREFS.issuesLabels), + mrsLabels: pick(p.mrsLabels, isStringArray, DEFAULT_RADAR_PREFS.mrsLabels), + } +} + +export function serializeRadarPrefs(prefs: RadarPrefs): string { + return JSON.stringify(prefs) +} + +// ── localStorage wrappers (best-effort: privacy modes / disabled storage can throw) ── + +export function readRadarPrefs(): RadarPrefs { + try { + if (typeof localStorage === 'undefined') { + return DEFAULT_RADAR_PREFS + } + return parseRadarPrefs(localStorage.getItem(RADAR_PREFS_STORAGE_KEY)) + } catch { + return DEFAULT_RADAR_PREFS + } +} + +export function writeRadarPrefs(prefs: RadarPrefs): void { + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(RADAR_PREFS_STORAGE_KEY, serializeRadarPrefs(prefs)) + } + } catch { + // Best-effort, like every other persisted UI preference in this app. + } +} diff --git a/packages/web/src/i18n.ts b/packages/web/src/i18n.ts index 31138ea..0b0f5a8 100644 --- a/packages/web/src/i18n.ts +++ b/packages/web/src/i18n.ts @@ -634,6 +634,30 @@ const en = { 'workspace.forgeReasonUnknown': 'for a reason this workspace could not name', 'workspace.forgeUnavailableHint': 'Conversations still run: a task can be created from a title and a prompt, and a task already bound to a ticket carries on with the copy it froze. What is off: binding a new issue, posting a recap on a merge request, and merging.', + + // --- Issue Radar (C3): two accordions, issues and pull requests --- + 'radar.issuesTitle': 'Issues', + 'radar.mrsTitle': 'Pull requests', + 'radar.sortLabel': 'Sort', + 'radar.sortUpdated': 'Recently updated', + 'radar.sortTitle': 'Title (A→Z)', + 'radar.filterAria': 'Filter by status', + 'radar.filterAll': 'All', + 'radar.filterDraft': 'Draft', + 'radar.filterReady': 'Ready', + 'radar.labelsAria': 'Filter by label', + 'radar.truncatedHint': '{n} shown, the forge has more.', + 'radar.loading': 'Loading…', + 'radar.retry': 'Retry', + 'radar.transportError': 'Could not load ({error}).', + 'radar.issuesEmpty': 'No open issue.', + 'radar.mrsEmpty': 'No open merge request.', + 'radar.issuesReasonNoRemote': 'No forge remote configured for this repo.', + 'radar.issuesReasonNoCli': 'Install gh (GitHub) or glab (GitLab) to list issues.', + 'radar.issuesReasonCliError': 'The forge CLI returned an unexpected response.', + 'radar.issuesReasonInvalidInput': 'This project could not be resolved to a forge issue list.', + 'radar.issuesReasonUnsupported': 'This forge does not support listing issues.', + 'radar.openItemAria': 'Open "{title}" in the forge', } export type MessageKey = keyof typeof en @@ -1252,6 +1276,30 @@ const fr: Record = { 'workspace.forgeReasonUnknown': "pour une raison que ce workspace n'a pas su nommer", 'workspace.forgeUnavailableHint': "Les conversations continuent : on peut créer une tâche à partir d'un titre et d'un prompt, et une tâche déjà rattachée à un ticket poursuit sur la copie qu'elle a figée. Ce qui est coupé : rattacher une nouvelle issue, poster un récap sur une merge request, et merger.", + + // --- Issue Radar (C3) : deux accordéons, issues et pull requests --- + 'radar.issuesTitle': 'Issues', + 'radar.mrsTitle': 'Pull requests', + 'radar.sortLabel': 'Trier', + 'radar.sortUpdated': 'Récemment mises à jour', + 'radar.sortTitle': 'Titre (A→Z)', + 'radar.filterAria': 'Filtrer par statut', + 'radar.filterAll': 'Toutes', + 'radar.filterDraft': 'Brouillon', + 'radar.filterReady': 'Prêtes', + 'radar.labelsAria': 'Filtrer par label', + 'radar.truncatedHint': '{n} affichées, la forge en a davantage.', + 'radar.loading': 'Chargement…', + 'radar.retry': 'Réessayer', + 'radar.transportError': 'Impossible de charger ({error}).', + 'radar.issuesEmpty': 'Aucune issue ouverte.', + 'radar.mrsEmpty': 'Aucune merge request ouverte.', + 'radar.issuesReasonNoRemote': 'Aucun remote de forge configuré pour ce dépôt.', + 'radar.issuesReasonNoCli': 'Installe gh (GitHub) ou glab (GitLab) pour lister les issues.', + 'radar.issuesReasonCliError': 'La CLI de la forge a renvoyé une réponse inattendue.', + 'radar.issuesReasonInvalidInput': "Ce projet n'a pas pu être résolu en liste d'issues de forge.", + 'radar.issuesReasonUnsupported': 'Cette forge ne prend pas en charge la liste des issues.', + 'radar.openItemAria': 'Ouvrir « {title} » dans la forge', } /** From 1337e44fac3f78c773d9fc5faad79419edb1b319 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 24 Aug 2026 10:56:26 +0200 Subject: [PATCH 10/89] fix(web): keep the merge request list outcome instead of a bare array --- packages/web/src/components/WorkspaceView.vue | 3 +- .../src/components/radar/IssueRadar.test.ts | 107 +++++++---- .../web/src/components/radar/IssueRadar.vue | 60 ++++-- packages/web/src/composables/useTasks.test.ts | 176 +++++++++++++++++- packages/web/src/composables/useTasks.ts | 36 +++- 5 files changed, 324 insertions(+), 58 deletions(-) diff --git a/packages/web/src/components/WorkspaceView.vue b/packages/web/src/components/WorkspaceView.vue index 06740f4..14f6416 100644 --- a/packages/web/src/components/WorkspaceView.vue +++ b/packages/web/src/components/WorkspaceView.vue @@ -68,6 +68,7 @@ const { connections, projects, mrsByProject, + mrsLoadByProject, branchesByProject, start, stop, @@ -735,7 +736,7 @@ async function onRemoveProject(id: string): Promise {

diff --git a/packages/web/src/components/radar/IssueRadar.test.ts b/packages/web/src/components/radar/IssueRadar.test.ts index 21176cb..fe26989 100644 --- a/packages/web/src/components/radar/IssueRadar.test.ts +++ b/packages/web/src/components/radar/IssueRadar.test.ts @@ -8,8 +8,9 @@ import { createSSRApp } from 'vue' import { compileScript, parse } from 'vue/compiler-sfc' import { renderToString } from 'vue/server-renderer' import type { ProjectIssuesState } from '../../composables/useIssues' +import type { MrsLoadState } from '../../composables/useTasks' import { t } from '../../i18n' -import type { ForgeIssue, ForgeIssuesResult, ForgeMr, WorkspaceInfo } from '../../types' +import type { ForgeIssue, ForgeIssuesResult, ForgeMr } from '../../types' Bun.plugin({ name: 'vue-sfc-with-template', @@ -72,19 +73,10 @@ function available(issues: ForgeIssue[], truncated = false): ForgeIssuesResult { return { available: true, truncated, issues } } -function workspace(overrides: Partial = {}): WorkspaceInfo { - return { - isolation_available: true, - isolation_default: 'container', - isolation_reason: 'ok', - ...overrides, - } -} - async function render(props: { issuesState: ProjectIssuesState mrs: ForgeMr[] - workspace: WorkspaceInfo | null + mrsState: MrsLoadState | null }): Promise { const IssueRadar = (await import('./IssueRadar.vue')).default const app = createSSRApp(IssueRadar, props) @@ -98,12 +90,17 @@ function issuesSectionOf(html: string): string { return html.slice(0, html.indexOf('Pull requests')) } +/** Symmetric cut, starting at the Pull requests accordion. */ +function mrsSectionOf(html: string): string { + return html.slice(html.indexOf('Pull requests')) +} + describe('issues: loading / error / unavailable / empty / list', () => { test('no result yet: shows the loading message, no count badge', async () => { const html = await render({ issuesState: issuesState({ loading: true }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).toContain(t('radar.loading')) expect(issuesSectionOf(html)).not.toContain('ra-count') @@ -113,7 +110,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { const html = await render({ issuesState: issuesState({ error: 'HTTP 500' }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).toContain(t('radar.transportError', { error: 'HTTP 500' })) expect(html).toContain(t('radar.retry')) @@ -129,7 +126,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { const html = await render({ issuesState: issuesState({ result: { available: false, reason } }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).toContain(t(key)) // Never confused with "no open issue" (a success), nor with the retry flow. @@ -141,7 +138,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { const html = await render({ issuesState: issuesState({ result: available([]) }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).toContain(t('radar.issuesEmpty')) expect(html).toContain('>0<') // count badge shows the measured zero @@ -152,7 +149,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { const html = await render({ issuesState: issuesState({ result: { available: false, reason: 'no-remote' } }), mrs: [], - workspace: null, + mrsState: null, }) expect(issuesSectionOf(html)).not.toContain('ra-count') }) @@ -163,7 +160,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { result: available([issue({ number: 1 }), issue({ number: 2 })], true), }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).toContain(t('radar.truncatedHint', { n: 2 })) }) @@ -172,7 +169,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { const html = await render({ issuesState: issuesState({ result: available([issue()], false) }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).not.toContain('ra-truncated') }) @@ -183,7 +180,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { const html = await render({ issuesState: issuesState({ result: available([older, newer]) }), mrs: [], - workspace: null, + mrsState: null, }) expect(html.indexOf('newer')).toBeLessThan(html.indexOf('older')) }) @@ -194,7 +191,7 @@ describe('issues: loading / error / unavailable / empty / list', () => { result: available([issue({ url: 'https://example.test/issues/9' })]), }), mrs: [], - workspace: null, + mrsState: null, }) expect(html).toContain('href="https://example.test/issues/9"') expect(html).toContain('target="_blank"') @@ -204,45 +201,72 @@ describe('issues: loading / error / unavailable / empty / list', () => { const withLabels = await render({ issuesState: issuesState({ result: available([issue({ labels: ['bug'] })]) }), mrs: [], - workspace: null, + mrsState: null, }) expect(withLabels).toContain('bug') const empty = await render({ issuesState: issuesState({ result: available([]) }), mrs: [], - workspace: null, + mrsState: null, }) expect(empty).not.toContain('lc-chip') }) }) -describe('pull requests: forge unavailable vs empty vs list', () => { - test('forge unavailable (workspace overlay) hides the list and its controls', async () => { +describe('pull requests: transport error / forge unavailable / empty / list / truncated', () => { + test('a transport error shows the error, never a reason or the empty message', async () => { const html = await render({ issuesState: issuesState(), - mrs: [mr()], - workspace: workspace({ forge_available: false, forge_reason: 'no-cli' }), + mrs: [], + mrsState: { status: 'error', error: 'HTTP 500' }, }) - expect(html).toContain(t('workspace.forgeReasonNoCli')) - expect(html).not.toContain('ir-filters') + expect(html).toContain(t('radar.transportError', { error: 'HTTP 500' })) + expect(html).not.toContain(t('radar.mrsEmpty')) + expect(mrsSectionOf(html)).not.toContain('ra-count') }) - test('an unknown workspace (null) never claims unavailability', async () => { - const html = await render({ issuesState: issuesState(), mrs: [], workspace: null }) + test.each([ + ['no-remote', 'mrs.reasonNoRemote'], + ['no-cli', 'mrs.reasonNoCli'], + ['cli-error', 'mrs.reasonCliError'], + ] as const)( + 'forge unavailable (%s) renders its own distinct message, hides the list and its controls', + async (reason, key) => { + const html = await render({ + issuesState: issuesState(), + mrs: [mr()], + mrsState: { status: 'unavailable', reason }, + }) + expect(html).toContain(t(key)) + expect(html).not.toContain(t('radar.mrsEmpty')) + expect(html).not.toContain('ir-filters') + // Unknown, not zero: no fabricated count for a list that could not be fetched. + expect(mrsSectionOf(html)).not.toContain('ra-count') + }, + ) + + test('an unknown mrsState (not fetched yet) never claims unavailability, and shows no count badge', async () => { + const html = await render({ issuesState: issuesState(), mrs: [], mrsState: null }) expect(html).toContain(t('radar.mrsEmpty')) + expect(mrsSectionOf(html)).not.toContain('ra-count') }) - test('an empty, available MR list renders "no open merge request"', async () => { - const html = await render({ issuesState: issuesState(), mrs: [], workspace: workspace() }) + test('an empty, LOADED list is a success: "no open merge request", with the measured 0', async () => { + const html = await render({ + issuesState: issuesState(), + mrs: [], + mrsState: { status: 'loaded', truncated: false }, + }) expect(html).toContain(t('radar.mrsEmpty')) + expect(mrsSectionOf(html)).toContain('>0<') }) test('a non-empty list renders the count, the status filter chips and each MR', async () => { const html = await render({ issuesState: issuesState(), mrs: [mr({ number: 1 }), mr({ number: 2 })], - workspace: workspace(), + mrsState: { status: 'loaded', truncated: false }, }) expect(html).toContain('>2<') expect(html).toContain(t('radar.filterAll')) @@ -256,16 +280,25 @@ describe('pull requests: forge unavailable vs empty vs list', () => { const html = await render({ issuesState: issuesState(), mrs: [mr({ url: 'https://example.test/mr/99' })], - workspace: workspace(), + mrsState: { status: 'loaded', truncated: false }, }) expect(html).toContain('href="https://example.test/mr/99"') }) - test('never claims a truncation the mrsByProject cache cannot report', async () => { + test('a truncated MR list says so explicitly, with the number actually shown', async () => { + const html = await render({ + issuesState: issuesState(), + mrs: [mr(), mr({ number: 2 })], + mrsState: { status: 'loaded', truncated: true }, + }) + expect(html).toContain(t('radar.truncatedHint', { n: 2 })) + }) + + test('a non-truncated MR list carries no truncation caveat', async () => { const html = await render({ issuesState: issuesState(), mrs: [mr(), mr({ number: 2 })], - workspace: workspace(), + mrsState: { status: 'loaded', truncated: false }, }) expect(html).not.toContain('ra-truncated') }) @@ -294,7 +327,7 @@ describe('the two accordions fold independently, from the persisted prefs blob', const html = await render({ issuesState: issuesState({ result: available([issue({ title: 'HIDDEN_ISSUE' })]) }), mrs: [mr({ title: 'SHOWN_MR' })], - workspace: workspace(), + mrsState: { status: 'loaded', truncated: false }, }) expect(html).not.toContain('HIDDEN_ISSUE') expect(html).toContain('SHOWN_MR') diff --git a/packages/web/src/components/radar/IssueRadar.vue b/packages/web/src/components/radar/IssueRadar.vue index a95957d..a2b29cd 100644 --- a/packages/web/src/components/radar/IssueRadar.vue +++ b/packages/web/src/components/radar/IssueRadar.vue @@ -2,13 +2,14 @@ // Issue Radar: the two accordions (issues, pull requests) shown in the focus // zone in place of the sober empty-state message once a project is selected. // Pure presentational orchestration: all fetching lives upstream (useIssues -// for issues, useTasks' mrsByProject for MRs, already wired in WorkspaceView); -// this component only sorts, filters, counts and persists the UI prefs. +// for issues, useTasks' mrsByProject/mrsLoadByProject for MRs, already wired +// in WorkspaceView); this component only sorts, filters, counts and persists +// the UI prefs. import { computed, ref, watch } from 'vue' import type { ProjectIssuesState } from '../../composables/useIssues' -import { forgeUnavailableKey } from '../../composables/useProjects' +import type { MrsLoadState } from '../../composables/useTasks' import { t } from '../../i18n' -import type { ForgeMr, WorkspaceInfo } from '../../types' +import type { ForgeMr } from '../../types' import MrCard from '../mr/MrCard.vue' import IssueCard from './IssueCard.vue' import LabelChips from './LabelChips.vue' @@ -26,12 +27,10 @@ import { readRadarPrefs, writeRadarPrefs } from './RadarPrefs' const props = defineProps<{ issuesState: ProjectIssuesState mrs: ForgeMr[] - /** Process-wide/project isolation facts (T1.4): the only surviving signal - * this screen has for "the forge could not be reached" on the MR side, - * since the shared mrsByProject cache (useTasks.ts, out of this chunk's - * scope) collapses both a genuinely empty list and an unreachable forge - * down to the same `[]`, see the component doc below for the tradeoff. */ - workspace: WorkspaceInfo | null + /** The fact behind the last GET /api/mrs of this project (useTasks.ts' + * mrsLoadByProject): null while nothing was fetched yet, distinct from + * both a loaded empty list and a forge that could not be reached. */ + mrsState: MrsLoadState | null }>() const emit = defineEmits<{ 'retry-issues': [] }>() @@ -110,9 +109,31 @@ const issuesVisible = computed(() => ), ) -// ── Pull requests: state filter, then labels, then sort ─────────────────── -const mrsForgeUnavailableKey = computed(() => forgeUnavailableKey(props.workspace)) -const mrsCount = computed(() => (mrsForgeUnavailableKey.value === null ? props.mrs.length : null)) +// ── Pull requests: transport error / forge unavailable / empty / list ───── +const MRS_REASON_KEY = { + 'no-remote': 'mrs.reasonNoRemote', + 'no-cli': 'mrs.reasonNoCli', + 'cli-error': 'mrs.reasonCliError', +} as const + +const mrsErrorMessage = computed(() => + props.mrsState?.status === 'error' + ? t('radar.transportError', { error: props.mrsState.error }) + : null, +) +const mrsUnavailableKey = computed(() => + props.mrsState?.status === 'unavailable' ? MRS_REASON_KEY[props.mrsState.reason] : null, +) +const mrsDegraded = computed( + () => mrsErrorMessage.value !== null || mrsUnavailableKey.value !== null, +) +// Null (never a fabricated 0) until a fetch actually resolved into a count. +const mrsCount = computed(() => (props.mrsState?.status === 'loaded' ? props.mrs.length : null)) +const mrsTruncatedHint = computed(() => + props.mrsState?.status === 'loaded' && props.mrsState.truncated + ? t('radar.truncatedHint', { n: props.mrs.length }) + : null, +) const mrsStateFiltered = computed(() => radarFilterMrsByState(props.mrs, mrsFilter.value)) const mrsLabelCounts = computed(() => radarLabelCounts(mrsStateFiltered.value)) const mrsVisible = computed(() => @@ -182,12 +203,12 @@ const MR_FILTER_LABEL_KEY = { v-model:open="mrsOpen" :label="t('radar.mrsTitle')" :count="mrsCount" - :truncated-hint="null" + :truncated-hint="mrsTruncatedHint" > - @@ -82,11 +457,10 @@ const emit = defineEmits<{ .fcp-root { display: flex; flex-direction: column; - gap: 10px; height: 100%; min-height: 0; overflow-y: auto; - padding: 12px 10px; + padding: 10px 0 0; } .fcp-root--collapsed { @@ -108,6 +482,7 @@ const emit = defineEmits<{ background: var(--cs-surface); color: var(--cs-muted); cursor: pointer; + margin: 0 8px 8px 0; } .fcp-collapse:hover { @@ -165,33 +540,248 @@ const emit = defineEmits<{ } } -.fcp-nav { +.fcp-sections { display: flex; flex-direction: column; - gap: 4px; + min-height: 0; } -.fcp-nav-item { +/* Section container: 8px external margin, 16px radius, 1px neutral border, + elevated surface, discrete shadow. */ +.fcp-section { + margin: 8px; + border: 1px solid var(--cs-line-2); + border-radius: 16px; + background: var(--cs-surface-2); + box-shadow: var(--cs-shadow-card); + overflow: hidden; +} + +.fcp-acc-head { + display: flex; + align-items: center; + gap: 6px; + width: 100%; text-align: left; font-family: inherit; - font-size: 13px; + font-size: 12px; font-weight: 600; - padding: 7px 10px; - border: 1px solid var(--cs-line-2); - border-radius: 8px; - background: var(--cs-surface); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 12px; + border: none; + background: transparent; color: var(--cs-muted); cursor: pointer; } -.fcp-nav-item:hover { - border-color: var(--cs-line-3); +.fcp-acc-head:hover { + color: var(--cs-text); +} + +.fcp-acc-icon { + flex: none; + width: 13px; + height: 13px; +} + +.fcp-acc-chevron { + flex: none; + width: 14px; + height: 14px; + margin-left: auto; + transition: transform 150ms ease; +} + +.fcp-acc-chevron--closed { + transform: rotate(-90deg); +} + +.fcp-acc-body { + padding-bottom: 12px; +} + +.fcp-block { + padding: 0 12px; +} + +.fcp-block-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 20px 0 6px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--cs-ghost); +} + +.fcp-block-title-text { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.fcp-block-icon { + flex: none; + width: 12px; + height: 12px; +} + +.fcp-row-list { + display: flex; + flex-direction: column; + gap: 2px; } -/* The active section is a state: colored, per the doctrine. */ -.fcp-nav-item--on { - border-color: var(--cs-green-ring); +.fcp-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + font-family: inherit; + font-size: 13px; + color: var(--cs-muted); + padding: 6px 8px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; +} + +.fcp-row:hover { + background: var(--cs-hover); +} + +/* Selected: an accent-weak fill and a heavier weight, per the doctrine. + Never a border -- a row's identity comes from its content and fill. */ +.fcp-row--on { background: var(--cs-green-soft); color: var(--cs-text); + font-weight: 500; +} + +.fcp-row-icon { + flex: none; + width: 14px; + height: 14px; +} + +/* Between the mutually exclusive states and the cumulable toggles. */ +.fcp-filter-sep { + height: 1px; + margin: 4px 0; + background: var(--cs-line-2); +} + +.fcp-reset { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + font-family: inherit; + font-size: 12px; + font-weight: 500; + text-transform: none; + letter-spacing: normal; + color: var(--cs-ghost); + background: transparent; + border: none; + cursor: pointer; +} + +.fcp-reset:hover { + color: var(--cs-text-2); +} + +.fcp-reset svg { + width: 11px; + height: 11px; +} + +.fcp-search-toggle { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--cs-ghost); + cursor: pointer; +} + +.fcp-search-toggle svg { + width: 13px; + height: 13px; +} + +.fcp-search-toggle:hover { + color: var(--cs-text-2); +} + +.fcp-search-toggle--on { + color: var(--cs-green-text); +} + +.fcp-label-search { + position: relative; + margin: 0 0 8px; +} + +.fcp-label-search-input { + width: 100%; + font-family: inherit; + font-size: 13px; + padding: 6px 28px 6px 12px; + border: 1px solid var(--cs-line-2); + border-radius: 8px; + background: var(--cs-inset); + color: var(--cs-text); +} + +.fcp-label-search-close { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--cs-ghost); + cursor: pointer; +} + +.fcp-label-search-close svg { + width: 11px; + height: 11px; +} + +.fcp-label-search-close:hover { + color: var(--cs-text-2); +} + +.fcp-acc-icon, +.fcp-acc-chevron, +.fcp-block-icon, +.fcp-row-icon, +.fcp-search-toggle svg, +.fcp-label-search-close svg, +.fcp-reset svg { + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; } diff --git a/packages/web/src/components/forge/ForgeListPanel.test.ts b/packages/web/src/components/forge/ForgeListPanel.test.ts index acffcdb..2cb7e2f 100644 --- a/packages/web/src/components/forge/ForgeListPanel.test.ts +++ b/packages/web/src/components/forge/ForgeListPanel.test.ts @@ -224,16 +224,6 @@ describe('issues: loading / error / unavailable / empty / list', () => { expect(html).toContain('class="flp-item') expect(html).not.toContain('target="_blank"') }) - - test('label chips render from the loaded issues, hidden on an empty list', async () => { - const withLabels = await render({ - issuesState: issuesState({ result: available([issue({ labels: [label('bug')] })]) }), - }) - expect(withLabels).toContain('bug') - - const empty = await render({ issuesState: issuesState({ result: available([]) }) }) - expect(empty).not.toContain('lc-chip') - }) }) describe('pull requests: transport error / forge unavailable / empty / list / truncated', () => { @@ -249,14 +239,13 @@ describe('pull requests: transport error / forge unavailable / empty / list / tr ['no-cli', 'mrs.reasonNoCli'], ['cli-error', 'mrs.reasonCliError'], ] as const)( - 'forge unavailable (%s) renders its own distinct message, hides the list and its controls', + 'forge unavailable (%s) renders its own distinct message, hides the list', async (reason, key) => { const html = await render( mrsProps({ mrs: [mr()], mrsState: { status: 'unavailable', reason } }), ) expect(html).toContain(t(key)) expect(html).not.toContain(t('forge.mrsEmpty')) - expect(html).not.toContain('flp-filters') expect(html).not.toContain('flp-count') }, ) @@ -273,7 +262,7 @@ describe('pull requests: transport error / forge unavailable / empty / list / tr expect(countBadgeOf(html)).toBe('0') }) - test('a non-empty list renders the count, the status filter chips and each MR', async () => { + test('a non-empty list renders the count and each MR', async () => { const html = await render( mrsProps({ mrs: [mr({ number: 1 }), mr({ number: 2 })], @@ -281,9 +270,6 @@ describe('pull requests: transport error / forge unavailable / empty / list / tr }), ) expect(countBadgeOf(html)).toBe('2') - expect(html).toContain(t('forge.filterAll')) - expect(html).toContain(t('forge.filterDraft')) - expect(html).toContain(t('forge.filterReady')) expect(html).toContain(t('mrs.number', { n: 1 })) expect(html).toContain(t('mrs.number', { n: 2 })) }) diff --git a/packages/web/src/components/forge/ForgeListPanel.vue b/packages/web/src/components/forge/ForgeListPanel.vue index 7e4d7ca..2e1915e 100644 --- a/packages/web/src/components/forge/ForgeListPanel.vue +++ b/packages/web/src/components/forge/ForgeListPanel.vue @@ -1,13 +1,15 @@