From 4a53a89a55555582335786d8eeb8bfc44b627d64 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 19 May 2026 13:43:12 -0300 Subject: [PATCH 1/3] Add Vitest test suite with CI and testability refactors. Introduce unit, integration, and CLI smoke tests; extract git parsers, support COMMIT_TOOLS_HOME, and default bare commit to generate. --- .env.example | 4 + .github/workflows/pr-validate.yml | 7 + package.json | 8 +- pnpm-lock.yaml | 834 ++++++++++++++++++++++++- src/cli/commit-errors.test.ts | 15 + src/cli/commit-errors.ts | 6 + src/cli/commit.test.ts | 76 +++ src/cli/commit.ts | 6 +- src/cli/doctor.test.ts | 34 + src/cli/effort.test.ts | 41 ++ src/cli/model.test.ts | 53 ++ src/cli/parser.test.ts | 34 + src/cli/parser.ts | 2 +- src/cli/setup.test.ts | 56 ++ src/cli/update.test.ts | 28 + src/cli/update.ts | 2 +- src/domain/commit/prompts.test.ts | 44 ++ src/domain/config/config.test.ts | 47 ++ src/domain/llm/auth-resolver.test.ts | 73 +++ src/domain/llm/auth-resolver.ts | 2 +- src/domain/llm/response-parser.test.ts | 19 + src/domain/llm/router.test.ts | 42 ++ src/infra/git/parsers.test.ts | 68 ++ src/infra/git/parsers.ts | 72 +++ src/infra/git/repo.integration.test.ts | 43 ++ src/infra/git/repo.ts | 64 +- src/infra/github/pr.test.ts | 56 ++ src/infra/storage/config.test.ts | 41 ++ src/infra/storage/config.ts | 2 +- src/infra/version-check.test.ts | 14 + src/libs/fuzzy.test.ts | 37 ++ test/cli-smoke.test.ts | 45 ++ test/helpers/run-future.ts | 3 + test/helpers/temp-git-repo.ts | 22 + test/setup.ts | 11 + tsconfig.json | 2 +- vitest.config.ts | 23 + 37 files changed, 1867 insertions(+), 69 deletions(-) create mode 100644 src/cli/commit-errors.test.ts create mode 100644 src/cli/commit-errors.ts create mode 100644 src/cli/commit.test.ts create mode 100644 src/cli/doctor.test.ts create mode 100644 src/cli/effort.test.ts create mode 100644 src/cli/model.test.ts create mode 100644 src/cli/parser.test.ts create mode 100644 src/cli/setup.test.ts create mode 100644 src/cli/update.test.ts create mode 100644 src/domain/commit/prompts.test.ts create mode 100644 src/domain/config/config.test.ts create mode 100644 src/domain/llm/auth-resolver.test.ts create mode 100644 src/domain/llm/response-parser.test.ts create mode 100644 src/domain/llm/router.test.ts create mode 100644 src/infra/git/parsers.test.ts create mode 100644 src/infra/git/parsers.ts create mode 100644 src/infra/git/repo.integration.test.ts create mode 100644 src/infra/github/pr.test.ts create mode 100644 src/infra/storage/config.test.ts create mode 100644 src/infra/version-check.test.ts create mode 100644 src/libs/fuzzy.test.ts create mode 100644 test/cli-smoke.test.ts create mode 100644 test/helpers/run-future.ts create mode 100644 test/helpers/temp-git-repo.ts create mode 100644 test/setup.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index f45b205..6f53104 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your-client-secret + +# Test / CI (optional) +# COMMIT_TOOLS_HOME=/tmp/commit-tools-test +# NO_UPDATE_NOTIFIER=true diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml index 1eea11f..22540bf 100644 --- a/.github/workflows/pr-validate.yml +++ b/.github/workflows/pr-validate.yml @@ -28,6 +28,13 @@ jobs: - name: Typecheck run: pnpm run typecheck + - name: Unit tests + env: + GOOGLE_CLIENT_ID: test-client-id + GOOGLE_CLIENT_SECRET: test-client-secret + NO_UPDATE_NOTIFIER: "true" + run: pnpm test + - name: Check formatting run: pnpm exec prettier . --check diff --git a/package.json b/package.json index 31bee60..4abcd4a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "format": "npx prettier . --write", "typecheck": "tsc --noEmit", "lint": "eslint .", - "lint:ci": "eslint ." + "lint:ci": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "repository": { "type": "git", @@ -33,6 +35,7 @@ "@types/luxon": "^3.7.1", "@types/node": "^22", "@types/react": "^19.2.14", + "@vitest/coverage-v8": "^4.1.6", "eslint": "^9.39.4", "eslint-plugin-sonarjs": "^4.0.3", "prettier": "^3.8.1", @@ -40,7 +43,8 @@ "react-devtools-core": "^7.0.1", "tsup": "^8", "tsx": "^4", - "typescript-eslint": "^8.59.0" + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.6" }, "peerDependencies": { "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b6c32c..d417a3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: "@types/react": specifier: ^19.2.14 version: 19.2.14 + "@vitest/coverage-v8": + specifier: ^4.1.6 + version: 4.1.6(vitest@4.1.6) eslint: specifier: ^9.39.4 version: 9.39.4 @@ -82,13 +85,16 @@ importers: version: 7.0.1 tsup: specifier: ^8 - version: 8.5.1(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4 version: 4.21.0 typescript-eslint: specifier: ^8.59.0 version: 8.59.0(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@22.19.15)(@vitest/coverage-v8@4.1.6)(vite@8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0)) packages: "@alcalzone/ansi-tokenize@0.2.5": @@ -104,10 +110,31 @@ packages: zod: optional: true + "@babel/helper-string-parser@7.27.1": + resolution: { integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== } + engines: { node: ">=6.9.0" } + + "@babel/helper-validator-identifier@7.28.5": + resolution: { integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== } + engines: { node: ">=6.9.0" } + + "@babel/parser@7.29.3": + resolution: { integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA== } + engines: { node: ">=6.0.0" } + hasBin: true + "@babel/runtime@7.29.2": resolution: { integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== } engines: { node: ">=6.9.0" } + "@babel/types@7.29.0": + resolution: { integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== } + engines: { node: ">=6.9.0" } + + "@bcoe/v8-coverage@1.0.2": + resolution: { integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== } + engines: { node: ">=18" } + "@clack/core@1.1.0": resolution: { integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA== } @@ -118,6 +145,15 @@ packages: resolution: { integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== } engines: { node: ">=0.1.90" } + "@emnapi/core@1.10.0": + resolution: { integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== } + + "@emnapi/runtime@1.10.0": + resolution: { integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== } + + "@emnapi/wasi-threads@1.2.1": + resolution: { integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== } + "@esbuild/aix-ppc64@0.27.4": resolution: { integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q== } engines: { node: ">=18" } @@ -354,6 +390,15 @@ packages: "@jridgewell/trace-mapping@0.3.31": resolution: { integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } + "@napi-rs/wasm-runtime@1.1.4": + resolution: { integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== } + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + + "@oxc-project/types@0.130.0": + resolution: { integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q== } + "@protobufjs/aspromise@1.1.2": resolution: { integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== } @@ -384,6 +429,104 @@ packages: "@protobufjs/utf8@1.1.0": resolution: { integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== } + "@rolldown/binding-android-arm64@1.0.1": + resolution: { integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + "@rolldown/binding-darwin-arm64@1.0.1": + resolution: { integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + "@rolldown/binding-darwin-x64@1.0.1": + resolution: { integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + "@rolldown/binding-freebsd-x64@1.0.1": + resolution: { integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + "@rolldown/binding-linux-arm-gnueabihf@1.0.1": + resolution: { integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + "@rolldown/binding-linux-arm64-gnu@1.0.1": + resolution: { integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-arm64-musl@1.0.1": + resolution: { integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@rolldown/binding-linux-ppc64-gnu@1.0.1": + resolution: { integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-s390x-gnu@1.0.1": + resolution: { integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-x64-gnu@1.0.1": + resolution: { integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-x64-musl@1.0.1": + resolution: { integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [musl] + + "@rolldown/binding-openharmony-arm64@1.0.1": + resolution: { integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + "@rolldown/binding-wasm32-wasi@1.0.1": + resolution: { integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [wasm32] + + "@rolldown/binding-win32-arm64-msvc@1.0.1": + resolution: { integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + "@rolldown/binding-win32-x64-msvc@1.0.1": + resolution: { integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + "@rolldown/pluginutils@1.0.1": + resolution: { integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== } + "@rollup/rollup-android-arm-eabi@4.59.0": resolution: { integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== } cpu: [arm] @@ -522,12 +665,24 @@ packages: cpu: [x64] os: [win32] + "@standard-schema/spec@1.1.0": + resolution: { integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== } + + "@tybys/wasm-util@0.10.2": + resolution: { integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== } + "@types/body-parser@1.19.6": resolution: { integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== } + "@types/chai@5.2.3": + resolution: { integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== } + "@types/connect@3.4.38": resolution: { integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== } + "@types/deep-eql@4.0.2": + resolution: { integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== } + "@types/estree@1.0.8": resolution: { integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== } @@ -635,6 +790,44 @@ packages: resolution: { integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + "@vitest/coverage-v8@4.1.6": + resolution: { integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ== } + peerDependencies: + "@vitest/browser": 4.1.6 + vitest: 4.1.6 + peerDependenciesMeta: + "@vitest/browser": + optional: true + + "@vitest/expect@4.1.6": + resolution: { integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg== } + + "@vitest/mocker@4.1.6": + resolution: { integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ== } + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + "@vitest/pretty-format@4.1.6": + resolution: { integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw== } + + "@vitest/runner@4.1.6": + resolution: { integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA== } + + "@vitest/snapshot@4.1.6": + resolution: { integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw== } + + "@vitest/spy@4.1.6": + resolution: { integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg== } + + "@vitest/utils@4.1.6": + resolution: { integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ== } + acorn-jsx@5.3.2: resolution: { integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== } peerDependencies: @@ -678,6 +871,13 @@ packages: argparse@2.0.1: resolution: { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== } + assertion-error@2.0.1: + resolution: { integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== } + engines: { node: ">=12" } + + ast-v8-to-istanbul@1.0.0: + resolution: { integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg== } + auto-bind@5.0.1: resolution: { integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } @@ -731,6 +931,10 @@ packages: resolution: { integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== } engines: { node: ">=6" } + chai@6.2.2: + resolution: { integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== } + engines: { node: ">=18" } + chalk@4.1.2: resolution: { integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== } engines: { node: ">=10" } @@ -784,6 +988,9 @@ packages: resolution: { integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== } engines: { node: ^14.18.0 || >=16.10.0 } + convert-source-map@2.0.0: + resolution: { integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== } + convert-to-spaces@2.0.1: resolution: { integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } @@ -823,6 +1030,10 @@ packages: resolution: { integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== } engines: { node: ">=12" } + detect-libc@2.1.2: + resolution: { integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== } + engines: { node: ">=8" } + ecdsa-sig-formatter@1.0.11: resolution: { integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== } @@ -836,6 +1047,9 @@ packages: resolution: { integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== } engines: { node: ">=18" } + es-module-lexer@2.1.0: + resolution: { integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== } + es-toolkit@1.45.1: resolution: { integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== } @@ -899,10 +1113,17 @@ packages: resolution: { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== } engines: { node: ">=4.0" } + estree-walker@3.0.3: + resolution: { integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== } + esutils@2.0.3: resolution: { integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== } engines: { node: ">=0.10.0" } + expect-type@1.3.0: + resolution: { integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== } + engines: { node: ">=12.0.0" } + extend@3.0.2: resolution: { integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== } @@ -1001,6 +1222,9 @@ packages: resolution: { integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== } engines: { node: ">=8" } + html-escaper@2.0.2: + resolution: { integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== } + https-proxy-agent@7.0.6: resolution: { integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== } engines: { node: ">= 14" } @@ -1080,10 +1304,25 @@ packages: isexe@2.0.0: resolution: { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== } + istanbul-lib-coverage@3.2.2: + resolution: { integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== } + engines: { node: ">=8" } + + istanbul-lib-report@3.0.1: + resolution: { integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== } + engines: { node: ">=10" } + + istanbul-reports@3.2.0: + resolution: { integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== } + engines: { node: ">=8" } + joycon@3.1.1: resolution: { integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== } engines: { node: ">=10" } + js-tokens@10.0.0: + resolution: { integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== } + js-yaml@4.1.1: resolution: { integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== } hasBin: true @@ -1121,6 +1360,80 @@ packages: resolution: { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== } engines: { node: ">= 0.8.0" } + lightningcss-android-arm64@1.32.0: + resolution: { integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: { integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: { integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: { integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: { integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== } + engines: { node: ">= 12.0.0" } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: { integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: { integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: { integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: { integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: { integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: { integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: { integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== } + engines: { node: ">= 12.0.0" } + lilconfig@3.1.3: resolution: { integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== } engines: { node: ">=14" } @@ -1149,6 +1462,13 @@ packages: magic-string@0.30.21: resolution: { integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== } + magicast@0.5.3: + resolution: { integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw== } + + make-dir@4.0.0: + resolution: { integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== } + engines: { node: ">=10" } + mimic-fn@2.1.0: resolution: { integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== } engines: { node: ">=6" } @@ -1169,6 +1489,11 @@ packages: mz@2.7.0: resolution: { integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== } + nanoid@3.3.12: + resolution: { integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + natural-compare@1.4.0: resolution: { integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== } @@ -1185,6 +1510,9 @@ packages: resolution: { integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== } engines: { node: ">=0.10.0" } + obug@2.1.1: + resolution: { integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== } + onetime@5.1.2: resolution: { integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== } engines: { node: ">=6" } @@ -1247,6 +1575,10 @@ packages: resolution: { integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== } engines: { node: ">=12" } + picomatch@4.0.4: + resolution: { integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== } + engines: { node: ">=12" } + pirates@4.0.7: resolution: { integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== } engines: { node: ">= 6" } @@ -1272,6 +1604,10 @@ packages: yaml: optional: true + postcss@8.5.15: + resolution: { integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== } + engines: { node: ^10 || ^12 || >=14 } + powershell-utils@0.1.0: resolution: { integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A== } engines: { node: ">=20" } @@ -1392,6 +1728,11 @@ packages: resolution: { integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== } engines: { node: ">= 4" } + rolldown@1.0.1: + resolution: { integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ== } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + rollup@4.59.0: resolution: { integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== } engines: { node: ">=18.0.0", npm: ">=8.0.0" } @@ -1434,6 +1775,9 @@ packages: resolution: { integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== } engines: { node: ">= 0.4" } + siginfo@2.0.0: + resolution: { integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== } + signal-exit@3.0.7: resolution: { integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== } @@ -1444,6 +1788,10 @@ packages: resolution: { integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg== } engines: { node: ">=20" } + source-map-js@1.2.1: + resolution: { integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== } + engines: { node: ">=0.10.0" } + source-map@0.7.6: resolution: { integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== } engines: { node: ">= 12" } @@ -1452,6 +1800,12 @@ packages: resolution: { integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== } engines: { node: ">=10" } + stackback@0.0.2: + resolution: { integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== } + + std-env@4.1.0: + resolution: { integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ== } + string-width@4.2.3: resolution: { integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== } engines: { node: ">=8" } @@ -1500,13 +1854,28 @@ packages: thenify@3.3.1: resolution: { integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== } + tinybench@2.9.0: + resolution: { integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== } + tinyexec@0.3.2: resolution: { integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== } + tinyexec@1.1.2: + resolution: { integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA== } + engines: { node: ">=18" } + tinyglobby@0.2.15: resolution: { integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== } engines: { node: ">=12.0.0" } + tinyglobby@0.2.16: + resolution: { integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== } + engines: { node: ">=12.0.0" } + + tinyrainbow@3.1.0: + resolution: { integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw== } + engines: { node: ">=14.0.0" } + tree-kill@1.2.2: resolution: { integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== } hasBin: true @@ -1523,6 +1892,9 @@ packages: ts-interface-checker@0.1.13: resolution: { integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== } + tslib@2.8.1: + resolution: { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== } + tsup@8.5.1: resolution: { integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing== } engines: { node: ">=18" } @@ -1576,6 +1948,90 @@ packages: uri-js@4.4.1: resolution: { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== } + vite@8.0.13: + resolution: { integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw== } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: { integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ== } + engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.6 + "@vitest/browser-preview": 4.1.6 + "@vitest/browser-webdriverio": 4.1.6 + "@vitest/coverage-istanbul": 4.1.6 + "@vitest/coverage-v8": 4.1.6 + "@vitest/ui": 4.1.6 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-streams-polyfill@3.3.3: resolution: { integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== } engines: { node: ">= 8" } @@ -1585,6 +2041,11 @@ packages: engines: { node: ">= 8" } hasBin: true + why-is-node-running@2.3.0: + resolution: { integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== } + engines: { node: ">=8" } + hasBin: true + widest-line@6.0.0: resolution: { integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA== } engines: { node: ">=20" } @@ -1642,8 +2103,23 @@ snapshots: dependencies: json-schema-to-ts: 3.1.1 + "@babel/helper-string-parser@7.27.1": {} + + "@babel/helper-validator-identifier@7.28.5": {} + + "@babel/parser@7.29.3": + dependencies: + "@babel/types": 7.29.0 + "@babel/runtime@7.29.2": {} + "@babel/types@7.29.0": + dependencies: + "@babel/helper-string-parser": 7.27.1 + "@babel/helper-validator-identifier": 7.28.5 + + "@bcoe/v8-coverage@1.0.2": {} + "@clack/core@1.1.0": dependencies: sisteransi: 1.0.5 @@ -1656,6 +2132,22 @@ snapshots: "@colors/colors@1.5.0": optional: true + "@emnapi/core@1.10.0": + dependencies: + "@emnapi/wasi-threads": 1.2.1 + tslib: 2.8.1 + optional: true + + "@emnapi/runtime@1.10.0": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/wasi-threads@1.2.1": + dependencies: + tslib: 2.8.1 + optional: true + "@esbuild/aix-ppc64@0.27.4": optional: true @@ -1821,6 +2313,15 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 + "@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)": + dependencies: + "@emnapi/core": 1.10.0 + "@emnapi/runtime": 1.10.0 + "@tybys/wasm-util": 0.10.2 + optional: true + + "@oxc-project/types@0.130.0": {} + "@protobufjs/aspromise@1.1.2": {} "@protobufjs/base64@1.1.2": {} @@ -1844,6 +2345,57 @@ snapshots: "@protobufjs/utf8@1.1.0": {} + "@rolldown/binding-android-arm64@1.0.1": + optional: true + + "@rolldown/binding-darwin-arm64@1.0.1": + optional: true + + "@rolldown/binding-darwin-x64@1.0.1": + optional: true + + "@rolldown/binding-freebsd-x64@1.0.1": + optional: true + + "@rolldown/binding-linux-arm-gnueabihf@1.0.1": + optional: true + + "@rolldown/binding-linux-arm64-gnu@1.0.1": + optional: true + + "@rolldown/binding-linux-arm64-musl@1.0.1": + optional: true + + "@rolldown/binding-linux-ppc64-gnu@1.0.1": + optional: true + + "@rolldown/binding-linux-s390x-gnu@1.0.1": + optional: true + + "@rolldown/binding-linux-x64-gnu@1.0.1": + optional: true + + "@rolldown/binding-linux-x64-musl@1.0.1": + optional: true + + "@rolldown/binding-openharmony-arm64@1.0.1": + optional: true + + "@rolldown/binding-wasm32-wasi@1.0.1": + dependencies: + "@emnapi/core": 1.10.0 + "@emnapi/runtime": 1.10.0 + "@napi-rs/wasm-runtime": 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + "@rolldown/binding-win32-arm64-msvc@1.0.1": + optional: true + + "@rolldown/binding-win32-x64-msvc@1.0.1": + optional: true + + "@rolldown/pluginutils@1.0.1": {} + "@rollup/rollup-android-arm-eabi@4.59.0": optional: true @@ -1919,15 +2471,29 @@ snapshots: "@rollup/rollup-win32-x64-msvc@4.59.0": optional: true + "@standard-schema/spec@1.1.0": {} + + "@tybys/wasm-util@0.10.2": + dependencies: + tslib: 2.8.1 + optional: true + "@types/body-parser@1.19.6": dependencies: "@types/connect": 3.4.38 "@types/node": 22.19.15 + "@types/chai@5.2.3": + dependencies: + "@types/deep-eql": 4.0.2 + assertion-error: 2.0.1 + "@types/connect@3.4.38": dependencies: "@types/node": 22.19.15 + "@types/deep-eql@4.0.2": {} + "@types/estree@1.0.8": {} "@types/express-serve-static-core@5.1.1": @@ -2074,6 +2640,61 @@ snapshots: "@typescript-eslint/types": 8.59.0 eslint-visitor-keys: 5.0.1 + "@vitest/coverage-v8@4.1.6(vitest@4.1.6)": + dependencies: + "@bcoe/v8-coverage": 1.0.2 + "@vitest/utils": 4.1.6 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@22.19.15)(@vitest/coverage-v8@4.1.6)(vite@8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0)) + + "@vitest/expect@4.1.6": + dependencies: + "@standard-schema/spec": 1.1.0 + "@types/chai": 5.2.3 + "@vitest/spy": 4.1.6 + "@vitest/utils": 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + "@vitest/mocker@4.1.6(vite@8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0))": + dependencies: + "@vitest/spy": 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0) + + "@vitest/pretty-format@4.1.6": + dependencies: + tinyrainbow: 3.1.0 + + "@vitest/runner@4.1.6": + dependencies: + "@vitest/utils": 4.1.6 + pathe: 2.0.3 + + "@vitest/snapshot@4.1.6": + dependencies: + "@vitest/pretty-format": 4.1.6 + "@vitest/utils": 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + "@vitest/spy@4.1.6": {} + + "@vitest/utils@4.1.6": + dependencies: + "@vitest/pretty-format": 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2107,6 +2728,14 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + "@jridgewell/trace-mapping": 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + auto-bind@5.0.1: {} balanced-match@1.0.2: {} @@ -2145,6 +2774,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2191,6 +2822,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} cross-spawn@7.0.6: @@ -2218,6 +2851,8 @@ snapshots: define-lazy-prop@3.0.0: {} + detect-libc@2.1.2: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -2228,6 +2863,8 @@ snapshots: environment@1.1.0: {} + es-module-lexer@2.1.0: {} + es-toolkit@1.45.1: {} esbuild@0.27.4: @@ -2345,8 +2982,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + "@types/estree": 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -2359,6 +3002,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -2445,6 +3092,8 @@ snapshots: has-flag@4.0.0: {} + html-escaper@2.0.2: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -2528,8 +3177,23 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -2571,6 +3235,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -2591,6 +3304,16 @@ snapshots: dependencies: "@jridgewell/sourcemap-codec": 1.5.5 + magicast@0.5.3: + dependencies: + "@babel/parser": 7.29.3 + "@babel/types": 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + mimic-fn@2.1.0: {} minimatch@10.2.5: @@ -2616,6 +3339,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.12: {} + natural-compare@1.4.0: {} node-domexception@1.0.0: {} @@ -2628,6 +3353,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.1: {} + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -2683,6 +3410,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pirates@4.0.7: {} pkg-types@1.3.1: @@ -2691,12 +3420,19 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 - postcss-load-config@6.0.1(tsx@4.21.0): + postcss-load-config@6.0.1(postcss@8.5.15)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + postcss: 8.5.15 tsx: 4.21.0 + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + powershell-utils@0.1.0: {} prelude-ls@1.2.1: {} @@ -2763,6 +3499,27 @@ snapshots: retry@0.13.1: {} + rolldown@1.0.1: + dependencies: + "@oxc-project/types": 0.130.0 + "@rolldown/pluginutils": 1.0.1 + optionalDependencies: + "@rolldown/binding-android-arm64": 1.0.1 + "@rolldown/binding-darwin-arm64": 1.0.1 + "@rolldown/binding-darwin-x64": 1.0.1 + "@rolldown/binding-freebsd-x64": 1.0.1 + "@rolldown/binding-linux-arm-gnueabihf": 1.0.1 + "@rolldown/binding-linux-arm64-gnu": 1.0.1 + "@rolldown/binding-linux-arm64-musl": 1.0.1 + "@rolldown/binding-linux-ppc64-gnu": 1.0.1 + "@rolldown/binding-linux-s390x-gnu": 1.0.1 + "@rolldown/binding-linux-x64-gnu": 1.0.1 + "@rolldown/binding-linux-x64-musl": 1.0.1 + "@rolldown/binding-openharmony-arm64": 1.0.1 + "@rolldown/binding-wasm32-wasi": 1.0.1 + "@rolldown/binding-win32-arm64-msvc": 1.0.1 + "@rolldown/binding-win32-x64-msvc": 1.0.1 + rollup@4.59.0: dependencies: "@types/estree": 1.0.8 @@ -2820,6 +3577,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} sisteransi@1.0.5: {} @@ -2829,12 +3588,18 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} + source-map@0.7.6: {} stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2888,13 +3653,24 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + tree-kill@1.2.2: {} ts-algebra@2.0.0: {} @@ -2905,7 +3681,10 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(tsx@4.21.0)(typescript@5.9.3): + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -2916,7 +3695,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(tsx@4.21.0) + postcss-load-config: 6.0.1(postcss@8.5.15)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -2925,6 +3704,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.15 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -2968,12 +3748,58 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + "@types/node": 22.19.15 + esbuild: 0.27.4 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.1.6(@types/node@22.19.15)(@vitest/coverage-v8@4.1.6)(vite@8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0)): + dependencies: + "@vitest/expect": 4.1.6 + "@vitest/mocker": 4.1.6(vite@8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0)) + "@vitest/pretty-format": 4.1.6 + "@vitest/runner": 4.1.6 + "@vitest/snapshot": 4.1.6 + "@vitest/spy": 4.1.6 + "@vitest/utils": 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + "@types/node": 22.19.15 + "@vitest/coverage-v8": 4.1.6(vitest@4.1.6) + transitivePeerDependencies: + - msw + web-streams-polyfill@3.3.3: {} which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@6.0.0: dependencies: string-width: 8.2.0 diff --git a/src/cli/commit-errors.test.ts b/src/cli/commit-errors.test.ts new file mode 100644 index 0000000..88f3daf --- /dev/null +++ b/src/cli/commit-errors.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isNonFastForwardError } from "@/cli/commit-errors"; + +describe("isNonFastForwardError", () => { + it.each(["error: failed to push: non-fast-forward", "Updates were rejected because the tip of your current branch is behind"])( + "detects: %s", + (message) => { + expect(isNonFastForwardError(new Error(message))).toBe(true); + } + ); + + it("returns false for unrelated errors", () => { + expect(isNonFastForwardError(new Error("authentication failed"))).toBe(false); + }); +}); diff --git a/src/cli/commit-errors.ts b/src/cli/commit-errors.ts new file mode 100644 index 0000000..86e20f0 --- /dev/null +++ b/src/cli/commit-errors.ts @@ -0,0 +1,6 @@ +export { isNonFastForwardError }; + +const isNonFastForwardError = (error: Error): boolean => { + const msg = error.message.toLowerCase(); + return msg.includes("non-fast-forward") || msg.includes("updates were rejected"); +}; diff --git a/src/cli/commit.test.ts b/src/cli/commit.test.ts new file mode 100644 index 0000000..dcead02 --- /dev/null +++ b/src/cli/commit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } +})); + +import { Commit } from "@/cli/commit"; +import { Future } from "@/libs/future"; +import { Nothing, Just } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; + +type ConfigValue = s.Infer; + +vi.mock("@/infra/storage/config", () => ({ + loadConfig: vi.fn() +})); +vi.mock("@/domain/llm/auth-resolver", () => ({ + resolveProvider: vi.fn((c: ConfigValue) => Future.resolve(c.ai)) +})); +vi.mock("@/infra/git/repo", () => ({ + checkIsGitRepo: vi.fn(() => Future.resolve(undefined)), + getStagedDiff: vi.fn(() => Future.resolve("staged diff")), + performCommit: vi.fn(() => Future.resolve("\n 1 file changed\n")), + findCommitMetadata: vi.fn() +})); +vi.mock("@/domain/llm/router", () => ({ + generateCommitMessage: vi.fn(() => + Future.resolve({ + text: "feat: generated", + metadata: { durationMs: 1, model: { provider: "openai", model: "m", effort: "medium" }, tokens: Nothing() } + }) + ), + refineCommitMessage: vi.fn() +})); +vi.mock("@clack/prompts", () => ({ + note: vi.fn(), + select: vi.fn(async () => "commit"), + text: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + outro: vi.fn(), + log: { warn: vi.fn(), error: vi.fn() } +})); +vi.mock("@/infra/ui/push-note", () => ({ + renderCommitNote: vi.fn(), + renderPushNote: vi.fn() +})); +vi.mock("@/infra/ui/spinner", () => ({ + loading: vi.fn((_a: string, _b: string, f: Future) => f as Future) +})); + +const config = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } +}); + +describe("Commit.run", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config())); + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.findCommitMetadata).mockReturnValue( + Future.resolve(Just({ hash: "h", short: "h", subject: "feat: generated", authorName: "t", authorEmail: "t@t.com", date: new Date() })) + ); + }); + + it("commits when user selects commit", async () => { + await runFuture(Commit.create().chain((c) => c.run())); + const repo = await import("@/infra/git/repo"); + expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); + }); +}); diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 1c37e9d..88042c8 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -15,15 +15,11 @@ import { loading } from "@/infra/ui/spinner"; import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note"; import color from "picocolors"; +import { isNonFastForwardError } from "@/cli/commit-errors"; const USER_ACTIONS = ["commit_push", "commit", "regenerate", "adjust", "cancel"] as const; type UserAction = (typeof USER_ACTIONS)[number]; -const isNonFastForwardError = (error: Error): boolean => { - const msg = error.message.toLowerCase(); - return msg.includes("non-fast-forward") || msg.includes("updates were rejected"); -}; - class Commit { private constructor( private readonly config: Config, diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts new file mode 100644 index 0000000..f533e63 --- /dev/null +++ b/src/cli/doctor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test-client-id", GOOGLE_CLIENT_SECRET: "test-secret" } +})); + +import { Doctor } from "@/cli/doctor"; +import { Future } from "@/libs/future"; +import { Just } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; + +vi.mock("@/infra/storage/config", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: vi.fn(() => Future.reject(new Error("missing"))) }; +}); +vi.mock("@/infra/git/repo", () => ({ + checkIsGitRepo: vi.fn(() => Future.resolve(undefined)), + findCurrentBranch: vi.fn(() => Future.resolve(Just("main"))), + findBaseBranch: vi.fn(() => Future.resolve(Just("main"))), + findTrackingRemoteUrl: vi.fn(() => Future.resolve(Just("https://github.com/o/r.git"))) +})); +vi.mock("@/infra/github/pr", () => ({ + getOpenPullRequest: vi.fn(() => Future.resolve({ type: "not-found" as const })) +})); + +describe("Doctor.run", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("completes when config file is missing", async () => { + await expect(runFuture(Doctor.create().run())).resolves.toBeUndefined(); + }); +}); diff --git a/src/cli/effort.test.ts b/src/cli/effort.test.ts new file mode 100644 index 0000000..5ce20f3 --- /dev/null +++ b/src/cli/effort.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { EffortCommand } from "@/cli/effort"; +import { Future } from "@/libs/future"; +import { Nothing, Just } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; + +type ConfigValue = s.Infer; + +vi.mock("@/infra/storage/config", () => ({ + loadConfig: vi.fn(), + saveConfig: vi.fn(() => Future.resolve()) +})); +vi.mock("@/infra/ui/effort-picker", () => ({ + selectOpenAIEffort: vi.fn(() => Future.resolve(Just("low"))) +})); +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { error: vi.fn() } +})); + +describe("EffortCommand", () => { + beforeEach(async () => { + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue( + Future.resolve({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } + } satisfies ConfigValue) + ); + }); + + it("persists new effort", async () => { + const { saveConfig } = await import("@/infra/storage/config"); + await runFuture(EffortCommand.create().chain((e) => e.run())); + expect(saveConfig).toHaveBeenCalled(); + }); +}); diff --git a/src/cli/model.test.ts b/src/cli/model.test.ts new file mode 100644 index 0000000..f3bdac7 --- /dev/null +++ b/src/cli/model.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { ModelCommand } from "@/cli/model"; +import { Future } from "@/libs/future"; +import { Nothing, Just } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; + +type ConfigValue = s.Infer; + +const config = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { provider: "openai", model: "old", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } +}); + +vi.mock("@/infra/storage/config", () => ({ + loadConfig: vi.fn(), + saveConfig: vi.fn(() => Future.resolve()) +})); +vi.mock("@/domain/llm/auth-resolver", () => ({ + resolveProvider: vi.fn((c: ConfigValue) => Future.resolve(c.ai)) +})); +vi.mock("@/domain/commit/models", () => ({ + fetchModels: vi.fn(() => Future.resolve([{ id: "gpt-4.1-mini", description: "fast" }])) +})); +vi.mock("@/infra/ui/model-picker", () => ({ + selectModelInteractively: vi.fn(() => Future.resolve("gpt-4.1-mini")) +})); +vi.mock("@/infra/ui/effort-picker", () => ({ + selectOpenAIEffort: vi.fn(() => Future.resolve(Just("high"))) +})); +vi.mock("@/infra/ui/spinner", () => ({ + loading: vi.fn((_a: string, _b: string, f: Future) => f as Future) +})); +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { error: vi.fn() } +})); + +describe("ModelCommand", () => { + beforeEach(async () => { + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config())); + }); + + it("updates model in saved config", async () => { + const { saveConfig } = await import("@/infra/storage/config"); + await runFuture(ModelCommand.create().chain((m) => m.run())); + expect(saveConfig).toHaveBeenCalled(); + }); +}); diff --git a/src/cli/parser.test.ts b/src/cli/parser.test.ts new file mode 100644 index 0000000..131dda2 --- /dev/null +++ b/src/cli/parser.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { parseArgs } from "@/cli/parser"; + +describe("parseArgs", () => { + it.each([ + [["generate"], "generate"], + [["setup"], "setup"], + [["login"], "setup"], + [["doctor"], "doctor"], + [["model"], "model"], + [["effort"], "effort"], + [["update"], "update"], + [["-v"], "version"], + [["--version"], "version"], + [["-h"], "help"], + [["--help"], "help"] + ] as const)("maps %j to %s", (argv, type) => { + const result = parseArgs([...argv]); + expect(result.isSuccess()).toBe(true); + if (result.isSuccess()) expect(result.value.type).toBe(type); + }); + + it("rejects unknown commands", () => { + const result = parseArgs(["wat"]); + expect(result.isFailure()).toBe(true); + if (result.isFailure()) expect(result.error.message).toContain("Unknown command"); + }); + + it("defaults bare invocation to generate", () => { + const result = parseArgs([]); + expect(result.isSuccess()).toBe(true); + if (result.isSuccess()) expect(result.value.type).toBe("generate"); + }); +}); diff --git a/src/cli/parser.ts b/src/cli/parser.ts index a2e8e75..6e8590b 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -16,7 +16,7 @@ type CliCommand = | { type: "help" }; const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) => { - const cmd = args[0] || "-h"; + const cmd = args[0] || "generate"; switch (cmd) { case "generate": diff --git a/src/cli/setup.test.ts b/src/cli/setup.test.ts new file mode 100644 index 0000000..f301d4c --- /dev/null +++ b/src/cli/setup.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } +})); +vi.mock("@/infra/auth/google", () => ({ + performOAuthFlow: vi.fn() +})); +vi.mock("@/infra/auth/openai", () => ({ + performOpenAIOAuthFlow: vi.fn(), + validateOpenAITokens: vi.fn() +})); + +import { Setup } from "@/cli/setup"; +import { Future } from "@/libs/future"; +import { Just } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + select: vi.fn().mockResolvedValueOnce("openai").mockResolvedValueOnce("conventional").mockResolvedValueOnce("api_key"), + confirm: vi.fn(async () => true), + text: vi.fn(async () => "sk-test"), + password: vi.fn(async () => "sk-test"), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } +})); +vi.mock("@/domain/commit/models", () => ({ + fetchModels: vi.fn(() => Future.resolve([{ id: "gpt-4.1-mini", description: "fast" }])) +})); +vi.mock("@/infra/ui/model-picker", () => ({ + selectModelInteractively: vi.fn(() => Future.resolve("gpt-4.1-mini")) +})); +vi.mock("@/infra/ui/effort-picker", () => ({ + selectOpenAIEffort: vi.fn(() => Future.resolve(Just("medium" as const))) +})); +vi.mock("@/infra/ui/spinner", () => ({ + loading: vi.fn((_a: string, _b: string, f: Future) => f as Future), + bracketStatus: vi.fn((_a: string, _b: string, f: (s: unknown) => Future) => f({})) +})); +vi.mock("@/infra/storage/config", () => ({ + saveConfig: vi.fn(() => Future.resolve()) +})); +vi.mock("@/infra/auth/anthropic", () => ({ + validateAnthropicApiKey: vi.fn(), + validateAnthropicSetupToken: vi.fn() +})); + +describe("Setup.run", () => { + it("saves config after wizard", async () => { + const { saveConfig } = await import("@/infra/storage/config"); + await runFuture(Setup.create().chain((s) => s.run())); + expect(saveConfig).toHaveBeenCalled(); + }); +}); diff --git a/src/cli/update.test.ts b/src/cli/update.test.ts new file mode 100644 index 0000000..8438f87 --- /dev/null +++ b/src/cli/update.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { detectPackageManager } from "@/cli/update"; +import { Future } from "@/libs/future"; +import { Success } from "@/libs/result"; +import { runFuture } from "../../../test/helpers/run-future"; + +vi.mock("@/infra/shell", () => ({ + execBin: vi.fn() +})); + +describe("detectPackageManager", () => { + it("selects pnpm when bin path contains pnpm", async () => { + const pm = await runFuture(detectPackageManager("/Users/x/.local/share/pnpm/commit")); + expect(pm.name).toBe("pnpm"); + }); + + it("selects npm for generic node path", async () => { + const pm = await runFuture(detectPackageManager("/usr/local/bin/node")); + expect(pm.name).toBe("npm"); + }); + + it("selects yarn 1 when yarn path and version is 1.x", async () => { + const { execBin } = await import("@/infra/shell"); + vi.mocked(execBin).mockReturnValue(Future.resolve(Success({ stdout: "1.22.0\n", stderr: "" }))); + const pm = await runFuture(detectPackageManager("/Users/x/.yarn/bin/commit")); + expect(pm.name).toBe("yarn"); + }); +}); diff --git a/src/cli/update.ts b/src/cli/update.ts index ebcd9d3..63496bc 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -1,4 +1,4 @@ -export { Update, checkUpdate }; +export { Update, checkUpdate, detectPackageManager }; import * as p from "@clack/prompts"; diff --git a/src/domain/commit/prompts.test.ts b/src/domain/commit/prompts.test.ts new file mode 100644 index 0000000..ab3eaa8 --- /dev/null +++ b/src/domain/commit/prompts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { getPrompt, getRefinePrompt } from "@/domain/commit/prompts"; +import { Just, Nothing } from "@/libs/maybe"; + +const DIFF = "diff --git a/foo.ts b/foo.ts\n+console.log(1)"; + +describe("getPrompt", () => { + it("embeds diff in conventional prompt", () => { + const prompt = getPrompt(DIFF, "conventional"); + expect(prompt).toContain(DIFF); + expect(prompt).toContain("Conventional Commits"); + }); + + it("embeds diff in imperative prompt", () => { + const prompt = getPrompt(DIFF, "imperative"); + expect(prompt).toContain(DIFF); + expect(prompt).toContain("Do NOT use conventional commit prefixes"); + }); + + it("substitutes {diff} in custom template", () => { + const prompt = getPrompt(DIFF, "custom", Just("Change:\n{diff}")); + expect(prompt).toContain("Change:"); + expect(prompt).toContain(DIFF); + expect(prompt).not.toContain("{diff}"); + }); + + it("falls back to imperative when custom has no template", () => { + const prompt = getPrompt(DIFF, "custom", Nothing()); + expect(prompt).toContain("imperative"); + }); +}); + +describe("getRefinePrompt", () => { + it("wraps diff, current message, and adjustment", () => { + const { prompt, systemInstruction } = getRefinePrompt({ + diff: DIFF, + currentMessage: "feat: add x", + adjustment: "shorter" + }); + expect(prompt).toContain(""); + expect(prompt).toContain("shorter"); + expect(systemInstruction).toContain("revise commit messages"); + }); +}); diff --git a/src/domain/config/config.test.ts b/src/domain/config/config.test.ts new file mode 100644 index 0000000..51c7c0f --- /dev/null +++ b/src/domain/config/config.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; +import { Just, Nothing } from "@/libs/maybe"; + +type ConfigValue = s.Infer; + +const sampleConfig = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { + provider: "openai", + model: "gpt-4.1-mini", + effort: Nothing(), + auth_method: { type: "api_key", content: "sk-test" } + } +}); + +describe("Config schema", () => { + it("round-trips openai api_key config", () => { + const encoded = s.encode(Config, sampleConfig()); + const decoded = s.decode(Config, encoded); + expect(decoded.isSuccess()).toBe(true); + if (!decoded.isSuccess()) return; + const again = s.decode(Config, s.encode(Config, decoded.value)); + expect(again.isSuccess()).toBe(true); + if (again.isSuccess()) { + expect(again.value.ai.provider).toBe("openai"); + expect(again.value.custom_template).toBeInstanceOf(Nothing); + } + }); + + it("rejects invalid provider", () => { + const bad = { ...sampleConfig(), ai: { provider: "unknown" } }; + expect(s.decode(Config, bad).isFailure()).toBe(true); + }); + + it("accepts custom convention with template", () => { + const json = { + ...s.encode(Config, { ...sampleConfig(), commit_convention: "custom", custom_template: Just("Summarize:\n{diff}") }), + commit_convention: "custom" + }; + const result = s.decode(Config, json); + expect(result.isSuccess()).toBe(true); + if (result.isSuccess()) expect(result.value.custom_template).toBeInstanceOf(Just); + }); +}); diff --git a/src/domain/llm/auth-resolver.test.ts b/src/domain/llm/auth-resolver.test.ts new file mode 100644 index 0000000..8a1b662 --- /dev/null +++ b/src/domain/llm/auth-resolver.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { Future } from "@/libs/future"; +import { resolveProvider, tokensChanged } from "@/domain/llm/auth-resolver"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config as ConfigSchema } from "@/domain/config/config"; + +vi.mock("@/infra/auth/google", () => ({ + ensureFreshTokens: vi.fn((t: { access_token: string }) => Future.resolve({ ...t, access_token: "new-access" })) +})); +vi.mock("@/infra/auth/openai", () => ({ ensureFreshOpenAITokens: vi.fn() })); +vi.mock("@/infra/storage/config", () => ({ + updateGoogleTokens: vi.fn(() => Future.resolve()), + updateOpenAITokens: vi.fn(() => Future.resolve()) +})); + +type ConfigValue = s.Infer; + +const googleConfig = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { + provider: "gemini", + model: "gemini-2.0", + effort: Nothing(), + auth_method: { + type: "google_oauth", + content: { + access_token: "old", + refresh_token: "r", + expiry_date: 1, + token_type: "Bearer", + scope: "openid" + } + } + } +}); + +describe("tokensChanged", () => { + it("detects access_token change", () => { + const orig = googleConfig().ai.auth_method; + if (orig.type !== "google_oauth") throw new Error("fixture"); + const fresh = { ...orig.content, access_token: "new" }; + expect(tokensChanged(orig.content, fresh)).toBeInstanceOf(Just); + }); + + it("returns Nothing when unchanged", () => { + const orig = googleConfig().ai.auth_method; + if (orig.type !== "google_oauth") throw new Error("fixture"); + expect(tokensChanged(orig.content, orig.content)).toBeInstanceOf(Nothing); + }); +}); + +describe("resolveProvider", () => { + beforeEach(() => vi.clearAllMocks()); + + it("passes through api_key without refresh", async () => { + const config: ConfigValue = { + commit_convention: "imperative", + custom_template: Nothing(), + ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk-x" } } + }; + const ai = await runFuture(resolveProvider(config)); + expect(ai.auth_method.type).toBe("api_key"); + }); + + it("persists google tokens when refresh changes access_token", async () => { + const { updateGoogleTokens } = await import("@/infra/storage/config"); + await runFuture(resolveProvider(googleConfig())); + expect(updateGoogleTokens).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index ccce46c..5927dc9 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -1,4 +1,4 @@ -export { resolveProvider }; +export { resolveProvider, tokensChanged }; import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; diff --git a/src/domain/llm/response-parser.test.ts b/src/domain/llm/response-parser.test.ts new file mode 100644 index 0000000..ef35bf8 --- /dev/null +++ b/src/domain/llm/response-parser.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { extractResponse } from "@/domain/llm/response-parser"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; + +describe("extractResponse", () => { + it("trims and returns non-empty text", async () => { + const text = await runFuture(extractResponse({ text: Just(" hello ") })); + expect(text).toBe("hello"); + }); + + it("rejects empty string", async () => { + await expect(runFuture(extractResponse({ text: Just("") }))).rejects.toThrow(/empty or missing/); + }); + + it("rejects missing text", async () => { + await expect(runFuture(extractResponse({ text: Nothing() }))).rejects.toThrow(/empty or missing/); + }); +}); diff --git a/src/domain/llm/router.test.ts b/src/domain/llm/router.test.ts new file mode 100644 index 0000000..6027000 --- /dev/null +++ b/src/domain/llm/router.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router"; +import { Future } from "@/libs/future"; +import { Nothing } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; +import type { ProviderConfig } from "@/domain/config/config"; + +const mockProvider = (provider: ProviderConfig["provider"]): ProviderConfig => ({ + provider, + model: "test-model", + effort: Nothing(), + auth_method: { type: "api_key", content: "sk-test" } +}); + +vi.mock("@/infra/llm/gemini", () => ({ + generateContentWithGemini: vi.fn(() => Future.resolve({ text: "feat: test", tokens: Nothing() })) +})); +vi.mock("@/infra/llm/openai", () => ({ + generateContentWithOpenAI: vi.fn(() => Future.resolve({ text: "feat: test", tokens: Nothing() })) +})); +vi.mock("@/infra/llm/anthropic", () => ({ + generateContentWithAnthropic: vi.fn(() => Future.resolve({ text: "feat: test", tokens: Nothing() })) +})); + +describe("generateCommitMessage", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each(["gemini", "openai", "anthropic"] as const)("routes to %s provider", async (provider) => { + const result = await runFuture(generateCommitMessage(mockProvider(provider), "diff", "conventional", Nothing())); + expect(result.text).toBe("feat: test"); + expect(result.metadata.model.provider).toBe(provider); + expect(result.metadata.durationMs).toBeGreaterThanOrEqual(0); + }); +}); + +describe("refineCommitMessage", () => { + it("calls openai provider for openai config", async () => { + const { generateContentWithOpenAI } = await import("@/infra/llm/openai"); + await runFuture(refineCommitMessage(mockProvider("openai"), "feat: x", "shorter", "diff")); + expect(generateContentWithOpenAI).toHaveBeenCalled(); + }); +}); diff --git a/src/infra/git/parsers.test.ts b/src/infra/git/parsers.test.ts new file mode 100644 index 0000000..ff3aa9f --- /dev/null +++ b/src/infra/git/parsers.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + parsePushRange, + formatCommitOutput, + parseBaseFromReflog, + splitCommitFields, + parseRemoteFromUpstream, + commandFailureMessage +} from "@/infra/git/parsers"; +import { Just, Nothing } from "@/libs/maybe"; +import { Success } from "@/libs/result"; + +describe("parsePushRange", () => { + it("parses before..after from push output", () => { + const range = parsePushRange("To origin\n abcdef0..1234567 main -> main\n"); + expect(range).toBeInstanceOf(Just); + if (range instanceof Just) { + expect(range.value.before).toBe("abcdef0"); + expect(range.value.after).toBe("1234567"); + } + }); + + it("returns Nothing when no range", () => { + expect(parsePushRange("Everything up-to-date")).toBeInstanceOf(Nothing); + }); +}); + +describe("parseBaseFromReflog", () => { + it("extracts branch from creation reflog line", () => { + const stdout = "branch: Fast-forward\nbranch: Created from origin/main\n"; + expect(parseBaseFromReflog(stdout)).toEqual(Success("origin/main")); + }); + + it("fails when reflog has no creation line", () => { + const result = parseBaseFromReflog("branch: Fast-forward\n"); + expect(result.isFailure()).toBe(true); + }); +}); + +describe("splitCommitFields", () => { + it("maps NUL-separated git log fields", () => { + const line = ["abc", "abc", "subject", "me", "me@x.com", "2024-01-01T00:00:00+00:00"].join("\0"); + const result = splitCommitFields(`${line}\n`); + expect(result.isSuccess()).toBe(true); + if (result.isSuccess()) expect(result.value["subject"]).toBe("subject"); + }); +}); + +describe("formatCommitOutput", () => { + it("strips bracketed progress lines", () => { + const out = formatCommitOutput("[main abc1234] feat: x\n 1 file changed\n"); + expect(out).toContain("1 file changed"); + expect(out).not.toContain("[main"); + }); +}); + +describe("parseRemoteFromUpstream", () => { + it("parses origin from origin/main", () => { + expect(parseRemoteFromUpstream("origin/main")).toEqual(Just("origin")); + }); +}); + +describe("commandFailureMessage", () => { + it("prefers stderr over stdout", () => { + const msg = commandFailureMessage({ output: { stderr: "fatal: no repo", stdout: "" }, error: new Error("exit 128") }, "fallback"); + expect(msg).toBe("fatal: no repo"); + }); +}); diff --git a/src/infra/git/parsers.ts b/src/infra/git/parsers.ts new file mode 100644 index 0000000..2276a70 --- /dev/null +++ b/src/infra/git/parsers.ts @@ -0,0 +1,72 @@ +export { + parsePushRange, + formatCommitOutput, + oldestReflogSubject, + parseCreatedFrom, + normalizeBranchRef, + parseBaseFromReflog, + parseRemoteFromUpstream, + splitCommitFields, + commandFailureMessage, + CREATED_FROM_RE, + COMMIT_KEYS, + type BaseLookupError +}; + +import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { type Result, Success, Failure } from "@/libs/result"; +import { type CommandFailure } from "@/infra/shell"; + +type PushRange = { before: string; after: string }; + +type BaseLookupError = { type: "reflog-empty" } | { type: "reflog-not-creation"; subject: string } | { type: "reflog-cmd-failed"; message: string }; + +const CREATED_FROM_RE = /^branch: Created from (\S+)$/; + +const COMMIT_KEYS = ["hash", "short", "subject", "authorName", "authorEmail", "date"] as const; + +const commandFailureMessage = (failure: CommandFailure, fallbackMsg: string): string => + failure.output.stderr.trim() || failure.output.stdout.trim() || `${failure.error.message}: ${fallbackMsg}`; + +const formatCommitOutput = (stdout: string): string => + "\n" + + stdout + .split("\n") + .filter((line) => !line.startsWith("[")) + .join("\n") + .trim() + + "\n"; + +const parsePushRange = (output: string): Maybe => { + const m = output.match(/([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})/); + if (!m) return Nothing(); + const [, before, after] = m; + return before && after ? Just({ before, after }) : Nothing(); +}; + +const oldestReflogSubject = (stdout: string): Result => { + const oldest = stdout.split("\n").filter(Boolean).at(-1); + return oldest ? Success(oldest) : Failure({ type: "reflog-empty" }); +}; + +const parseCreatedFrom = (subject: string): Result => { + const source = subject.match(CREATED_FROM_RE)?.[1]; + return source && source !== "HEAD" ? Success(source) : Failure({ type: "reflog-not-creation", subject }); +}; + +const normalizeBranchRef = (ref: string): string => ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""); + +const parseBaseFromReflog = (stdout: string): Result => + oldestReflogSubject(stdout).chain(parseCreatedFrom).map(normalizeBranchRef); + +const parseRemoteFromUpstream = (upstream: string): Maybe => { + const idx = upstream.indexOf("/"); + return idx > 0 ? Just(upstream.slice(0, idx)) : Nothing(); +}; + +const splitCommitFields = (stdout: string): Result> => { + const parts = stdout.replace(/\n$/, "").split("\x00"); + return parts.length === COMMIT_KEYS.length ? + Success(Object.fromEntries(COMMIT_KEYS.map((k, i) => [k, parts[i]]))) + : Failure(`expected ${COMMIT_KEYS.length} fields, got ${parts.length}`); +}; diff --git a/src/infra/git/repo.integration.test.ts b/src/infra/git/repo.integration.test.ts new file mode 100644 index 0000000..1ee858b --- /dev/null +++ b/src/infra/git/repo.integration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { chdir, cwd } from "node:process"; +import { runFuture } from "../../../test/helpers/run-future"; +import { createTempGitRepo } from "../../../test/helpers/temp-git-repo"; +import * as repo from "@/infra/git/repo"; + +describe("git repo integration", () => { + it("getStagedDiff rejects when nothing staged", async () => { + const { dir } = createTempGitRepo({ staged: false }); + const prev = cwd(); + chdir(dir); + try { + await expect(runFuture(repo.getStagedDiff())).rejects.toThrow(/No staged changes/); + } finally { + chdir(prev); + } + }); + + it("getStagedDiff returns staged patch", async () => { + const { dir } = createTempGitRepo({ staged: true }); + const prev = cwd(); + chdir(dir); + try { + const diff = await runFuture(repo.getStagedDiff()); + expect(diff).toContain("file.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit creates commit with message", async () => { + const { dir } = createTempGitRepo({ staged: true }); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: integration test")); + const meta = await runFuture(repo.getCommitMetadata()); + expect(meta.subject).toBe("feat: integration test"); + } finally { + chdir(prev); + } + }); +}); diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 6be4a94..03a5cf5 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -21,13 +21,22 @@ export { import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -import { type Result, Success, Failure } from "@/libs/result"; +import { type Result, Failure } from "@/libs/result"; import { absurd } from "@/libs/types"; -import { execBin, type CommandFailure } from "@/infra/shell"; +import { type BaseLookupError } from "@/infra/git/parsers"; +import { execBin } from "@/infra/shell"; import * as Decoder from "@/libs/json/decoder"; import { unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + parsePushRange, + formatCommitOutput, + parseBaseFromReflog, + parseRemoteFromUpstream, + splitCommitFields, + commandFailureMessage +} from "@/infra/git/parsers"; type CommitMetadata = { hash: string; @@ -45,13 +54,6 @@ type PushResult = { range: Maybe; }; -type BaseLookupError = { type: "reflog-empty" } | { type: "reflog-not-creation"; subject: string } | { type: "reflog-cmd-failed"; message: string }; - -const CREATED_FROM_RE = /^branch: Created from (\S+)$/; - -const commandFailureMessage = (failure: CommandFailure, fallbackMsg: string): string => - failure.output.stderr.trim() || failure.output.stdout.trim() || `${failure.error.message}: ${fallbackMsg}`; - const execGitChecked = (args: string[], fallbackMsg: string): Future => execBin("git", args).chain((result) => result.either( @@ -60,22 +62,6 @@ const execGitChecked = (args: string[], fallbackMsg: string): Future - "\n" + - stdout - .split("\n") - .filter((line) => !line.startsWith("[")) - .join("\n") - .trim() + - "\n"; - -const parsePushRange = (output: string): Maybe => { - const m = output.match(/([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})/); - if (!m) return Nothing(); - const [, before, after] = m; - return before && after ? Just({ before, after }) : Nothing(); -}; - const checkIsGitRepo = (): Future => execGitChecked(["rev-parse", "--is-inside-work-tree"], "Not a git repository").map(() => {}); const getStagedDiff = (): Future => @@ -136,21 +122,6 @@ const getUpstream = (): Future> => ) ); -const oldestReflogSubject = (stdout: string): Result => { - const oldest = stdout.split("\n").filter(Boolean).at(-1); - return oldest ? Success(oldest) : Failure({ type: "reflog-empty" }); -}; - -const parseCreatedFrom = (subject: string): Result => { - const source = subject.match(CREATED_FROM_RE)?.[1]; - return source && source !== "HEAD" ? Success(source) : Failure({ type: "reflog-not-creation", subject }); -}; - -const normalizeBranchRef = (ref: string): string => ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""); - -const parseBaseFromReflog = (stdout: string): Result => - oldestReflogSubject(stdout).chain(parseCreatedFrom).map(normalizeBranchRef); - const getBaseFromReflog = (branch: string): Future> => execBin("git", ["log", "-g", "--format=%gs", branch]).map((result) => result.either( @@ -196,11 +167,6 @@ const findBaseBranch = (): Future> => getBaseBranch().chain const getRemoteUrl = (remote: string = "origin"): Future => execGitChecked(["remote", "get-url", remote], `Failed to read remote '${remote}' url`).map((s) => s.trim()); -const parseRemoteFromUpstream = (upstream: string): Maybe => { - const idx = upstream.indexOf("/"); - return idx > 0 ? Just(upstream.slice(0, idx)) : Nothing(); -}; - const getTrackingRemoteUrl = (): Future => getUpstream().chain((maybeRef) => { const remote = maybeRef instanceof Just ? parseRemoteFromUpstream(maybeRef.value) : Nothing(); @@ -240,14 +206,6 @@ const COMMIT_FIELDS = [ ] as const; const COMMIT_FORMAT = COMMIT_FIELDS.map(([, p]) => p).join("%x00"); -const COMMIT_KEYS = COMMIT_FIELDS.map(([n]) => n); - -const splitCommitFields = (stdout: string): Result> => { - const parts = stdout.replace(/\n$/, "").split("\x00"); - return parts.length === COMMIT_KEYS.length ? - Success(Object.fromEntries(COMMIT_KEYS.map((k, i) => [k, parts[i]]))) - : Failure(`expected ${COMMIT_KEYS.length} fields, got ${parts.length}`); -}; const getCommitMetadata = (ref: string = "HEAD"): Future => execGitChecked(["log", "-1", `--format=${COMMIT_FORMAT}`, ref], "Failed to read commit metadata").chain((stdout) => diff --git a/src/infra/github/pr.test.ts b/src/infra/github/pr.test.ts new file mode 100644 index 0000000..9efedb7 --- /dev/null +++ b/src/infra/github/pr.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { getOpenPullRequest } from "@/infra/github/pr"; +import { Future } from "@/libs/future"; +import { Success, Failure } from "@/libs/result"; +import { runFuture } from "../../../test/helpers/run-future"; + +vi.mock("@/infra/git/repo", () => ({ + getTrackingRemoteUrl: vi.fn(() => Future.resolve("https://github.com/o/r.git")), + getCurrentBranch: vi.fn(() => Future.resolve("feature/x")) +})); + +vi.mock("@/infra/shell", () => ({ + execBin: vi.fn() +})); + +describe("getOpenPullRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns found when gh outputs valid JSON", async () => { + const { execBin } = await import("@/infra/shell"); + vi.mocked(execBin).mockReturnValue( + Future.resolve( + Success({ + stdout: JSON.stringify({ url: "https://github.com/o/r/pull/1", number: 1 }), + stderr: "" + }) + ) + ); + + const lookup = await runFuture(getOpenPullRequest()); + expect(lookup).toEqual({ type: "found", pr: { url: "https://github.com/o/r/pull/1", number: 1 } }); + }); + + it("returns not-found when gh stderr says no PR", async () => { + const { execBin } = await import("@/infra/shell"); + vi.mocked(execBin).mockReturnValue( + Future.resolve( + Failure({ + output: { stderr: "no pull requests found for branch", stdout: "" }, + error: new Error("exit 1") + }) + ) + ); + + const lookup = await runFuture(getOpenPullRequest()); + expect(lookup.type).toBe("not-found"); + }); + + it("returns unavailable for non-github remote", async () => { + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.getTrackingRemoteUrl).mockReturnValue(Future.resolve("git@gitlab.com:o/r.git")); + + const lookup = await runFuture(getOpenPullRequest()); + expect(lookup.type).toBe("unavailable"); + }); +}); diff --git a/src/infra/storage/config.test.ts b/src/infra/storage/config.test.ts new file mode 100644 index 0000000..82ab777 --- /dev/null +++ b/src/infra/storage/config.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { readFile } from "node:fs/promises"; +import { loadConfig, saveConfig, CONFIG_FILE } from "@/infra/storage/config"; +import { Nothing } from "@/libs/maybe"; +import { runFuture } from "../../../test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; + +type ConfigValue = s.Infer; + +const sampleConfig = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { + provider: "openai", + model: "gpt-4.1-mini", + effort: Nothing(), + auth_method: { type: "api_key", content: "sk-test" } + } +}); + +describe("config storage", () => { + beforeEach(async () => { + await runFuture(saveConfig(sampleConfig())); + }); + + it("writes and reads config.json", async () => { + const loaded = await runFuture(loadConfig()); + expect(loaded.ai.provider).toBe("openai"); + const raw = await readFile(CONFIG_FILE, "utf-8"); + expect(JSON.parse(raw).ai.provider).toBe("openai"); + }); + + it("rejects invalid config on load", async () => { + const { writeFile, mkdir } = await import("node:fs/promises"); + const { dirname } = await import("node:path"); + await mkdir(dirname(CONFIG_FILE), { recursive: true }); + await writeFile(CONFIG_FILE, "{ invalid", "utf-8"); + await expect(runFuture(loadConfig())).rejects.toThrow(); + }); +}); diff --git a/src/infra/storage/config.ts b/src/infra/storage/config.ts index de3bee3..f489b94 100644 --- a/src/infra/storage/config.ts +++ b/src/infra/storage/config.ts @@ -9,7 +9,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { Config, resolveAuthMethod, type OAuthTokens, type OpenAITokens } from "@/domain/config/config"; import { absurd } from "@/libs/types"; -const CONFIG_DIR = resolve(homedir(), ".commit-tools"); +const CONFIG_DIR = process.env["COMMIT_TOOLS_HOME"] ? resolve(process.env["COMMIT_TOOLS_HOME"]) : resolve(homedir(), ".commit-tools"); const CONFIG_FILE = resolve(CONFIG_DIR, "config.json"); const loadConfig = (): Future => diff --git a/src/infra/version-check.test.ts b/src/infra/version-check.test.ts new file mode 100644 index 0000000..2e87203 --- /dev/null +++ b/src/infra/version-check.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { compareVersions } from "@/infra/version-check"; + +describe("compareVersions", () => { + it.each([ + ["1.0.0", "1.0.0", 0], + ["1.2.0", "1.1.9", 1], + ["0.2.9", "0.3.0", -1], + ["2.0.0-beta.1", "2.0.0", 0], + ["10.0.0", "9.9.9", 1] + ] as const)("%s vs %s => %i", (a, b, expected) => { + expect(compareVersions(a, b)).toBe(expected); + }); +}); diff --git a/src/libs/fuzzy.test.ts b/src/libs/fuzzy.test.ts new file mode 100644 index 0000000..ac99378 --- /dev/null +++ b/src/libs/fuzzy.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { match, search } from "@/libs/fuzzy"; +import { Just } from "@/libs/maybe"; + +describe("match", () => { + it("matches subsequence with positive score", () => { + const m = match("gcm", "feat: add generateCommitMessage"); + expect(m).toBeInstanceOf(Just); + if (m instanceof Just) expect(m.value.score).toBeGreaterThan(0); + }); + + it("returns no match when chars are missing", () => { + expect(match("zzz", "hello").isNothing()).toBe(true); + }); + + it("is case-insensitive when query has no uppercase", () => { + expect(match("abc", "A-B-C").isJust()).toBe(true); + }); +}); + +describe("search", () => { + const items = [ + { id: "gpt-4.1", label: "GPT 4.1" }, + { id: "claude-sonnet", label: "Claude Sonnet" } + ]; + + it("returns all items with zero score for empty query", () => { + const results = search("", items, [(i) => i.label]); + expect(results).toHaveLength(2); + expect(results.every((r) => r.match.score === 0)).toBe(true); + }); + + it("ranks better matches first", () => { + const results = search("claude", items, [(i) => i.id, (i) => i.label]); + expect(results[0]?.item.id).toBe("claude-sonnet"); + }); +}); diff --git a/test/cli-smoke.test.ts b/test/cli-smoke.test.ts new file mode 100644 index 0000000..d6f9973 --- /dev/null +++ b/test/cli-smoke.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const BIN = resolve(import.meta.dirname, "../dist/index.js"); + +const runCli = (args: string[]) => + spawnSync(process.execPath, [BIN, ...args], { + encoding: "utf-8", + env: { + ...process.env, + COMMIT_TOOLS_HOME: process.env["COMMIT_TOOLS_HOME"], + NO_UPDATE_NOTIFIER: "true", + GOOGLE_CLIENT_ID: "test-client-id", + GOOGLE_CLIENT_SECRET: "test-client-secret" + } + }); + +const distExists = () => existsSync(BIN); + +describe("CLI smoke (built dist)", () => { + it.skipIf(!distExists())("-v prints version", () => { + const { status, stdout } = runCli(["-v"]); + expect(status).toBe(0); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it.skipIf(!distExists())("-h prints usage", () => { + const { status, stdout } = runCli(["-h"]); + expect(status).toBe(0); + expect(stdout).toContain("generate"); + }); + + it.skipIf(!distExists() || !process.env["CI"])("doctor exits 0", () => { + const { status } = runCli(["doctor"]); + expect(status).toBe(0); + }); + + it.skipIf(!distExists())("unknown command exits 1", () => { + const { status, stderr } = runCli(["nope"]); + expect(status).toBe(1); + expect(stderr).toContain("Unknown command"); + }); +}); diff --git a/test/helpers/run-future.ts b/test/helpers/run-future.ts new file mode 100644 index 0000000..58f46f9 --- /dev/null +++ b/test/helpers/run-future.ts @@ -0,0 +1,3 @@ +import { type Future } from "@/libs/future"; + +export const runFuture = (future: Future): Promise => future.promise((e) => (e instanceof Error ? e : new Error(String(e)))); diff --git a/test/helpers/temp-git-repo.ts b/test/helpers/temp-git-repo.ts new file mode 100644 index 0000000..3a84e49 --- /dev/null +++ b/test/helpers/temp-git-repo.ts @@ -0,0 +1,22 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +export type TempGitRepo = { dir: string; run: (args: string) => string }; + +export const createTempGitRepo = (opts?: { staged?: boolean }): TempGitRepo => { + const dir = mkdtempSync(join(tmpdir(), "commit-tools-git-")); + const run = (args: string) => execSync(`git ${args}`, { cwd: dir, encoding: "utf-8" }); + run("init -b main"); + run('config user.email "test@example.com"'); + run('config user.name "Test"'); + writeFileSync(join(dir, "file.txt"), "hello\n"); + run("add file.txt"); + run('commit -m "initial"'); + if (opts?.staged) { + writeFileSync(join(dir, "file.txt"), "hello world\n"); + run("add file.txt"); + } + return { dir, run }; +}; diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..7a223f9 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,11 @@ +import { beforeEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +beforeEach(() => { + process.env["COMMIT_TOOLS_HOME"] = mkdtempSync(join(tmpdir(), "commit-tools-test-")); + process.env["NO_UPDATE_NOTIFIER"] = "true"; + process.env["GOOGLE_CLIENT_ID"] = "test-client-id"; + process.env["GOOGLE_CLIENT_SECRET"] = "test-client-secret"; +}); diff --git a/tsconfig.json b/tsconfig.json index 6573bd2..ebfb76e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,5 +29,5 @@ } }, "include": ["index.ts", "src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "test"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4f75cf4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "node:path"; + +const root = import.meta.dirname; + +export default defineConfig({ + resolve: { + alias: [ + { find: "@/package.json", replacement: resolve(root, "package.json") }, + { find: /^@\/(.+)$/, replacement: resolve(root, "src/$1") } + ] + }, + test: { + environment: "node", + include: ["src/**/*.test.ts", "test/**/*.test.ts"], + setupFiles: ["./test/setup.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts", "index.ts"], + exclude: ["**/*.test.ts", "dist/**"] + } + } +}); From 66f858abd75e6d1cb4c4df2274e72894f9dece39 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 22 May 2026 16:32:55 -0300 Subject: [PATCH 2/3] Update PR validation workflow and config parse errors - Add PR validation concurrency and shared CI environment settings for build and test steps. - Run the built CLI smoke doctor test whenever `dist/` exists. - Return a clear config load error when `config.json` contains invalid JSON. - Document pull request checks and local validation commands in the README. --- .github/workflows/pr-validate.yml | 24 +++++++++++++----------- README.md | 23 +++++++++++++++++++++++ src/infra/storage/config.test.ts | 6 ++++-- src/infra/storage/config.ts | 27 +++++++++++++++++++++------ test/cli-smoke.test.ts | 2 +- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml index 22540bf..811bc0b 100644 --- a/.github/workflows/pr-validate.yml +++ b/.github/workflows/pr-validate.yml @@ -4,12 +4,21 @@ on: pull_request: types: [opened, synchronize, reopened] +concurrency: + group: pr-validate-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: checks: - name: Typecheck, format, and build + name: Validate (typecheck, format, build, test) runs-on: ubuntu-latest permissions: contents: read + env: + GOOGLE_CLIENT_ID: test-client-id + GOOGLE_CLIENT_SECRET: test-client-secret + NO_UPDATE_NOTIFIER: "true" + CI: "true" steps: - name: Checkout repository uses: actions/checkout@v6 @@ -28,22 +37,15 @@ jobs: - name: Typecheck run: pnpm run typecheck - - name: Unit tests - env: - GOOGLE_CLIENT_ID: test-client-id - GOOGLE_CLIENT_SECRET: test-client-secret - NO_UPDATE_NOTIFIER: "true" - run: pnpm test - - name: Check formatting run: pnpm exec prettier . --check - name: Build project - env: - GOOGLE_CLIENT_ID: pr-validation-not-a-real-client-id - GOOGLE_CLIENT_SECRET: pr-validation-not-a-real-secret run: pnpm run build + - name: Test + run: pnpm test + lint: name: Cognitive complexity runs-on: ubuntu-latest diff --git a/README.md b/README.md index 409f821..7f60f4d 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,26 @@ Contributions and feedback are welcome! ## Contributing We welcome contributions! Feel free to report bugs, suggest features, or submit pull requests. + +### Pull request checks + +Every pull request runs the **PR Validate** workflow (`.github/workflows/pr-validate.yml`): + +- **Validate (typecheck, format, build, test)** — typecheck, Prettier, production build, then the full Vitest suite (unit, integration, and CLI smoke tests against `dist/`) +- **Cognitive complexity** — ESLint SonarJS rules +- **Publish preview** — semver / npm publish preview (informational) + +Run locally before opening a PR: + +```bash +pnpm typecheck +pnpm exec prettier . --check +pnpm build # requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment +pnpm test +pnpm lint +``` + +To block merges when checks fail, enable branch protection on `main` and require these status checks: + +- `Validate (typecheck, format, build, test)` +- `Cognitive complexity` diff --git a/src/infra/storage/config.test.ts b/src/infra/storage/config.test.ts index 82ab777..d01f701 100644 --- a/src/infra/storage/config.test.ts +++ b/src/infra/storage/config.test.ts @@ -31,11 +31,13 @@ describe("config storage", () => { expect(JSON.parse(raw).ai.provider).toBe("openai"); }); - it("rejects invalid config on load", async () => { + it("rejects invalid JSON on load with a clear error", async () => { const { writeFile, mkdir } = await import("node:fs/promises"); const { dirname } = await import("node:path"); await mkdir(dirname(CONFIG_FILE), { recursive: true }); await writeFile(CONFIG_FILE, "{ invalid", "utf-8"); - await expect(runFuture(loadConfig())).rejects.toThrow(); + await expect(runFuture(loadConfig())).rejects.toThrow(/not valid JSON/); + await expect(runFuture(loadConfig())).rejects.toThrow(CONFIG_FILE); + await expect(runFuture(loadConfig())).rejects.toThrow(/commit setup/); }); }); diff --git a/src/infra/storage/config.ts b/src/infra/storage/config.ts index f489b94..13835af 100644 --- a/src/infra/storage/config.ts +++ b/src/infra/storage/config.ts @@ -3,6 +3,7 @@ export { loadConfig, saveConfig, updateGoogleTokens, updateOpenAITokens, CONFIG_ import * as s from "@/libs/json/schema"; import { Future } from "@/libs/future"; +import { Success, Failure, type Result } from "@/libs/result"; import { resolve } from "node:path"; import { homedir } from "node:os"; import { readFile, writeFile, mkdir } from "node:fs/promises"; @@ -12,15 +13,29 @@ import { absurd } from "@/libs/types"; const CONFIG_DIR = process.env["COMMIT_TOOLS_HOME"] ? resolve(process.env["COMMIT_TOOLS_HOME"]) : resolve(homedir(), ".commit-tools"); const CONFIG_FILE = resolve(CONFIG_DIR, "config.json"); +const parseConfigJson = (raw: string, path: string): Result => { + try { + return Success(JSON.parse(raw)); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + return Failure(new Error(`Config file is not valid JSON (${path}): ${detail}. Fix the file or run 'commit setup' to recreate it.`)); + } +}; + const loadConfig = (): Future => Future.attemptP(() => readFile(CONFIG_FILE, "utf-8")) .mapRej((err) => new Error(`Failed to read config file: ${err}`)) - .map((value) => JSON.parse(value)) - .chain((json) => { - const result = s.decode(Config, json); - return result.either( - (err) => Future.reject(new Error(`Invalid config: ${err}`)), - (ok) => Future.resolve(ok) + .chain((raw) => { + const parsed = parseConfigJson(raw, CONFIG_FILE); + return parsed.either( + (err) => Future.reject(err), + (json) => { + const result = s.decode(Config, json); + return result.either( + (err) => Future.reject(new Error(`Invalid config: ${err}`)), + (ok) => Future.resolve(ok) + ); + } ); }); diff --git a/test/cli-smoke.test.ts b/test/cli-smoke.test.ts index d6f9973..95c5b27 100644 --- a/test/cli-smoke.test.ts +++ b/test/cli-smoke.test.ts @@ -32,7 +32,7 @@ describe("CLI smoke (built dist)", () => { expect(stdout).toContain("generate"); }); - it.skipIf(!distExists() || !process.env["CI"])("doctor exits 0", () => { + it.skipIf(!distExists())("doctor exits 0", () => { const { status } = runCli(["doctor"]); expect(status).toBe(0); }); From d7a2b243c650592c83a58ee694fcc96d96e8cabe Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 22 May 2026 17:01:47 -0300 Subject: [PATCH 3/3] Read config and cache paths lazily to protect real user config CONFIG_DIR/CONFIG_FILE were computed at module import, before Vitest's beforeEach set COMMIT_TOOLS_HOME, so `pnpm test` wrote to the real ~/.commit-tools/config.json. Convert them to configDir()/configFile() functions read at call time, update version-check.ts and doctor.ts callers, and move static test env to top-level setup so import-time reads in env.ts see the test values. --- src/cli/doctor.ts | 6 +++--- src/infra/storage/config.test.ts | 10 +++++----- src/infra/storage/config.ts | 14 +++++++------- src/infra/version-check.ts | 10 +++++----- test/setup.ts | 9 ++++++--- 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index befb58e..e6f3db2 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -4,7 +4,7 @@ import * as pr from "@/infra/github/pr"; import * as repo from "@/infra/git/repo"; import { Future } from "@/libs/future"; -import { CONFIG_FILE, loadConfig } from "@/infra/storage/config"; +import { configFile, loadConfig } from "@/infra/storage/config"; import { type AuthMethod, type ProviderConfig } from "@/domain/config/config"; import { Just, type Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; @@ -57,14 +57,14 @@ class Doctor { private checkConfig(): Future { return Future.attemptP(() => - access(CONFIG_FILE) + access(configFile()) .then(() => true) .catch(() => false) ).chain((configExists) => { const row: CheckRow = [ "Configuration", configExists ? color.green("Found") : color.yellow("Missing"), - configExists ? CONFIG_FILE : "Run 'commit-tools setup' to create" + configExists ? configFile() : "Run 'commit-tools setup' to create" ]; if (!configExists) { diff --git a/src/infra/storage/config.test.ts b/src/infra/storage/config.test.ts index d01f701..43da65e 100644 --- a/src/infra/storage/config.test.ts +++ b/src/infra/storage/config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach } from "vitest"; import { readFile } from "node:fs/promises"; -import { loadConfig, saveConfig, CONFIG_FILE } from "@/infra/storage/config"; +import { loadConfig, saveConfig, configFile } from "@/infra/storage/config"; import { Nothing } from "@/libs/maybe"; import { runFuture } from "../../../test/helpers/run-future"; import * as s from "@/libs/json/schema"; @@ -27,17 +27,17 @@ describe("config storage", () => { it("writes and reads config.json", async () => { const loaded = await runFuture(loadConfig()); expect(loaded.ai.provider).toBe("openai"); - const raw = await readFile(CONFIG_FILE, "utf-8"); + const raw = await readFile(configFile(), "utf-8"); expect(JSON.parse(raw).ai.provider).toBe("openai"); }); it("rejects invalid JSON on load with a clear error", async () => { const { writeFile, mkdir } = await import("node:fs/promises"); const { dirname } = await import("node:path"); - await mkdir(dirname(CONFIG_FILE), { recursive: true }); - await writeFile(CONFIG_FILE, "{ invalid", "utf-8"); + await mkdir(dirname(configFile()), { recursive: true }); + await writeFile(configFile(), "{ invalid", "utf-8"); await expect(runFuture(loadConfig())).rejects.toThrow(/not valid JSON/); - await expect(runFuture(loadConfig())).rejects.toThrow(CONFIG_FILE); + await expect(runFuture(loadConfig())).rejects.toThrow(configFile()); await expect(runFuture(loadConfig())).rejects.toThrow(/commit setup/); }); }); diff --git a/src/infra/storage/config.ts b/src/infra/storage/config.ts index 13835af..3400c64 100644 --- a/src/infra/storage/config.ts +++ b/src/infra/storage/config.ts @@ -1,4 +1,4 @@ -export { loadConfig, saveConfig, updateGoogleTokens, updateOpenAITokens, CONFIG_DIR, CONFIG_FILE }; +export { loadConfig, saveConfig, updateGoogleTokens, updateOpenAITokens, configDir, configFile }; import * as s from "@/libs/json/schema"; @@ -10,8 +10,8 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { Config, resolveAuthMethod, type OAuthTokens, type OpenAITokens } from "@/domain/config/config"; import { absurd } from "@/libs/types"; -const CONFIG_DIR = process.env["COMMIT_TOOLS_HOME"] ? resolve(process.env["COMMIT_TOOLS_HOME"]) : resolve(homedir(), ".commit-tools"); -const CONFIG_FILE = resolve(CONFIG_DIR, "config.json"); +const configDir = (): string => (process.env["COMMIT_TOOLS_HOME"] ? resolve(process.env["COMMIT_TOOLS_HOME"]) : resolve(homedir(), ".commit-tools")); +const configFile = (): string => resolve(configDir(), "config.json"); const parseConfigJson = (raw: string, path: string): Result => { try { @@ -23,10 +23,10 @@ const parseConfigJson = (raw: string, path: string): Result => { }; const loadConfig = (): Future => - Future.attemptP(() => readFile(CONFIG_FILE, "utf-8")) + Future.attemptP(() => readFile(configFile(), "utf-8")) .mapRej((err) => new Error(`Failed to read config file: ${err}`)) .chain((raw) => { - const parsed = parseConfigJson(raw, CONFIG_FILE); + const parsed = parseConfigJson(raw, configFile()); return parsed.either( (err) => Future.reject(err), (json) => { @@ -41,8 +41,8 @@ const loadConfig = (): Future => const saveConfig = (config: Config): Future => Future.attemptP(async () => { - await mkdir(CONFIG_DIR, { recursive: true }); - await writeFile(CONFIG_FILE, JSON.stringify(s.encode(Config, config), null, 2), "utf-8"); + await mkdir(configDir(), { recursive: true }); + await writeFile(configFile(), JSON.stringify(s.encode(Config, config), null, 2), "utf-8"); }); const updateGoogleTokens = (tokens: OAuthTokens): Future => diff --git a/src/infra/version-check.ts b/src/infra/version-check.ts index 6c1834f..5c6d14a 100644 --- a/src/infra/version-check.ts +++ b/src/infra/version-check.ts @@ -3,10 +3,10 @@ export { checkForUpdate, compareVersions }; import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { CONFIG_DIR } from "@/infra/storage/config"; +import { configDir } from "@/infra/storage/config"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -const CACHE_FILE = resolve(CONFIG_DIR, "version-check.json"); +const cacheFile = (): string => resolve(configDir(), "version-check.json"); const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const REGISTRY_URL = "https://registry.npmjs.org/@rafaeelricco/commit-tools/latest"; @@ -14,7 +14,7 @@ type CachedCheck = { checkedAt: number; latestVersion: string }; const loadCache = (): Maybe => { try { - const raw = readFileSync(CACHE_FILE, "utf-8"); + const raw = readFileSync(cacheFile(), "utf-8"); const parsed = JSON.parse(raw); if (typeof parsed?.checkedAt === "number" && typeof parsed?.latestVersion === "string") { return Just({ checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion }); @@ -43,8 +43,8 @@ const refreshCacheInBackground = (): void => { `fetch(${JSON.stringify(REGISTRY_URL)})`, `.then(r => r.ok ? r.json() : Promise.reject())`, `.then(j => { const fs = require("fs");`, - `fs.mkdirSync(${JSON.stringify(CONFIG_DIR)}, { recursive: true });`, - `fs.writeFileSync(${JSON.stringify(CACHE_FILE)},`, + `fs.mkdirSync(${JSON.stringify(configDir())}, { recursive: true });`, + `fs.writeFileSync(${JSON.stringify(cacheFile())},`, `JSON.stringify({ checkedAt: Date.now(), latestVersion: j.version })); })`, `.catch(() => {});` ].join(""); diff --git a/test/setup.ts b/test/setup.ts index 7a223f9..3e6027c 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -3,9 +3,12 @@ import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +// Static env at module load — before test files import modules that read env at import time (src/infra/env.ts). +process.env["NO_UPDATE_NOTIFIER"] = "true"; +process.env["GOOGLE_CLIENT_ID"] = "test-client-id"; +process.env["GOOGLE_CLIENT_SECRET"] = "test-client-secret"; + +// Fresh config home per test; config paths are read lazily, so this isolates each test. beforeEach(() => { process.env["COMMIT_TOOLS_HOME"] = mkdtempSync(join(tmpdir(), "commit-tools-test-")); - process.env["NO_UPDATE_NOTIFIER"] = "true"; - process.env["GOOGLE_CLIENT_ID"] = "test-client-id"; - process.env["GOOGLE_CLIENT_SECRET"] = "test-client-secret"; });