From a1a802922daf0ac6a0a8813fad0f35445511a015 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 30 Jun 2026 23:20:47 +0700 Subject: [PATCH 1/8] Build ZCode PR review bot pilot --- .gitignore | 8 + README.md | 35 +- config.example.json | 20 + docs/github-app-setup.md | 29 + docs/launchd.md | 26 + launchd/evaos-code-review-bot.plist.example | 35 + package-lock.json | 1680 +++++++++++++++++++ package.json | 24 + src/cli.ts | 87 + src/config.ts | 64 + src/diff.ts | 61 + src/findings.ts | 124 ++ src/git.ts | 79 + src/github.ts | 134 ++ src/secrets.ts | 18 + src/state.ts | 67 + src/types.ts | 61 + src/worker.ts | 130 ++ src/zcode-env.ts | 67 + src/zcode.ts | 143 ++ tests/diff.test.ts | 45 + tests/findings.test.ts | 44 + tests/state.test.ts | 32 + tests/synthetic-review.test.ts | 39 + tests/zcode-env.test.ts | 44 + tsconfig.json | 15 + vitest.config.ts | 14 + 27 files changed, 3123 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 config.example.json create mode 100644 docs/github-app-setup.md create mode 100644 docs/launchd.md create mode 100644 launchd/evaos-code-review-bot.plist.example create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/diff.ts create mode 100644 src/findings.ts create mode 100644 src/git.ts create mode 100644 src/github.ts create mode 100644 src/secrets.ts create mode 100644 src/state.ts create mode 100644 src/types.ts create mode 100644 src/worker.ts create mode 100644 src/zcode-env.ts create mode 100644 src/zcode.ts create mode 100644 tests/diff.test.ts create mode 100644 tests/findings.test.ts create mode 100644 tests/state.test.ts create mode 100644 tests/synthetic-review.test.ts create mode 100644 tests/zcode-env.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..de9c16a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +runtime/ +.env +*.pem +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/README.md b/README.md index b5324c1b..bd65db2e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,33 @@ -# evaos-code-review-bot -Local ZCode-backed PR review bot pilot for evaOS/Electric Sheep repos +# evaOS Code Review Bot + +Pilot local worker for ZCode/GLM-5.2 pull request reviews. + +## Safety Defaults + +- Reviews only the allowlisted pilot repos in `config.example.json`. +- Skips draft PRs by default. +- Posts at most one review per `{repo, pr, head_sha}`. +- Never submits `APPROVE`. +- Uses `REQUEST_CHANGES` only for validated P0/P1 findings. +- Drops findings that cannot be placed on current RIGHT-side diff lines. +- Drops secret-looking findings instead of redacting and posting them. +- Verifies the ZCode worktree is clean after every review run. +- Caps ZCode prompt patch bytes and kills long ZCode runs with `zcode.timeoutMs`. + +## Commands + +```bash +npm run doctor +npm run run-once -- --dry-run true --repo electricsheephq/WorldOS --pr 1205 +npm run daemon -- --dry-run true +``` + +Posting reviews requires a GitHub App installed on the pilot repos: + +```bash +export EVAOS_REVIEW_BOT_APP_ID=... +export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/path/to/private-key.pem +npm run run-once -- --dry-run false +``` + +The worker derives transient ZCode CLI model environment from the existing ZCode app config at `/Volumes/LEXAR/zcode/.zcode/v2/config.json`; it does not copy the Z.ai API key into this repository. diff --git a/config.example.json b/config.example.json new file mode 100644 index 00000000..3d960dc1 --- /dev/null +++ b/config.example.json @@ -0,0 +1,20 @@ +{ + "pilotRepos": ["electricsheephq/WorldOS", "100yenadmin/evaOS-GUI"], + "pollIntervalMs": 90000, + "skipDrafts": true, + "workRoot": "/Volumes/LEXAR/repos/evaos-code-review-bot/runtime", + "statePath": "/Volumes/LEXAR/Codex/evaos-code-review-bot/state/reviews.sqlite", + "evidenceDir": "/Volumes/LEXAR/Codex/evaos-code-review-bot/evidence", + "zcode": { + "cliPath": "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + "appConfigPath": "/Volumes/LEXAR/zcode/.zcode/v2/config.json", + "model": "GLM-5.2", + "timeoutMs": 180000, + "maxPatchBytes": 80000 + }, + "github": { + "appId": "set via EVAOS_REVIEW_BOT_APP_ID", + "privateKeyPath": "set via EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH", + "token": "optional read-only dry-run token, preferably GITHUB_TOKEN env" + } +} diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md new file mode 100644 index 00000000..9f708ca0 --- /dev/null +++ b/docs/github-app-setup.md @@ -0,0 +1,29 @@ +# GitHub App Setup + +Create a GitHub App named `evaOS Code Review Bot` with slug `evaos-code-review-bot`. + +GitHub does not provide a direct noninteractive `gh app create` command for this. Use the GitHub App manifest handshake or the GitHub web UI. The manifest flow requires a signed-in user to open the organization app-registration URL, approve the manifest, then exchange GitHub's temporary `code` for the app ID and PEM within one hour. + +Required repository permissions: + +- Contents: read +- Pull requests: read/write +- Checks: read +- Actions: read +- Metadata: read + +Install the app only on: + +- `electricsheephq/WorldOS` +- `100yenadmin/evaOS-GUI` + +After installation, save the generated private key outside the repository and launch the worker with: + +```bash +export EVAOS_REVIEW_BOT_APP_ID=... +export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/absolute/path/to/evaos-code-review-bot.private-key.pem +npm run doctor +npm run run-once -- --dry-run true +``` + +Do not run with `--dry-run false` until dry-run evidence exists for both pilot repos and `npm test` plus `npm run build` pass. diff --git a/docs/launchd.md b/docs/launchd.md new file mode 100644 index 00000000..0c1270ee --- /dev/null +++ b/docs/launchd.md @@ -0,0 +1,26 @@ +# Launchd Pilot + +Launchd should stay disabled until GitHub App installation is complete and a real ZCode dry-run succeeds without rate limiting. + +Recommended first live command: + +```bash +cd /Volumes/LEXAR/repos/evaos-code-review-bot +GITHUB_TOKEN="$(gh auth token)" npm run run-once -- --dry-run true --repo electricsheephq/WorldOS --pr 1205 +``` + +After the GitHub App is installed, use app credentials and keep `--dry-run true` for the first 24-hour observation window: + +```bash +export EVAOS_REVIEW_BOT_APP_ID=... +export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/absolute/path/to/private-key.pem +npm run daemon -- --dry-run true +``` + +Only switch to `--dry-run false` after: + +- current-head duplicate reruns post nothing, +- review-plan JSON contains only valid current-diff lines, +- no secret-like text appears in comments or logs, +- ZCode worktrees stay clean after runs, +- GLM/Z.ai rate limits are not firing. diff --git a/launchd/evaos-code-review-bot.plist.example b/launchd/evaos-code-review-bot.plist.example new file mode 100644 index 00000000..863c0f13 --- /dev/null +++ b/launchd/evaos-code-review-bot.plist.example @@ -0,0 +1,35 @@ + + + + + Label + com.electricsheephq.evaos-code-review-bot + WorkingDirectory + /Volumes/LEXAR/repos/evaos-code-review-bot + ProgramArguments + + /opt/homebrew/bin/npm + run + daemon + -- + --dry-run + true + + EnvironmentVariables + + EVAOS_REVIEW_BOT_APP_ID + REPLACE_WITH_APP_ID + EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH + /absolute/path/to/private-key.pem + + RunAtLoad + + KeepAlive + + StandardOutPath + /Volumes/LEXAR/Codex/evaos-code-review-bot/logs/launchd.out.log + StandardErrorPath + /Volumes/LEXAR/Codex/evaos-code-review-bot/logs/launchd.err.log + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..361c34ae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1680 @@ +{ + "name": "evaos-code-review-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "evaos-code-review-bot", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.0.15", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=26" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..2467526e --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "evaos-code-review-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Local ZCode-backed GitHub PR review bot pilot for evaOS repos.", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "doctor": "tsx src/cli.ts doctor", + "run-once": "tsx src/cli.ts run-once", + "daemon": "tsx src/cli.ts daemon" + }, + "devDependencies": { + "@types/node": "^24.0.15", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..cad3feb3 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,87 @@ +import { loadConfig } from "./config.js"; +import { GitHubApi } from "./github.js"; +import { runOnce } from "./worker.js"; +import { resolveZCodeProviderEnv } from "./zcode-env.js"; + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const command = args._[0]; + + if (command === "doctor") { + const config = loadConfig(args.config); + const zcode = resolveZCodeProviderEnv({ + appConfigPath: config.zcode.appConfigPath, + model: config.zcode.model, + providerId: config.zcode.providerId + }); + const github = new GitHubApi(config.github); + console.log(JSON.stringify({ + ok: true, + pilotRepos: config.pilotRepos, + statePath: config.statePath, + workRoot: config.workRoot, + zcode: zcode.redacted, + github: { + canPostAsApp: github.canPostAsApp(), + hasReadToken: Boolean(config.github.token) + } + }, null, 2)); + return; + } + + if (command === "run-once") { + await runOnce({ + configPath: args.config, + dryRun: args["dry-run"] !== "false", + repo: args.repo, + pullNumber: args.pr ? Number(args.pr) : undefined, + useZCode: args.zcode !== "false" + }); + return; + } + + if (command === "daemon") { + const config = loadConfig(args.config); + for (;;) { + await runOnce({ configPath: args.config, dryRun: args["dry-run"] !== "false" }); + await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs)); + } + } + + throw new Error(`Unknown command: ${command ?? "(missing)"}`); +} + +function parseArgs(argv: string[]): ParsedArgs { + const parsed: ParsedArgs = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]!; + if (!arg.startsWith("--")) { + parsed._.push(arg); + continue; + } + const key = arg.slice(2); + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + parsed[key] = next; + index += 1; + } else { + parsed[key] = "true"; + } + } + return parsed; +} + +interface ParsedArgs { + _: string[]; + config?: string; + repo?: string; + pr?: string; + "dry-run"?: string; + zcode?: string; + [key: string]: string | string[] | undefined; +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..403ebca8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; + +export interface BotConfig { + pilotRepos: string[]; + pollIntervalMs: number; + skipDrafts: boolean; + workRoot: string; + statePath: string; + evidenceDir: string; + zcode: { + cliPath: string; + appConfigPath: string; + model: string; + providerId?: string; + timeoutMs: number; + maxPatchBytes: number; + }; + github: { + appId?: string; + privateKeyPath?: string; + token?: string; + }; +} + +const DEFAULT_CONFIG: BotConfig = { + pilotRepos: ["electricsheephq/WorldOS", "100yenadmin/evaOS-GUI"], + pollIntervalMs: 90_000, + skipDrafts: true, + workRoot: "/Volumes/LEXAR/repos/evaos-code-review-bot/runtime", + statePath: "/Volumes/LEXAR/Codex/evaos-code-review-bot/state/reviews.sqlite", + evidenceDir: "/Volumes/LEXAR/Codex/evaos-code-review-bot/evidence", + zcode: { + cliPath: "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + appConfigPath: "/Volumes/LEXAR/zcode/.zcode/v2/config.json", + model: "GLM-5.2", + timeoutMs: 180_000, + maxPatchBytes: 80_000 + }, + github: {} +}; + +export function loadConfig(configPath?: string): BotConfig { + const fromFile = configPath && existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}; + const merged = deepMerge(DEFAULT_CONFIG, fromFile) as BotConfig; + + merged.github.appId = process.env.EVAOS_REVIEW_BOT_APP_ID ?? merged.github.appId; + merged.github.privateKeyPath = process.env.EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH ?? merged.github.privateKeyPath; + merged.github.token = process.env.GITHUB_TOKEN ?? merged.github.token; + + return merged; +} + +function deepMerge(base: unknown, overlay: unknown): unknown { + if (!isRecord(base) || !isRecord(overlay)) return overlay ?? base; + const output: Record = { ...base }; + for (const [key, value] of Object.entries(overlay)) { + output[key] = key in output ? deepMerge(output[key], value) : value; + } + return output; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 00000000..29406da2 --- /dev/null +++ b/src/diff.ts @@ -0,0 +1,61 @@ +import type { DroppedFinding, Finding, PullFilePatch } from "./types.js"; + +const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; + +export function collectRightSideLines(files: PullFilePatch[]): Map> { + const byPath = new Map>(); + + for (const file of files) { + const lines = new Set(); + let newLine: number | null = null; + + for (const rawLine of (file.patch ?? "").split("\n")) { + const hunk = rawLine.match(HUNK_HEADER); + if (hunk) { + newLine = Number(hunk[1]); + continue; + } + + if (newLine === null || rawLine.startsWith("\\ No newline")) continue; + + if (rawLine.startsWith("+") && !rawLine.startsWith("+++")) { + lines.add(newLine); + newLine += 1; + continue; + } + + if (rawLine.startsWith("-") && !rawLine.startsWith("---")) continue; + + lines.add(newLine); + newLine += 1; + } + + if (lines.size > 0) byPath.set(file.filename, lines); + } + + return byPath; +} + +export function validateFindingLocations(findings: Finding[], files: PullFilePatch[]): { + valid: Finding[]; + dropped: DroppedFinding[]; +} { + const rightLines = collectRightSideLines(files); + const valid: Finding[] = []; + const dropped: DroppedFinding[] = []; + + for (const finding of findings) { + const fileLines = rightLines.get(finding.path); + if (!fileLines) { + dropped.push({ ...finding, reason: "file_not_in_diff" }); + continue; + } + if (!fileLines.has(finding.line)) { + dropped.push({ ...finding, reason: "line_not_in_current_diff" }); + continue; + } + valid.push(finding); + } + + return { valid, dropped }; +} diff --git a/src/findings.ts b/src/findings.ts new file mode 100644 index 00000000..6aa076bd --- /dev/null +++ b/src/findings.ts @@ -0,0 +1,124 @@ +import { containsSecretLikeText } from "./secrets.js"; +import type { DroppedFinding, Finding, ReviewComment, ReviewEvent, Severity } from "./types.js"; + +const SEVERITY_RANK: Record = { + P0: 0, + P1: 1, + P2: 2, + P3: 3 +}; + +const SEVERITIES = new Set(["P0", "P1", "P2", "P3"]); + +export function parseFindings(value: unknown): { findings: Finding[]; dropped: DroppedFinding[] } { + const rawFindings = Array.isArray(value) + ? value + : isRecord(value) && Array.isArray(value.findings) + ? value.findings + : []; + const findings: Finding[] = []; + const dropped: DroppedFinding[] = []; + + for (const raw of rawFindings) { + if (!isRecord(raw)) { + dropped.push({ reason: "invalid_schema" }); + continue; + } + + const severity = raw.severity; + const path = raw.path; + const line = raw.line; + const title = raw.title; + const body = raw.body; + const confidence = raw.confidence; + + if ( + !SEVERITIES.has(severity as Severity) || + typeof path !== "string" || + typeof line !== "number" || + !Number.isInteger(line) || + line <= 0 || + typeof title !== "string" || + title.trim().length === 0 || + typeof body !== "string" || + body.trim().length === 0 || + typeof confidence !== "number" || + confidence < 0 || + confidence > 1 + ) { + dropped.push({ reason: "invalid_schema" }); + continue; + } + + findings.push({ + severity: severity as Severity, + path, + line, + title: title.trim(), + body: body.trim(), + confidence, + ...(typeof raw.why_this_matters === "string" && raw.why_this_matters.trim() + ? { why_this_matters: raw.why_this_matters.trim() } + : {}) + }); + } + + return { findings, dropped }; +} + +export function normalizeFindingsForReview( + findings: Finding[], + options: { maxInlineComments?: number } = {} +): { comments: ReviewComment[]; dropped: DroppedFinding[] } { + const maxInlineComments = options.maxInlineComments ?? 25; + const accepted: Finding[] = []; + const dropped: DroppedFinding[] = []; + + for (const finding of findings) { + if (containsSecretLikeText(`${finding.title}\n${finding.body}\n${finding.why_this_matters ?? ""}`)) { + dropped.push({ ...finding, reason: "secret_detected" }); + continue; + } + accepted.push(finding); + } + + accepted.sort((a, b) => { + const severity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]; + if (severity !== 0) return severity; + const path = a.path.localeCompare(b.path); + if (path !== 0) return path; + return a.line - b.line || a.title.localeCompare(b.title); + }); + + const kept = accepted.slice(0, maxInlineComments); + for (const finding of accepted.slice(maxInlineComments)) { + dropped.push({ ...finding, reason: "comment_cap_exceeded" }); + } + + return { + comments: kept.map((finding) => ({ + path: finding.path, + line: finding.line, + side: "RIGHT", + severity: finding.severity, + title: finding.title, + body: formatReviewComment(finding) + })), + dropped + }; +} + +export function decideReviewEvent(findings: Pick[]): ReviewEvent { + return findings.some((finding) => finding.severity === "P0" || finding.severity === "P1") + ? "REQUEST_CHANGES" + : "COMMENT"; +} + +export function formatReviewComment(finding: Finding): string { + const why = finding.why_this_matters ? `\n\nWhy this matters: ${finding.why_this_matters}` : ""; + return `**${finding.severity}: ${finding.title}**\n\n${finding.body}${why}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 00000000..6cfb25fb --- /dev/null +++ b/src/git.ts @@ -0,0 +1,79 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +export interface PreparedWorktree { + path: string; + headSha: string; +} + +export function preparePullWorktree(input: { + repo: string; + pullNumber: number; + expectedHeadSha: string; + workRoot: string; +}): PreparedWorktree { + const safeRepo = input.repo.replace(/[^A-Za-z0-9_.-]+/g, "__"); + const mirrorPath = join(input.workRoot, "mirrors", `${safeRepo}.git`); + const worktreePath = join(input.workRoot, "worktrees", `${safeRepo}__pr-${input.pullNumber}__${input.expectedHeadSha.slice(0, 12)}`); + const repoUrl = `https://github.com/${input.repo}.git`; + + mkdirSync(join(input.workRoot, "mirrors"), { recursive: true }); + mkdirSync(join(input.workRoot, "worktrees"), { recursive: true }); + + if (!existsAsGitMirror(mirrorPath)) { + run("git", ["clone", "--mirror", repoUrl, mirrorPath]); + } else { + run("git", ["--git-dir", mirrorPath, "remote", "set-url", "origin", repoUrl]); + } + + run("git", [ + "--git-dir", + mirrorPath, + "fetch", + "--prune", + "origin", + `+refs/pull/${input.pullNumber}/head:refs/pull/${input.pullNumber}/head`, + "+refs/heads/*:refs/heads/*" + ]); + + rmSync(worktreePath, { recursive: true, force: true }); + run("git", ["--git-dir", mirrorPath, "worktree", "prune"]); + run("git", [ + "--git-dir", + mirrorPath, + "worktree", + "add", + "--detach", + worktreePath, + `refs/pull/${input.pullNumber}/head` + ]); + + const actualHeadSha = run("git", ["-C", worktreePath, "rev-parse", "HEAD"]).stdout.trim(); + if (actualHeadSha !== input.expectedHeadSha) { + throw new Error(`Worktree head mismatch for ${input.repo}#${input.pullNumber}: ${actualHeadSha} !== ${input.expectedHeadSha}`); + } + + return { path: worktreePath, headSha: actualHeadSha }; +} + +export function assertGitClean(worktreePath: string): void { + run("git", ["-C", worktreePath, "diff", "--exit-code"]); + run("git", ["-C", worktreePath, "diff", "--cached", "--exit-code"]); +} + +function existsAsGitMirror(path: string): boolean { + const result = spawnSync("git", ["--git-dir", path, "rev-parse", "--is-bare-repository"], { encoding: "utf8" }); + return result.status === 0 && result.stdout.trim() === "true"; +} + +function run(command: string, args: string[]): { stdout: string; stderr: string } { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024 + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } + return { stdout: result.stdout, stderr: result.stderr }; +} diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 00000000..1345482e --- /dev/null +++ b/src/github.ts @@ -0,0 +1,134 @@ +import { createSign } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { PullFilePatch, PullRequestSummary, ReviewComment, ReviewEvent } from "./types.js"; + +export interface GitHubApiOptions { + appId?: string; + privateKeyPath?: string; + token?: string; + apiBaseUrl?: string; +} + +export class GitHubApi { + private readonly appId?: string; + private readonly privateKey?: string; + private readonly token?: string; + private readonly apiBaseUrl: string; + private installationTokens = new Map(); + + constructor(options: GitHubApiOptions) { + this.appId = options.appId; + this.privateKey = options.privateKeyPath ? readFileSync(options.privateKeyPath, "utf8") : undefined; + this.token = options.token; + this.apiBaseUrl = options.apiBaseUrl ?? "https://api.github.com"; + } + + canPostAsApp(): boolean { + return Boolean(this.appId && this.privateKey); + } + + async listOpenPulls(repo: string): Promise { + return this.request(`/repos/${repo}/pulls?state=open&per_page=100`); + } + + async getPull(repo: string, pullNumber: number): Promise { + return this.request(`/repos/${repo}/pulls/${pullNumber}`); + } + + async listPullFiles(repo: string, pullNumber: number): Promise { + const files: PullFilePatch[] = []; + for (let page = 1; ; page += 1) { + const chunk = await this.request( + `/repos/${repo}/pulls/${pullNumber}/files?per_page=100&page=${page}` + ); + files.push(...chunk); + if (chunk.length < 100) return files; + } + } + + async createReview(input: { + repo: string; + pullNumber: number; + event: ReviewEvent; + body: string; + comments: ReviewComment[]; + }): Promise<{ html_url?: string; id: number }> { + if (!this.canPostAsApp()) { + throw new Error("GitHub App credentials are required before posting reviews."); + } + const token = await this.getInstallationToken(input.repo); + return this.request<{ html_url?: string; id: number }>(`/repos/${input.repo}/pulls/${input.pullNumber}/reviews`, { + method: "POST", + token, + body: { + event: input.event, + body: input.body, + comments: input.comments.map((comment) => ({ + path: comment.path, + line: comment.line, + side: comment.side, + body: comment.body + })) + } + }); + } + + private async getInstallationToken(repo: string): Promise { + const cached = this.installationTokens.get(repo); + if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token; + if (!this.appId || !this.privateKey) throw new Error("Missing GitHub App credentials."); + + const jwt = createAppJwt(this.appId, this.privateKey); + const installation = await this.request<{ id: number }>(`/repos/${repo}/installation`, { token: jwt }); + const token = await this.request<{ token: string; expires_at: string }>( + `/app/installations/${installation.id}/access_tokens`, + { method: "POST", token: jwt, body: { repositories: [repo.split("/")[1]] } } + ); + + this.installationTokens.set(repo, { + token: token.token, + expiresAt: new Date(token.expires_at).getTime() + }); + return token.token; + } + + private async request( + path: string, + options: { method?: string; token?: string; body?: unknown } = {} + ): Promise { + const token = options.token ?? this.token; + const response = await fetch(`${this.apiBaseUrl}${path}`, { + method: options.method ?? "GET", + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }) + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body) + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API ${response.status} ${response.statusText} for ${path}: ${text.slice(0, 400)}`); + } + + return (await response.json()) as T; + } +} + +export function createAppJwt(appId: string, privateKey: string, nowSeconds = Math.floor(Date.now() / 1000)): string { + const header = base64UrlJson({ alg: "RS256", typ: "JWT" }); + const payload = base64UrlJson({ + iat: nowSeconds - 60, + exp: nowSeconds + 540, + iss: appId + }); + const unsigned = `${header}.${payload}`; + const signature = createSign("RSA-SHA256").update(unsigned).sign(privateKey, "base64url"); + return `${unsigned}.${signature}`; +} + +function base64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 00000000..8bc378a8 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,18 @@ +const SECRET_PATTERNS: RegExp[] = [ + /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g, + /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/gi, + /\b(?:api[_-]?key|token|secret|password|cookie|session)\b\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}/gi, + /-----BEGIN [A-Z ]*PRIVATE KEY-----/g +]; + +export function containsSecretLikeText(input: string): boolean { + return SECRET_PATTERNS.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(input); + }); +} + +export function redactSecrets(input: string): string { + return SECRET_PATTERNS.reduce((text, pattern) => text.replace(pattern, "[redacted-secret]"), input); +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 00000000..0e2e3cbd --- /dev/null +++ b/src/state.ts @@ -0,0 +1,67 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import type { ReviewEvent } from "./types.js"; + +export type ProcessedStatus = "dry_run" | "posted" | "skipped" | "failed"; + +export interface ProcessedReviewRecord { + repo: string; + pullNumber: number; + headSha: string; + status: ProcessedStatus; + event?: ReviewEvent; + reviewUrl?: string; + error?: string; +} + +export class ReviewStateStore { + private readonly db: DatabaseSync; + + constructor(dbPath: string) { + mkdirSync(dirname(dbPath), { recursive: true }); + this.db = new DatabaseSync(dbPath); + this.db.exec(` + create table if not exists processed_reviews ( + repo text not null, + pull_number integer not null, + head_sha text not null, + status text not null, + event text, + review_url text, + error text, + created_at text not null default (datetime('now')), + primary key (repo, pull_number, head_sha) + ); + `); + } + + hasProcessed(repo: string, pullNumber: number, headSha: string): boolean { + const row = this.db + .prepare("select 1 from processed_reviews where repo = ? and pull_number = ? and head_sha = ? limit 1") + .get(repo, pullNumber, headSha); + return Boolean(row); + } + + recordProcessed(record: ProcessedReviewRecord): void { + this.db + .prepare( + `insert or replace into processed_reviews + (repo, pull_number, head_sha, status, event, review_url, error, created_at) + values (?, ?, ?, ?, ?, ?, ?, datetime('now'))` + ) + .run( + record.repo, + record.pullNumber, + record.headSha, + record.status, + record.event ?? null, + record.reviewUrl ?? null, + record.error ?? null + ); + } + + close(): void { + this.db.close(); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..73dd3bb8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,61 @@ +export type Severity = "P0" | "P1" | "P2" | "P3"; + +export type ReviewEvent = "COMMENT" | "REQUEST_CHANGES"; + +export interface Finding { + severity: Severity; + path: string; + line: number; + title: string; + body: string; + confidence: number; + why_this_matters?: string; +} + +export interface DroppedFinding extends Partial { + reason: string; +} + +export interface PullFilePatch { + filename: string; + patch?: string | null; +} + +export interface PullRequestSummary { + number: number; + title: string; + draft: boolean; + head: { + sha: string; + ref: string; + repo?: { + full_name: string; + clone_url?: string; + } | null; + }; + base: { + sha: string; + ref: string; + repo: { + full_name: string; + clone_url?: string; + }; + }; + html_url: string; +} + +export interface ReviewComment { + path: string; + line: number; + side: "RIGHT"; + body: string; + severity: Severity; + title: string; +} + +export interface ReviewPlan { + event: ReviewEvent; + comments: ReviewComment[]; + dropped: DroppedFinding[]; + summary: string; +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 00000000..2c669121 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,130 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadConfig, type BotConfig } from "./config.js"; +import { validateFindingLocations } from "./diff.js"; +import { decideReviewEvent, normalizeFindingsForReview } from "./findings.js"; +import { assertGitClean, preparePullWorktree } from "./git.js"; +import { GitHubApi } from "./github.js"; +import { ReviewStateStore } from "./state.js"; +import { buildReviewPrompt, runZCodeReview } from "./zcode.js"; +import type { PullRequestSummary, ReviewPlan } from "./types.js"; + +export interface RunOnceOptions { + configPath?: string; + dryRun: boolean; + repo?: string; + pullNumber?: number; + useZCode?: boolean; +} + +export async function runOnce(options: RunOnceOptions): Promise { + const config = loadConfig(options.configPath); + const github = new GitHubApi(config.github); + const state = new ReviewStateStore(config.statePath); + try { + const repos = options.repo ? [options.repo] : config.pilotRepos; + for (const repo of repos) { + const pulls = options.pullNumber + ? [await github.getPull(repo, options.pullNumber)] + : await github.listOpenPulls(repo); + for (const pull of pulls) { + await reviewPull({ config, github, state, repo, pull, dryRun: options.dryRun, useZCode: options.useZCode ?? true }); + } + } + } finally { + state.close(); + } +} + +export async function reviewPull(input: { + config: BotConfig; + github: GitHubApi; + state: ReviewStateStore; + repo: string; + pull: PullRequestSummary; + dryRun: boolean; + useZCode: boolean; +}): Promise { + const { config, github, state, repo, pull } = input; + if (config.skipDrafts && pull.draft) return; + if (state.hasProcessed(repo, pull.number, pull.head.sha)) return; + + const evidenceDir = join(config.evidenceDir, new Date().toISOString().slice(0, 10), repo.replace("/", "__"), `pr-${pull.number}`, pull.head.sha); + mkdirSync(evidenceDir, { recursive: true }); + + const files = await github.listPullFiles(repo, pull.number); + const worktree = preparePullWorktree({ + repo, + pullNumber: pull.number, + expectedHeadSha: pull.head.sha, + workRoot: config.workRoot + }); + + const prompt = buildReviewPrompt({ repo, pull, files, maxPatchBytes: config.zcode.maxPatchBytes }); + writeFileSync(join(evidenceDir, "review-prompt.txt"), prompt); + + const zcodeResult = input.useZCode + ? runZCodeReview({ + cwd: worktree.path, + prompt, + cliPath: config.zcode.cliPath, + appConfigPath: config.zcode.appConfigPath, + model: config.zcode.model, + providerId: config.zcode.providerId, + evidenceDir, + timeoutMs: config.zcode.timeoutMs + }) + : { findings: [], droppedFromSchema: [], rawResponse: "{\"findings\":[]}" }; + + assertGitClean(worktree.path); + + const located = validateFindingLocations(zcodeResult.findings, files); + const normalized = normalizeFindingsForReview(located.valid, { maxInlineComments: 25 }); + const comments = normalized.comments; + const dropped = [...zcodeResult.droppedFromSchema, ...located.dropped, ...normalized.dropped]; + const event = decideReviewEvent(comments); + const plan: ReviewPlan = { + event, + comments, + dropped, + summary: buildSummary({ repo, pull, comments, dropped, dryRun: input.dryRun }) + }; + + writeFileSync(join(evidenceDir, "review-plan.json"), `${JSON.stringify(plan, null, 2)}\n`); + + if (input.dryRun) { + state.recordProcessed({ repo, pullNumber: pull.number, headSha: pull.head.sha, status: "dry_run", event }); + return; + } + + const review = await github.createReview({ + repo, + pullNumber: pull.number, + event, + body: plan.summary, + comments + }); + state.recordProcessed({ + repo, + pullNumber: pull.number, + headSha: pull.head.sha, + status: "posted", + event, + reviewUrl: review.html_url + }); +} + +function buildSummary(input: { + repo: string; + pull: PullRequestSummary; + comments: { severity: string }[]; + dropped: { reason: string }[]; + dryRun: boolean; +}): string { + const p0p1 = input.comments.filter((comment) => comment.severity === "P0" || comment.severity === "P1").length; + return [ + `evaOS ZCode review ${input.dryRun ? "dry run" : "result"} for ${input.repo}#${input.pull.number} at ${input.pull.head.sha}.`, + `Inline comments: ${input.comments.length}. High-severity comments: ${p0p1}. Dropped findings: ${input.dropped.length}.`, + "Pilot policy: this bot never approves PRs; it requests changes only for validated P0/P1 findings." + ].join("\n\n"); +} diff --git a/src/zcode-env.ts b/src/zcode-env.ts new file mode 100644 index 00000000..c06d01a1 --- /dev/null +++ b/src/zcode-env.ts @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; + +interface ResolveZCodeProviderEnvOptions { + appConfigPath: string; + model: string; + providerId?: string; +} + +export interface ResolvedZCodeProviderEnv { + ZCODE_MODEL: string; + ZCODE_BASE_URL: string; + ZCODE_API_KEY: string; + redacted: { + providerId: string; + model: string; + baseURL: string; + apiKey: string; + }; +} + +export function resolveZCodeProviderEnv(options: ResolveZCodeProviderEnvOptions): ResolvedZCodeProviderEnv { + const config = JSON.parse(readFileSync(options.appConfigPath, "utf8")) as { + provider?: Record; + }; + const providers = Object.entries(config.provider ?? {}); + const match = providers.find(([providerId, provider]) => { + if (options.providerId && providerId !== options.providerId) return false; + return Boolean( + provider.enabled && + provider.options?.apiKey && + provider.options.baseURL && + provider.models && + Object.prototype.hasOwnProperty.call(provider.models, options.model) + ); + }); + + if (!match) { + throw new Error(`No enabled ZCode provider found for ${options.providerId ?? "any provider"}/${options.model}`); + } + + const [providerId, provider] = match; + const providerOptions = provider.options; + if (!providerOptions?.apiKey || !providerOptions.baseURL) { + throw new Error(`Selected ZCode provider ${providerId} is missing apiKey or baseURL.`); + } + + return { + ZCODE_MODEL: `${providerId}/${options.model}`, + ZCODE_BASE_URL: providerOptions.baseURL, + ZCODE_API_KEY: providerOptions.apiKey, + redacted: { + providerId, + model: options.model, + baseURL: providerOptions.baseURL, + apiKey: `[redacted len=${providerOptions.apiKey.length}]` + } + }; +} + +interface ZCodeProviderConfig { + enabled?: boolean; + options?: { + apiKey?: string; + baseURL?: string; + }; + models?: Record; +} diff --git a/src/zcode.ts b/src/zcode.ts new file mode 100644 index 00000000..d2cd1642 --- /dev/null +++ b/src/zcode.ts @@ -0,0 +1,143 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseFindings } from "./findings.js"; +import { resolveZCodeProviderEnv } from "./zcode-env.js"; +import type { Finding, PullFilePatch, PullRequestSummary } from "./types.js"; + +export interface ZCodeReviewResult { + findings: Finding[]; + droppedFromSchema: ReturnType["dropped"]; + rawResponse: string; +} + +export function buildReviewPrompt(input: { + repo: string; + pull: PullRequestSummary; + files: PullFilePatch[]; + maxPatchBytes?: number; +}): string { + const fileList = input.files.map((file) => `- ${file.filename}`).join("\n"); + let remainingPatchBytes = input.maxPatchBytes ?? 80_000; + const patches = input.files + .map((file) => { + const rawPatch = file.patch ?? "[binary or too large for GitHub patch]"; + const patch = truncateToBudget(rawPatch, remainingPatchBytes); + remainingPatchBytes = Math.max(0, remainingPatchBytes - Buffer.byteLength(patch)); + return `### ${file.filename}\n\n\`\`\`diff\n${patch}\n\`\`\``; + }) + .join("\n\n"); + + return [ + "You are evaOS Code Review Bot. Review this pull request aggressively for correctness, security, data loss, CI-breaking behavior, Unity/game regression risk, and missing high-signal tests.", + "Do not modify files. Do not run project tests, package scripts, builds, app commands, or arbitrary PR code.", + "Do not call Bash or shell commands. If more context is needed, use read-only file inspection only. If that is impossible, return no findings rather than executing code.", + "Only inspect the checkout and the diff provided below.", + "Return JSON only, with shape: {\"findings\":[{\"severity\":\"P0|P1|P2|P3\",\"path\":\"relative/file\",\"line\":123,\"title\":\"short title\",\"body\":\"specific actionable explanation\",\"confidence\":0.0,\"why_this_matters\":\"optional\"}],\"summary\":\"short review summary\"}.", + "Use P0/P1 only for validated correctness, security, data-loss, CI-breaking, or release-regression issues. Prefer no finding over speculative noise.", + "Every finding must point at a RIGHT-side line in the current diff.", + "", + `Repository: ${input.repo}`, + `Pull request: #${input.pull.number} ${input.pull.title}`, + `Head SHA: ${input.pull.head.sha}`, + "", + "Files:", + fileList, + "", + "Diff:", + patches + ].join("\n"); +} + +export function runZCodeReview(input: { + cwd: string; + prompt: string; + cliPath: string; + appConfigPath: string; + model: string; + providerId?: string; + evidenceDir?: string; + timeoutMs?: number; +}): ZCodeReviewResult { + const zcodeEnv = resolveZCodeProviderEnv({ + appConfigPath: input.appConfigPath, + model: input.model, + providerId: input.providerId + }); + + const result = spawnSync(process.execPath, [ + input.cliPath, + "--cwd", + input.cwd, + "--mode", + "plan", + "--json", + "--no-browser", + "--prompt", + input.prompt + ], { + env: { + ...process.env, + ZCODE_MODEL: zcodeEnv.ZCODE_MODEL, + ZCODE_BASE_URL: zcodeEnv.ZCODE_BASE_URL, + ZCODE_API_KEY: zcodeEnv.ZCODE_API_KEY + }, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + timeout: input.timeoutMs ?? 180_000 + }); + + const stderr = result.stderr.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]"); + if (input.evidenceDir) { + mkdirSync(input.evidenceDir, { recursive: true }); + writeFileSync(join(input.evidenceDir, "zcode-last-stdout.jsonl"), result.stdout); + writeFileSync(join(input.evidenceDir, "zcode-last-stderr.txt"), stderr); + } + + if (result.status !== 0) { + if (result.error) { + throw new Error(`ZCode failed before completion: ${result.error.message}`); + } + throw new Error(`ZCode failed with status ${result.status}: ${stderr || result.stdout.slice(0, 1000)}`); + } + + const rawResponse = extractZCodeResponse(result.stdout); + const parsed = JSON.parse(extractJsonObject(rawResponse)); + const { findings, dropped } = parseFindings(parsed); + return { findings, droppedFromSchema: dropped, rawResponse }; +} + +function truncateToBudget(text: string, maxBytes: number): string { + if (maxBytes <= 0) return "[patch omitted: prompt budget exhausted]"; + const bytes = Buffer.byteLength(text); + if (bytes <= maxBytes) return text; + return `${text.slice(0, maxBytes)}\n[patch truncated to fit prompt budget]`; +} + +function extractZCodeResponse(stdout: string): string { + const candidates = stdout + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as { response?: unknown }; + } catch { + return null; + } + }) + .filter((value): value is { response?: unknown } => Boolean(value)); + + const response = [...candidates].reverse().find((value) => typeof value.response === "string")?.response; + if (typeof response !== "string") throw new Error("ZCode JSON output did not include a string response."); + return response; +} + +function extractJsonObject(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced) return fenced[1]!.trim(); + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end < start) throw new Error("ZCode response did not contain a JSON object."); + return text.slice(start, end + 1); +} diff --git a/tests/diff.test.ts b/tests/diff.test.ts new file mode 100644 index 00000000..21e14cad --- /dev/null +++ b/tests/diff.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { collectRightSideLines, validateFindingLocations } from "../src/diff.js"; +import type { Finding } from "../src/types.js"; + +describe("diff coordinate validation", () => { + const files = [ + { + filename: "src/game/combat.ts", + patch: [ + "@@ -10,5 +10,7 @@ export function resolveTurn() {", + " const before = true;", + "-const removed = legacy();", + "+const added = nextFrame();", + "+const alsoAdded = true;", + " return before;", + "@@ -42,2 +44,3 @@ export function render() {", + " draw();", + "+flush();" + ].join("\n") + } + ]; + + it("treats only RIGHT-side diff lines as inline-commentable", () => { + const lines = collectRightSideLines(files); + + expect(lines.get("src/game/combat.ts")).toEqual(new Set([10, 11, 12, 13, 44, 45])); + }); + + it("drops deleted, missing, and out-of-diff findings while preserving valid findings", () => { + const findings: Finding[] = [ + { severity: "P1", path: "src/game/combat.ts", line: 11, title: "Valid", body: "This is valid.", confidence: 0.9 }, + { severity: "P1", path: "src/game/combat.ts", line: 12, title: "Also valid", body: "This is also valid.", confidence: 0.9 }, + { severity: "P1", path: "src/game/combat.ts", line: 9, title: "Deleted", body: "Cannot comment here.", confidence: 0.9 }, + { severity: "P1", path: "src/other.ts", line: 1, title: "Missing", body: "Wrong file.", confidence: 0.9 } + ]; + + const result = validateFindingLocations(findings, files); + + expect(result.valid.map((finding) => finding.title)).toEqual(["Valid", "Also valid"]); + expect(result.dropped).toEqual([ + expect.objectContaining({ title: "Deleted", reason: "line_not_in_current_diff" }), + expect.objectContaining({ title: "Missing", reason: "file_not_in_diff" }) + ]); + }); +}); diff --git a/tests/findings.test.ts b/tests/findings.test.ts new file mode 100644 index 00000000..eb65c2e7 --- /dev/null +++ b/tests/findings.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decideReviewEvent, normalizeFindingsForReview } from "../src/findings.js"; +import type { Finding } from "../src/types.js"; + +describe("finding normalization and review policy", () => { + it("keeps validated findings, caps aggressive inline comments, and sorts by severity", () => { + const findings: Finding[] = Array.from({ length: 30 }, (_, index) => ({ + severity: index === 29 ? "P0" : index % 2 === 0 ? "P2" : "P3", + path: "a.ts", + line: 1, + title: `Finding ${index}`, + body: "A concrete review comment.", + confidence: 0.8 + })); + + const result = normalizeFindingsForReview(findings, { maxInlineComments: 25 }); + + expect(result.comments).toHaveLength(25); + expect(result.comments[0]?.severity).toBe("P0"); + expect(result.dropped.filter((drop) => drop.reason === "comment_cap_exceeded")).toHaveLength(5); + }); + + it("drops any finding whose title or body contains secret-looking material", () => { + const result = normalizeFindingsForReview([ + { + severity: "P1", + path: "a.ts", + line: 1, + title: "Leaked token", + body: "The raw token ghp_1234567890abcdefghijklmnopqrstuvwx should never be posted.", + confidence: 0.99 + } + ]); + + expect(result.comments).toEqual([]); + expect(result.dropped).toEqual([expect.objectContaining({ reason: "secret_detected" })]); + }); + + it("requests changes only for P0 or P1 findings", () => { + expect(decideReviewEvent([{ severity: "P2" }, { severity: "P3" }])).toBe("COMMENT"); + expect(decideReviewEvent([{ severity: "P1" }])).toBe("REQUEST_CHANGES"); + expect(decideReviewEvent([{ severity: "P0" }])).toBe("REQUEST_CHANGES"); + }); +}); diff --git a/tests/state.test.ts b/tests/state.test.ts new file mode 100644 index 00000000..fe929e29 --- /dev/null +++ b/tests/state.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ReviewStateStore } from "../src/state.js"; + +describe("review state store", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("deduplicates one review per repo, PR, and head SHA", () => { + const root = mkdtempSync(join(tmpdir(), "evaos-review-state-")); + roots.push(root); + const store = new ReviewStateStore(join(root, "state.sqlite")); + + expect(store.hasProcessed("electricsheephq/WorldOS", 1205, "abc123")).toBe(false); + store.recordProcessed({ + repo: "electricsheephq/WorldOS", + pullNumber: 1205, + headSha: "abc123", + status: "dry_run", + event: "COMMENT" + }); + + expect(store.hasProcessed("electricsheephq/WorldOS", 1205, "abc123")).toBe(true); + expect(store.hasProcessed("electricsheephq/WorldOS", 1205, "def456")).toBe(false); + store.close(); + }); +}); diff --git a/tests/synthetic-review.test.ts b/tests/synthetic-review.test.ts new file mode 100644 index 00000000..4ea6a3ae --- /dev/null +++ b/tests/synthetic-review.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { validateFindingLocations } from "../src/diff.js"; +import { decideReviewEvent, normalizeFindingsForReview } from "../src/findings.js"; +import type { Finding } from "../src/types.js"; + +describe("synthetic high-severity review fixture", () => { + it("turns a validated P1 regression finding into REQUEST_CHANGES", () => { + const files = [ + { + filename: "Assets/Scripts/CombatTurn.cs", + patch: [ + "@@ -25,4 +25,5 @@ public void ResolveTurn() {", + " ApplyStartOfTurnEffects();", + "+player.Health = 1;", + " ResolveQueuedActions();", + " }" + ].join("\n") + } + ]; + const findings: Finding[] = [ + { + severity: "P1", + path: "Assets/Scripts/CombatTurn.cs", + line: 26, + title: "Combat health reset breaks active fights", + body: "The new assignment forces every player to one health during turn resolution, which can incorrectly kill or near-kill healthy players.", + confidence: 0.96, + why_this_matters: "This is a deterministic gameplay regression in the core combat loop." + } + ]; + + const located = validateFindingLocations(findings, files); + const normalized = normalizeFindingsForReview(located.valid); + + expect(located.dropped).toEqual([]); + expect(normalized.comments).toHaveLength(1); + expect(decideReviewEvent(normalized.comments)).toBe("REQUEST_CHANGES"); + }); +}); diff --git a/tests/zcode-env.test.ts b/tests/zcode-env.test.ts new file mode 100644 index 00000000..b87d872c --- /dev/null +++ b/tests/zcode-env.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveZCodeProviderEnv } from "../src/zcode-env.js"; + +describe("ZCode provider env", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("derives transient CLI env from the enabled GLM provider without logging the key", () => { + const root = mkdtempSync(join(tmpdir(), "zcode-env-")); + roots.push(root); + const configPath = join(root, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + provider: { + "builtin:zai-coding-plan": { + enabled: true, + kind: "anthropic", + options: { + apiKey: "zai-secret-key", + baseURL: "https://api.z.ai/api/anthropic" + }, + models: { + "GLM-5.2": {} + } + } + } + }) + ); + + const env = resolveZCodeProviderEnv({ appConfigPath: configPath, model: "GLM-5.2" }); + + expect(env.ZCODE_MODEL).toBe("builtin:zai-coding-plan/GLM-5.2"); + expect(env.ZCODE_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + expect(env.ZCODE_API_KEY).toBe("zai-secret-key"); + expect(JSON.stringify(env.redacted)).not.toContain("zai-secret-key"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..fa8d1da7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..9d7acaee --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["tests/**/*.test.ts"], + pool: "threads", + poolOptions: { + threads: { + singleThread: true + } + } + } +}); From e552bbf3273e4505f10cc1857730ea26e055cae1 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 00:07:32 +0700 Subject: [PATCH 2/8] Harden ZCode review worker --- README.md | 5 +- config.example.json | 3 +- src/config.ts | 4 +- src/findings.ts | 13 +++- src/git.ts | 4 ++ src/secrets.ts | 2 + src/worker.ts | 19 ++++- src/zcode-env.ts | 14 ++++ src/zcode.ts | 138 +++++++++++++++++++++++++++++-------- tests/findings.test.ts | 22 +++++- tests/git-clean.test.ts | 24 +++++++ tests/secrets.test.ts | 19 +++++ tests/zcode-env.test.ts | 28 +++++++- tests/zcode-output.test.ts | 26 +++++++ tests/zcode-policy.test.ts | 51 ++++++++++++++ 15 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 tests/git-clean.test.ts create mode 100644 tests/secrets.test.ts create mode 100644 tests/zcode-output.test.ts create mode 100644 tests/zcode-policy.test.ts diff --git a/README.md b/README.md index bd65db2e..983252d6 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,11 @@ Pilot local worker for ZCode/GLM-5.2 pull request reviews. - Uses `REQUEST_CHANGES` only for validated P0/P1 findings. - Drops findings that cannot be placed on current RIGHT-side diff lines. - Drops secret-looking findings instead of redacting and posting them. -- Verifies the ZCode worktree is clean after every review run. +- Redacts secret-looking material from local evidence logs before writing them. +- Verifies the ZCode worktree is clean, including untracked files, after every review run. - Caps ZCode prompt patch bytes and kills long ZCode runs with `zcode.timeoutMs`. +- Installs a temporary per-worktree ZCode policy that allows read-only file tools, disables Bash/mutation/subagents, then restores/removes it before the clean check. +- Sets `ZCODE_MODEL_RETRY_MAX_RETRIES=0` by default so provider rate limits fail fast instead of multiplying requests. ## Commands diff --git a/config.example.json b/config.example.json index 3d960dc1..160961f4 100644 --- a/config.example.json +++ b/config.example.json @@ -10,7 +10,8 @@ "appConfigPath": "/Volumes/LEXAR/zcode/.zcode/v2/config.json", "model": "GLM-5.2", "timeoutMs": 180000, - "maxPatchBytes": 80000 + "maxPatchBytes": 80000, + "retryMaxRetries": 0 }, "github": { "appId": "set via EVAOS_REVIEW_BOT_APP_ID", diff --git a/src/config.ts b/src/config.ts index 403ebca8..fdbda105 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ export interface BotConfig { providerId?: string; timeoutMs: number; maxPatchBytes: number; + retryMaxRetries: number; }; github: { appId?: string; @@ -34,7 +35,8 @@ const DEFAULT_CONFIG: BotConfig = { appConfigPath: "/Volumes/LEXAR/zcode/.zcode/v2/config.json", model: "GLM-5.2", timeoutMs: 180_000, - maxPatchBytes: 80_000 + maxPatchBytes: 80_000, + retryMaxRetries: 0 }, github: {} }; diff --git a/src/findings.ts b/src/findings.ts index 6aa076bd..d2097e32 100644 --- a/src/findings.ts +++ b/src/findings.ts @@ -1,4 +1,4 @@ -import { containsSecretLikeText } from "./secrets.js"; +import { containsSecretLikeText, redactSecrets } from "./secrets.js"; import type { DroppedFinding, Finding, ReviewComment, ReviewEvent, Severity } from "./types.js"; const SEVERITY_RANK: Record = { @@ -76,7 +76,7 @@ export function normalizeFindingsForReview( for (const finding of findings) { if (containsSecretLikeText(`${finding.title}\n${finding.body}\n${finding.why_this_matters ?? ""}`)) { - dropped.push({ ...finding, reason: "secret_detected" }); + dropped.push({ ...redactFinding(finding), reason: "secret_detected" }); continue; } accepted.push(finding); @@ -108,6 +108,15 @@ export function normalizeFindingsForReview( }; } +function redactFinding(finding: T): T { + return { + ...finding, + title: redactSecrets(finding.title), + body: redactSecrets(finding.body), + ...(finding.why_this_matters ? { why_this_matters: redactSecrets(finding.why_this_matters) } : {}) + }; +} + export function decideReviewEvent(findings: Pick[]): ReviewEvent { return findings.some((finding) => finding.severity === "P0" || finding.severity === "P1") ? "REQUEST_CHANGES" diff --git a/src/git.ts b/src/git.ts index 6cfb25fb..4423accb 100644 --- a/src/git.ts +++ b/src/git.ts @@ -60,6 +60,10 @@ export function preparePullWorktree(input: { export function assertGitClean(worktreePath: string): void { run("git", ["-C", worktreePath, "diff", "--exit-code"]); run("git", ["-C", worktreePath, "diff", "--cached", "--exit-code"]); + const status = run("git", ["-C", worktreePath, "status", "--porcelain=v1", "--untracked-files=all"]).stdout.trim(); + if (status) { + throw new Error(`Worktree has untracked or modified files after review:\n${status}`); + } } function existsAsGitMirror(path: string): boolean { diff --git a/src/secrets.ts b/src/secrets.ts index 8bc378a8..613d2aed 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -3,6 +3,8 @@ const SECRET_PATTERNS: RegExp[] = [ /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g, /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/gi, /\b(?:api[_-]?key|token|secret|password|cookie|session)\b\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}/gi, + /\b[A-Za-z0-9]{3,}[-_](?:secret|token|password|cookie)[-_][A-Za-z0-9_-]{3,}\b/gi, + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, /-----BEGIN [A-Z ]*PRIVATE KEY-----/g ]; diff --git a/src/worker.ts b/src/worker.ts index 2c669121..8817ed70 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -5,6 +5,7 @@ import { validateFindingLocations } from "./diff.js"; import { decideReviewEvent, normalizeFindingsForReview } from "./findings.js"; import { assertGitClean, preparePullWorktree } from "./git.js"; import { GitHubApi } from "./github.js"; +import { redactSecrets } from "./secrets.js"; import { ReviewStateStore } from "./state.js"; import { buildReviewPrompt, runZCodeReview } from "./zcode.js"; import type { PullRequestSummary, ReviewPlan } from "./types.js"; @@ -61,7 +62,7 @@ export async function reviewPull(input: { }); const prompt = buildReviewPrompt({ repo, pull, files, maxPatchBytes: config.zcode.maxPatchBytes }); - writeFileSync(join(evidenceDir, "review-prompt.txt"), prompt); + writeFileSync(join(evidenceDir, "review-prompt.txt"), redactSecrets(prompt)); const zcodeResult = input.useZCode ? runZCodeReview({ @@ -72,7 +73,8 @@ export async function reviewPull(input: { model: config.zcode.model, providerId: config.zcode.providerId, evidenceDir, - timeoutMs: config.zcode.timeoutMs + timeoutMs: config.zcode.timeoutMs, + retryMaxRetries: config.zcode.retryMaxRetries }) : { findings: [], droppedFromSchema: [], rawResponse: "{\"findings\":[]}" }; @@ -81,7 +83,7 @@ export async function reviewPull(input: { const located = validateFindingLocations(zcodeResult.findings, files); const normalized = normalizeFindingsForReview(located.valid, { maxInlineComments: 25 }); const comments = normalized.comments; - const dropped = [...zcodeResult.droppedFromSchema, ...located.dropped, ...normalized.dropped]; + const dropped = sanitizeDroppedFindings([...zcodeResult.droppedFromSchema, ...located.dropped, ...normalized.dropped]); const event = decideReviewEvent(comments); const plan: ReviewPlan = { event, @@ -114,6 +116,17 @@ export async function reviewPull(input: { }); } +function sanitizeDroppedFindings(dropped: ReviewPlan["dropped"]): ReviewPlan["dropped"] { + return dropped.map((finding) => ({ + ...finding, + ...(typeof finding.title === "string" ? { title: redactSecrets(finding.title) } : {}), + ...(typeof finding.body === "string" ? { body: redactSecrets(finding.body) } : {}), + ...(typeof finding.why_this_matters === "string" + ? { why_this_matters: redactSecrets(finding.why_this_matters) } + : {}) + })); +} + function buildSummary(input: { repo: string; pull: PullRequestSummary; diff --git a/src/zcode-env.ts b/src/zcode-env.ts index c06d01a1..85d9ddc5 100644 --- a/src/zcode-env.ts +++ b/src/zcode-env.ts @@ -57,6 +57,20 @@ export function resolveZCodeProviderEnv(options: ResolveZCodeProviderEnvOptions) }; } +export function buildZCodeRuntimeEnv(options: { + baseEnv: Record; + providerEnv: ResolvedZCodeProviderEnv; + retryMaxRetries: number; +}): Record { + return { + ...options.baseEnv, + ZCODE_MODEL: options.providerEnv.ZCODE_MODEL, + ZCODE_BASE_URL: options.providerEnv.ZCODE_BASE_URL, + ZCODE_API_KEY: options.providerEnv.ZCODE_API_KEY, + ZCODE_MODEL_RETRY_MAX_RETRIES: String(Math.max(0, Math.floor(options.retryMaxRetries))) + }; +} + interface ZCodeProviderConfig { enabled?: boolean; options?: { diff --git a/src/zcode.ts b/src/zcode.ts index d2cd1642..ea8dc862 100644 --- a/src/zcode.ts +++ b/src/zcode.ts @@ -1,8 +1,9 @@ import { spawnSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parseFindings } from "./findings.js"; -import { resolveZCodeProviderEnv } from "./zcode-env.js"; +import { redactSecrets } from "./secrets.js"; +import { buildZCodeRuntimeEnv, resolveZCodeProviderEnv } from "./zcode-env.js"; import type { Finding, PullFilePatch, PullRequestSummary } from "./types.js"; export interface ZCodeReviewResult { @@ -58,6 +59,7 @@ export function runZCodeReview(input: { providerId?: string; evidenceDir?: string; timeoutMs?: number; + retryMaxRetries?: number; }): ZCodeReviewResult { const zcodeEnv = resolveZCodeProviderEnv({ appConfigPath: input.appConfigPath, @@ -65,32 +67,34 @@ export function runZCodeReview(input: { providerId: input.providerId }); - const result = spawnSync(process.execPath, [ - input.cliPath, - "--cwd", - input.cwd, - "--mode", - "plan", - "--json", - "--no-browser", - "--prompt", - input.prompt - ], { - env: { - ...process.env, - ZCODE_MODEL: zcodeEnv.ZCODE_MODEL, - ZCODE_BASE_URL: zcodeEnv.ZCODE_BASE_URL, - ZCODE_API_KEY: zcodeEnv.ZCODE_API_KEY - }, - encoding: "utf8", - maxBuffer: 20 * 1024 * 1024, - timeout: input.timeoutMs ?? 180_000 - }); + const result = withTemporaryZCodeReviewPolicy(input.cwd, input.evidenceDir, () => + spawnSync(process.execPath, [ + input.cliPath, + "--cwd", + input.cwd, + "--mode", + "plan", + "--json", + "--no-browser", + "--prompt", + input.prompt + ], { + env: buildZCodeRuntimeEnv({ + baseEnv: process.env, + providerEnv: zcodeEnv, + retryMaxRetries: input.retryMaxRetries ?? 0 + }), + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + timeout: input.timeoutMs ?? 180_000 + }) + ); - const stderr = result.stderr.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]"); + const stdout = redactSecrets(result.stdout.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]")); + const stderr = redactSecrets(result.stderr.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]")); if (input.evidenceDir) { mkdirSync(input.evidenceDir, { recursive: true }); - writeFileSync(join(input.evidenceDir, "zcode-last-stdout.jsonl"), result.stdout); + writeFileSync(join(input.evidenceDir, "zcode-last-stdout.jsonl"), stdout); writeFileSync(join(input.evidenceDir, "zcode-last-stderr.txt"), stderr); } @@ -98,7 +102,7 @@ export function runZCodeReview(input: { if (result.error) { throw new Error(`ZCode failed before completion: ${result.error.message}`); } - throw new Error(`ZCode failed with status ${result.status}: ${stderr || result.stdout.slice(0, 1000)}`); + throw new Error(`ZCode failed with status ${result.status}: ${stderr || stdout.slice(0, 1000)}`); } const rawResponse = extractZCodeResponse(result.stdout); @@ -107,6 +111,79 @@ export function runZCodeReview(input: { return { findings, droppedFromSchema: dropped, rawResponse }; } +export function withTemporaryZCodeReviewPolicy(cwd: string, evidenceDir: string | undefined, run: () => T): T { + const configDir = join(cwd, ".zcode"); + const configPath = join(configDir, "config.json"); + const hadConfigDir = existsSync(configDir); + const originalConfig = existsSync(configPath) + ? { contents: readFileSync(configPath, "utf8"), mode: statSync(configPath).mode } + : null; + const policy = buildZCodeReviewPolicy(); + + mkdirSync(configDir, { recursive: true }); + writeFileSync(configPath, `${JSON.stringify(policy, null, 2)}\n`, { mode: 0o600 }); + if (evidenceDir) { + mkdirSync(evidenceDir, { recursive: true }); + writeFileSync(join(evidenceDir, "zcode-review-policy.json"), `${JSON.stringify(policy, null, 2)}\n`); + } + + try { + return run(); + } finally { + if (originalConfig) { + writeFileSync(configPath, originalConfig.contents, { mode: originalConfig.mode }); + } else { + rmSync(configPath, { force: true }); + if (!hadConfigDir) { + try { + rmdirSync(configDir); + } catch { + // Leave a non-empty directory in place; the clean-worktree guard will catch it. + } + } + } + } +} + +function buildZCodeReviewPolicy(): unknown { + return { + permission: { + mode: "build", + allowedTools: ["Read", "Grep", "Glob", "LS"], + disallowedTools: [ + "Bash", + "Shell", + "Edit", + "Write", + "MultiEdit", + "NotebookEdit", + "WebFetch", + "WebSearch", + "Task", + "Agent", + "Workflow", + "SendMessage" + ], + autoApproveHighRisk: false, + allowMediumRiskInAuto: false + }, + features: { + subagent: false, + mcp: false, + memory: false, + skill: false + }, + memory: { + use: false, + write: false, + autoConsolidate: false + }, + toolConcurrency: { + maxConcurrency: 1 + } + }; +} + function truncateToBudget(text: string, maxBytes: number): string { if (maxBytes <= 0) return "[patch omitted: prompt budget exhausted]"; const bytes = Buffer.byteLength(text); @@ -114,7 +191,14 @@ function truncateToBudget(text: string, maxBytes: number): string { return `${text.slice(0, maxBytes)}\n[patch truncated to fit prompt budget]`; } -function extractZCodeResponse(stdout: string): string { +export function extractZCodeResponse(stdout: string): string { + try { + const parsed = JSON.parse(stdout) as { response?: unknown }; + if (typeof parsed.response === "string") return parsed.response; + } catch { + // Fall through to JSONL parsing for older ZCode CLI builds. + } + const candidates = stdout .split(/\n+/) .map((line) => line.trim()) diff --git a/tests/findings.test.ts b/tests/findings.test.ts index eb65c2e7..7e1c6406 100644 --- a/tests/findings.test.ts +++ b/tests/findings.test.ts @@ -21,19 +21,39 @@ describe("finding normalization and review policy", () => { }); it("drops any finding whose title or body contains secret-looking material", () => { + const token = ["ghp", "1234567890abcdefghijklmnopqrstuvwx"].join("_"); const result = normalizeFindingsForReview([ { severity: "P1", path: "a.ts", line: 1, title: "Leaked token", - body: "The raw token ghp_1234567890abcdefghijklmnopqrstuvwx should never be posted.", + body: `The raw token ${token} should never be posted.`, confidence: 0.99 } ]); expect(result.comments).toEqual([]); expect(result.dropped).toEqual([expect.objectContaining({ reason: "secret_detected" })]); + expect(JSON.stringify(result.dropped)).not.toContain(token); + }); + + it("drops findings that repeat hyphenated fixture tokens", () => { + const fixtureToken = ["super", "secret", "token"].join("-"); + const result = normalizeFindingsForReview([ + { + severity: "P1", + path: "scripts/check-public-sensitive-content.js", + line: 64, + title: "Scanner self-trip", + body: `The scanner repeats ${fixtureToken} in its own source.`, + confidence: 0.9 + } + ]); + + expect(result.comments).toEqual([]); + expect(result.dropped).toEqual([expect.objectContaining({ reason: "secret_detected" })]); + expect(JSON.stringify(result.dropped)).not.toContain(fixtureToken); }); it("requests changes only for P0 or P1 findings", () => { diff --git a/tests/git-clean.test.ts b/tests/git-clean.test.ts new file mode 100644 index 00000000..d67af242 --- /dev/null +++ b/tests/git-clean.test.ts @@ -0,0 +1,24 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { assertGitClean } from "../src/git.js"; + +describe("assertGitClean", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("fails on untracked files left by a review run", () => { + const root = mkdtempSync(join(tmpdir(), "git-clean-")); + roots.push(root); + const init = spawnSync("git", ["init"], { cwd: root, encoding: "utf8" }); + expect(init.status).toBe(0); + writeFileSync(join(root, "left-behind.txt"), "mutation\n"); + + expect(() => assertGitClean(root)).toThrow(/untracked or modified files/); + }); +}); diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts new file mode 100644 index 00000000..a79a1b0e --- /dev/null +++ b/tests/secrets.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { containsSecretLikeText, redactSecrets } from "../src/secrets.js"; + +describe("secret redaction", () => { + it("detects and redacts hyphenated fixture tokens", () => { + const fixtureToken = ["super", "secret", "token"].join("-"); + const text = `fixture contains ${fixtureToken} in source`; + + expect(containsSecretLikeText(text)).toBe(true); + expect(redactSecrets(text)).toBe("fixture contains [redacted-secret] in source"); + }); + + it("redacts raw email addresses from evidence and comments", () => { + const text = "Use person@example.com only via env."; + + expect(containsSecretLikeText(text)).toBe(true); + expect(redactSecrets(text)).toBe("Use [redacted-secret] only via env."); + }); +}); diff --git a/tests/zcode-env.test.ts b/tests/zcode-env.test.ts index b87d872c..599efc7e 100644 --- a/tests/zcode-env.test.ts +++ b/tests/zcode-env.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveZCodeProviderEnv } from "../src/zcode-env.js"; +import { buildZCodeRuntimeEnv, resolveZCodeProviderEnv } from "../src/zcode-env.js"; describe("ZCode provider env", () => { const roots: string[] = []; @@ -41,4 +41,30 @@ describe("ZCode provider env", () => { expect(env.ZCODE_API_KEY).toBe("zai-secret-key"); expect(JSON.stringify(env.redacted)).not.toContain("zai-secret-key"); }); + + it("sets retry env to prevent provider rate-limit retry storms", () => { + const env = buildZCodeRuntimeEnv({ + baseEnv: { KEEP_ME: "yes" }, + providerEnv: { + ZCODE_MODEL: "builtin:zai-coding-plan/GLM-5.2", + ZCODE_BASE_URL: "https://api.z.ai/api/anthropic", + ZCODE_API_KEY: "secret", + redacted: { + providerId: "builtin:zai-coding-plan", + model: "GLM-5.2", + baseURL: "https://api.z.ai/api/anthropic", + apiKey: "[redacted len=6]" + } + }, + retryMaxRetries: 0 + }); + + expect(env).toMatchObject({ + KEEP_ME: "yes", + ZCODE_MODEL: "builtin:zai-coding-plan/GLM-5.2", + ZCODE_BASE_URL: "https://api.z.ai/api/anthropic", + ZCODE_API_KEY: "secret", + ZCODE_MODEL_RETRY_MAX_RETRIES: "0" + }); + }); }); diff --git a/tests/zcode-output.test.ts b/tests/zcode-output.test.ts new file mode 100644 index 00000000..704fb205 --- /dev/null +++ b/tests/zcode-output.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { extractZCodeResponse } from "../src/zcode.js"; + +describe("ZCode output parsing", () => { + it("accepts pretty JSON emitted by current ZCode CLI", () => { + const stdout = JSON.stringify( + { + sessionId: "sess_123", + response: "```json\n{\"findings\":[]}\n```" + }, + null, + 2 + ); + + expect(extractZCodeResponse(stdout)).toContain("\"findings\":[]"); + }); + + it("keeps JSONL compatibility for older ZCode CLI output", () => { + const stdout = [ + JSON.stringify({ event: "started" }), + JSON.stringify({ response: "{\"findings\":[]}" }) + ].join("\n"); + + expect(extractZCodeResponse(stdout)).toBe("{\"findings\":[]}"); + }); +}); diff --git a/tests/zcode-policy.test.ts b/tests/zcode-policy.test.ts new file mode 100644 index 00000000..18e788a7 --- /dev/null +++ b/tests/zcode-policy.test.ts @@ -0,0 +1,51 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { withTemporaryZCodeReviewPolicy } from "../src/zcode.js"; + +describe("temporary ZCode review policy", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("installs a restrictive project policy only for the ZCode run", () => { + const root = mkdtempSync(join(tmpdir(), "zcode-policy-")); + const evidence = mkdtempSync(join(tmpdir(), "zcode-policy-evidence-")); + roots.push(root, evidence); + + const result = withTemporaryZCodeReviewPolicy(root, evidence, () => { + const policyPath = join(root, ".zcode", "config.json"); + const policy = JSON.parse(readFileSync(policyPath, "utf8")) as { + permission: { allowedTools: string[]; disallowedTools: string[] }; + features: { subagent: boolean }; + }; + + expect(policy.permission.allowedTools).toEqual(["Read", "Grep", "Glob", "LS"]); + expect(policy.permission.disallowedTools).toContain("Bash"); + expect(policy.features.subagent).toBe(false); + return "reviewed"; + }); + + expect(result).toBe("reviewed"); + expect(existsSync(join(root, ".zcode", "config.json"))).toBe(false); + expect(readFileSync(join(evidence, "zcode-review-policy.json"), "utf8")).toContain("\"Bash\""); + }); + + it("restores an existing repo ZCode config after the run", () => { + const root = mkdtempSync(join(tmpdir(), "zcode-policy-existing-")); + roots.push(root); + const configDir = join(root, ".zcode"); + const configPath = join(configDir, "config.json"); + mkdirSync(configDir); + writeFileSync(configPath, "{\"features\":{\"subagent\":true}}\n"); + + withTemporaryZCodeReviewPolicy(root, undefined, () => { + expect(readFileSync(configPath, "utf8")).toContain("\"Bash\""); + }); + + expect(readFileSync(configPath, "utf8")).toBe("{\"features\":{\"subagent\":true}}\n"); + }); +}); From 03ac7d8dab332d88654563bedd4d445e9a02763e Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 00:41:35 +0700 Subject: [PATCH 3/8] chore: harden live review bot daemon --- README.md | 6 +- config.example.json | 3 +- docs/launchd.md | 15 +-- launchd/evaos-code-review-bot.plist.example | 2 + src/cli.ts | 51 +++++++++- src/config.ts | 2 + src/daemon-log.ts | 29 ++++++ src/github.ts | 16 ++- src/worker.ts | 62 ++++++++++-- src/zcode.ts | 106 +++++++++++++------- tests/canary.test.ts | 26 +++++ tests/daemon-log.test.ts | 44 ++++++++ tests/github-app-read.test.ts | 55 ++++++++++ 13 files changed, 357 insertions(+), 60 deletions(-) create mode 100644 src/daemon-log.ts create mode 100644 tests/canary.test.ts create mode 100644 tests/daemon-log.test.ts create mode 100644 tests/github-app-read.test.ts diff --git a/README.md b/README.md index 983252d6..549e48dd 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Pilot local worker for ZCode/GLM-5.2 pull request reviews. ```bash npm run doctor -npm run run-once -- --dry-run true --repo electricsheephq/WorldOS --pr 1205 -npm run daemon -- --dry-run true +npm run run-once -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run true --repo electricsheephq/WorldOS --pr 1161 +npm run daemon -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run true ``` Posting reviews requires a GitHub App installed on the pilot repos: @@ -30,7 +30,7 @@ Posting reviews requires a GitHub App installed on the pilot repos: ```bash export EVAOS_REVIEW_BOT_APP_ID=... export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/path/to/private-key.pem -npm run run-once -- --dry-run false +npm run run-once -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-live.json --dry-run false ``` The worker derives transient ZCode CLI model environment from the existing ZCode app config at `/Volumes/LEXAR/zcode/.zcode/v2/config.json`; it does not copy the Z.ai API key into this repository. diff --git a/config.example.json b/config.example.json index 160961f4..eb68c9f7 100644 --- a/config.example.json +++ b/config.example.json @@ -2,6 +2,7 @@ "pilotRepos": ["electricsheephq/WorldOS", "100yenadmin/evaOS-GUI"], "pollIntervalMs": 90000, "skipDrafts": true, + "canaryPulls": ["electricsheephq/WorldOS#1161", "100yenadmin/evaOS-GUI#497"], "workRoot": "/Volumes/LEXAR/repos/evaos-code-review-bot/runtime", "statePath": "/Volumes/LEXAR/Codex/evaos-code-review-bot/state/reviews.sqlite", "evidenceDir": "/Volumes/LEXAR/Codex/evaos-code-review-bot/evidence", @@ -16,6 +17,6 @@ "github": { "appId": "set via EVAOS_REVIEW_BOT_APP_ID", "privateKeyPath": "set via EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH", - "token": "optional read-only dry-run token, preferably GITHUB_TOKEN env" + "token": "optional fallback token for local development only" } } diff --git a/docs/launchd.md b/docs/launchd.md index 0c1270ee..28ae5147 100644 --- a/docs/launchd.md +++ b/docs/launchd.md @@ -6,15 +6,17 @@ Recommended first live command: ```bash cd /Volumes/LEXAR/repos/evaos-code-review-bot -GITHUB_TOKEN="$(gh auth token)" npm run run-once -- --dry-run true --repo electricsheephq/WorldOS --pr 1205 +export EVAOS_REVIEW_BOT_APP_ID=4184532 +export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/Volumes/LEXAR/Codex/evaos-code-review-bot/secrets/evaos-code-review-bot.private-key.pem +npm run run-once -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run true --repo electricsheephq/WorldOS --pr 1161 ``` -After the GitHub App is installed, use app credentials and keep `--dry-run true` for the first 24-hour observation window: +After the GitHub App is installed, use app credentials and keep `--dry-run true` for the first observation window: ```bash -export EVAOS_REVIEW_BOT_APP_ID=... -export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/absolute/path/to/private-key.pem -npm run daemon -- --dry-run true +export EVAOS_REVIEW_BOT_APP_ID=4184532 +export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/Volumes/LEXAR/Codex/evaos-code-review-bot/secrets/evaos-code-review-bot.private-key.pem +npm run daemon -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run true ``` Only switch to `--dry-run false` after: @@ -23,4 +25,5 @@ Only switch to `--dry-run false` after: - review-plan JSON contains only valid current-diff lines, - no secret-like text appears in comments or logs, - ZCode worktrees stay clean after runs, -- GLM/Z.ai rate limits are not firing. +- GLM/Z.ai rate limits are not firing, +- `npm run doctor` reports `readMode: "app_installation"` and successful read checks for every pilot repo. diff --git a/launchd/evaos-code-review-bot.plist.example b/launchd/evaos-code-review-bot.plist.example index 863c0f13..bb20a188 100644 --- a/launchd/evaos-code-review-bot.plist.example +++ b/launchd/evaos-code-review-bot.plist.example @@ -13,6 +13,8 @@ run daemon -- + --config + /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run true diff --git a/src/cli.ts b/src/cli.ts index cad3feb3..3576e6e6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { loadConfig } from "./config.js"; +import { formatDaemonLog } from "./daemon-log.js"; import { GitHubApi } from "./github.js"; import { runOnce } from "./worker.js"; import { resolveZCodeProviderEnv } from "./zcode-env.js"; @@ -15,17 +16,34 @@ async function main(): Promise { providerId: config.zcode.providerId }); const github = new GitHubApi(config.github); + const readChecks = []; + for (const repo of config.pilotRepos) { + try { + await github.listOpenPulls(repo); + readChecks.push({ repo, ok: true }); + } catch (error) { + readChecks.push({ + repo, + ok: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } console.log(JSON.stringify({ - ok: true, + ok: readChecks.every((check) => check.ok), pilotRepos: config.pilotRepos, + canaryPulls: config.canaryPulls ?? [], statePath: config.statePath, workRoot: config.workRoot, zcode: zcode.redacted, github: { canPostAsApp: github.canPostAsApp(), - hasReadToken: Boolean(config.github.token) + readMode: github.canPostAsApp() ? "app_installation" : "fallback_token", + hasFallbackReadToken: Boolean(config.github.token), + readChecks } }, null, 2)); + if (readChecks.some((check) => !check.ok)) process.exitCode = 1; return; } @@ -42,8 +60,35 @@ async function main(): Promise { if (command === "daemon") { const config = loadConfig(args.config); + let cycle = 0; for (;;) { - await runOnce({ configPath: args.config, dryRun: args["dry-run"] !== "false" }); + cycle += 1; + const dryRun = args["dry-run"] !== "false"; + console.log(formatDaemonLog({ + event: "daemon_cycle_start", + cycle, + dryRun, + pilotRepos: config.pilotRepos, + canaryPulls: config.canaryPulls ?? [] + })); + try { + const result = await runOnce({ configPath: args.config, dryRun }); + console.log(formatDaemonLog({ + event: "daemon_cycle_complete", + cycle, + dryRun, + result + })); + } catch (error) { + console.error(formatDaemonLog({ + event: "daemon_cycle_failed", + level: "error", + cycle, + dryRun, + error: error instanceof Error ? error.message : String(error) + })); + throw error; + } await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs)); } } diff --git a/src/config.ts b/src/config.ts index fdbda105..c81b187c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ export interface BotConfig { workRoot: string; statePath: string; evidenceDir: string; + canaryPulls?: string[]; zcode: { cliPath: string; appConfigPath: string; @@ -30,6 +31,7 @@ const DEFAULT_CONFIG: BotConfig = { workRoot: "/Volumes/LEXAR/repos/evaos-code-review-bot/runtime", statePath: "/Volumes/LEXAR/Codex/evaos-code-review-bot/state/reviews.sqlite", evidenceDir: "/Volumes/LEXAR/Codex/evaos-code-review-bot/evidence", + canaryPulls: undefined, zcode: { cliPath: "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", appConfigPath: "/Volumes/LEXAR/zcode/.zcode/v2/config.json", diff --git a/src/daemon-log.ts b/src/daemon-log.ts new file mode 100644 index 00000000..f946802e --- /dev/null +++ b/src/daemon-log.ts @@ -0,0 +1,29 @@ +import { redactSecrets } from "./secrets.js"; + +export interface DaemonLogEvent { + event: string; + level?: "info" | "error"; + [key: string]: unknown; +} + +export function formatDaemonLog(input: DaemonLogEvent, now = new Date()): string { + const { level = "info", ...rest } = input; + return JSON.stringify({ + ts: now.toISOString(), + level, + ...redactRecord(rest) + }); +} + +function redactRecord(value: Record): Record { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactObject(entry)])); +} + +function redactObject(value: unknown): unknown { + if (typeof value === "string") return redactSecrets(value); + if (Array.isArray(value)) return value.map((entry) => redactObject(entry)); + if (value && typeof value === "object") { + return redactRecord(value as Record); + } + return value; +} diff --git a/src/github.ts b/src/github.ts index 1345482e..ff96f434 100644 --- a/src/github.ts +++ b/src/github.ts @@ -28,18 +28,23 @@ export class GitHubApi { } async listOpenPulls(repo: string): Promise { - return this.request(`/repos/${repo}/pulls?state=open&per_page=100`); + return this.request(`/repos/${repo}/pulls?state=open&per_page=100`, { + token: await this.getReadToken(repo) + }); } async getPull(repo: string, pullNumber: number): Promise { - return this.request(`/repos/${repo}/pulls/${pullNumber}`); + return this.request(`/repos/${repo}/pulls/${pullNumber}`, { + token: await this.getReadToken(repo) + }); } async listPullFiles(repo: string, pullNumber: number): Promise { const files: PullFilePatch[] = []; for (let page = 1; ; page += 1) { const chunk = await this.request( - `/repos/${repo}/pulls/${pullNumber}/files?per_page=100&page=${page}` + `/repos/${repo}/pulls/${pullNumber}/files?per_page=100&page=${page}`, + { token: await this.getReadToken(repo) } ); files.push(...chunk); if (chunk.length < 100) return files; @@ -92,6 +97,11 @@ export class GitHubApi { return token.token; } + private async getReadToken(repo: string): Promise { + if (this.canPostAsApp()) return this.getInstallationToken(repo); + return this.token; + } + private async request( path: string, options: { method?: string; token?: string; body?: unknown } = {} diff --git a/src/worker.ts b/src/worker.ts index 8817ed70..55745d61 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -18,20 +18,54 @@ export interface RunOnceOptions { useZCode?: boolean; } -export async function runOnce(options: RunOnceOptions): Promise { +export interface RunOnceResult { + reposScanned: number; + pullsSeen: number; + reviewed: number; + skippedDraft: number; + skippedCanary: number; + skippedProcessed: number; +} + +type ReviewPullResult = "reviewed" | "skipped_draft" | "skipped_canary" | "skipped_processed"; + +export async function runOnce(options: RunOnceOptions): Promise { const config = loadConfig(options.configPath); const github = new GitHubApi(config.github); const state = new ReviewStateStore(config.statePath); + const result: RunOnceResult = { + reposScanned: 0, + pullsSeen: 0, + reviewed: 0, + skippedDraft: 0, + skippedCanary: 0, + skippedProcessed: 0 + }; try { const repos = options.repo ? [options.repo] : config.pilotRepos; for (const repo of repos) { + result.reposScanned += 1; const pulls = options.pullNumber ? [await github.getPull(repo, options.pullNumber)] : await github.listOpenPulls(repo); + result.pullsSeen += pulls.length; for (const pull of pulls) { - await reviewPull({ config, github, state, repo, pull, dryRun: options.dryRun, useZCode: options.useZCode ?? true }); + const status = await reviewPull({ + config, + github, + state, + repo, + pull, + dryRun: options.dryRun, + useZCode: options.useZCode ?? true + }); + if (status === "reviewed") result.reviewed += 1; + if (status === "skipped_draft") result.skippedDraft += 1; + if (status === "skipped_canary") result.skippedCanary += 1; + if (status === "skipped_processed") result.skippedProcessed += 1; } } + return result; } finally { state.close(); } @@ -45,12 +79,13 @@ export async function reviewPull(input: { pull: PullRequestSummary; dryRun: boolean; useZCode: boolean; -}): Promise { +}): Promise { const { config, github, state, repo, pull } = input; - if (config.skipDrafts && pull.draft) return; - if (state.hasProcessed(repo, pull.number, pull.head.sha)) return; + if (config.skipDrafts && pull.draft) return "skipped_draft"; + if (!isCanaryAllowed(config, repo, pull.number)) return "skipped_canary"; + if (state.hasProcessed(repo, pull.number, pull.head.sha)) return "skipped_processed"; - const evidenceDir = join(config.evidenceDir, new Date().toISOString().slice(0, 10), repo.replace("/", "__"), `pr-${pull.number}`, pull.head.sha); + const evidenceDir = join(config.evidenceDir, localDateFolder(), repo.replace("/", "__"), `pr-${pull.number}`, pull.head.sha); mkdirSync(evidenceDir, { recursive: true }); const files = await github.listPullFiles(repo, pull.number); @@ -96,7 +131,7 @@ export async function reviewPull(input: { if (input.dryRun) { state.recordProcessed({ repo, pullNumber: pull.number, headSha: pull.head.sha, status: "dry_run", event }); - return; + return "reviewed"; } const review = await github.createReview({ @@ -114,6 +149,19 @@ export async function reviewPull(input: { event, reviewUrl: review.html_url }); + return "reviewed"; +} + +export function isCanaryAllowed(config: Pick, repo: string, pullNumber: number): boolean { + if (!config.canaryPulls || config.canaryPulls.length === 0) return true; + return new Set(config.canaryPulls).has(`${repo}#${pullNumber}`); +} + +export function localDateFolder(now = new Date()): string { + const year = String(now.getFullYear()).padStart(4, "0"); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; } function sanitizeDroppedFindings(dropped: ReviewPlan["dropped"]): ReviewPlan["dropped"] { diff --git a/src/zcode.ts b/src/zcode.ts index ea8dc862..90886f38 100644 --- a/src/zcode.ts +++ b/src/zcode.ts @@ -67,48 +67,80 @@ export function runZCodeReview(input: { providerId: input.providerId }); - const result = withTemporaryZCodeReviewPolicy(input.cwd, input.evidenceDir, () => - spawnSync(process.execPath, [ - input.cliPath, - "--cwd", - input.cwd, - "--mode", - "plan", - "--json", - "--no-browser", - "--prompt", - input.prompt - ], { - env: buildZCodeRuntimeEnv({ - baseEnv: process.env, - providerEnv: zcodeEnv, - retryMaxRetries: input.retryMaxRetries ?? 0 - }), - encoding: "utf8", - maxBuffer: 20 * 1024 * 1024, - timeout: input.timeoutMs ?? 180_000 - }) - ); + const prompts = [ + input.prompt, + buildStrictJsonRetryPrompt(input.prompt) + ]; + let lastParseError: unknown; - const stdout = redactSecrets(result.stdout.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]")); - const stderr = redactSecrets(result.stderr.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]")); - if (input.evidenceDir) { - mkdirSync(input.evidenceDir, { recursive: true }); - writeFileSync(join(input.evidenceDir, "zcode-last-stdout.jsonl"), stdout); - writeFileSync(join(input.evidenceDir, "zcode-last-stderr.txt"), stderr); - } + for (let attempt = 1; attempt <= prompts.length; attempt += 1) { + const result = withTemporaryZCodeReviewPolicy(input.cwd, input.evidenceDir, () => + spawnSync(process.execPath, [ + input.cliPath, + "--cwd", + input.cwd, + "--mode", + "plan", + "--json", + "--no-browser", + "--prompt", + prompts[attempt - 1]! + ], { + env: buildZCodeRuntimeEnv({ + baseEnv: process.env, + providerEnv: zcodeEnv, + retryMaxRetries: input.retryMaxRetries ?? 0 + }), + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + timeout: input.timeoutMs ?? 180_000 + }) + ); + + const stdout = redactSecrets(result.stdout.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]")); + const stderr = redactSecrets(result.stderr.replaceAll(zcodeEnv.ZCODE_API_KEY, "[redacted-secret]")); + if (input.evidenceDir) { + mkdirSync(input.evidenceDir, { recursive: true }); + writeFileSync(join(input.evidenceDir, `zcode-attempt-${attempt}-stdout.jsonl`), stdout); + writeFileSync(join(input.evidenceDir, `zcode-attempt-${attempt}-stderr.txt`), stderr); + writeFileSync(join(input.evidenceDir, "zcode-last-stdout.jsonl"), stdout); + writeFileSync(join(input.evidenceDir, "zcode-last-stderr.txt"), stderr); + } + + if (result.status !== 0) { + if (result.error) { + throw new Error(`ZCode failed before completion: ${result.error.message}`); + } + throw new Error(`ZCode failed with status ${result.status}: ${stderr || stdout.slice(0, 1000)}`); + } - if (result.status !== 0) { - if (result.error) { - throw new Error(`ZCode failed before completion: ${result.error.message}`); + try { + const rawResponse = extractZCodeResponse(result.stdout); + const parsed = JSON.parse(extractJsonObject(rawResponse)); + const { findings, dropped } = parseFindings(parsed); + return { findings, droppedFromSchema: dropped, rawResponse }; + } catch (error) { + lastParseError = error; } - throw new Error(`ZCode failed with status ${result.status}: ${stderr || stdout.slice(0, 1000)}`); } - const rawResponse = extractZCodeResponse(result.stdout); - const parsed = JSON.parse(extractJsonObject(rawResponse)); - const { findings, dropped } = parseFindings(parsed); - return { findings, droppedFromSchema: dropped, rawResponse }; + throw new Error( + `ZCode response did not contain a parseable JSON review after ${prompts.length} attempts: ${ + lastParseError instanceof Error ? lastParseError.message : String(lastParseError) + }` + ); +} + +function buildStrictJsonRetryPrompt(originalPrompt: string): string { + return [ + "Your previous review output was rejected because it was not valid JSON.", + "Repeat the review and return ONLY the required JSON object. Do not include markdown, prose, analysis, confidence narration, or code fences.", + "The response must parse with JSON.parse and must have this exact top-level shape:", + "{\"findings\":[{\"severity\":\"P0|P1|P2|P3\",\"path\":\"relative/file\",\"line\":123,\"title\":\"short title\",\"body\":\"specific actionable explanation\",\"confidence\":0.0,\"why_this_matters\":\"optional\"}],\"summary\":\"short review summary\"}", + "If you cannot produce a finding with a current RIGHT-side diff line, return {\"findings\":[],\"summary\":\"No validated current-diff findings.\"}.", + "", + originalPrompt + ].join("\n"); } export function withTemporaryZCodeReviewPolicy(cwd: string, evidenceDir: string | undefined, run: () => T): T { diff --git a/tests/canary.test.ts b/tests/canary.test.ts new file mode 100644 index 00000000..eaa811b4 --- /dev/null +++ b/tests/canary.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isCanaryAllowed, localDateFolder } from "../src/worker.js"; + +describe("canary PR allowlist", () => { + it("allows all PRs when no canary list is configured", () => { + expect(isCanaryAllowed({}, "electricsheephq/WorldOS", 1161)).toBe(true); + expect(isCanaryAllowed({ canaryPulls: [] }, "100yenadmin/evaOS-GUI", 497)).toBe(true); + }); + + it("allows only exact repo and pull-number matches when configured", () => { + const config = { + canaryPulls: ["electricsheephq/WorldOS#1161", "100yenadmin/evaOS-GUI#497"] + }; + + expect(isCanaryAllowed(config, "electricsheephq/WorldOS", 1161)).toBe(true); + expect(isCanaryAllowed(config, "100yenadmin/evaOS-GUI", 497)).toBe(true); + expect(isCanaryAllowed(config, "electricsheephq/WorldOS", 1185)).toBe(false); + expect(isCanaryAllowed(config, "100yenadmin/evaOS-GUI", 410)).toBe(false); + }); +}); + +describe("local evidence date folders", () => { + it("uses the process local date instead of UTC ISO date folders", () => { + expect(localDateFolder(new Date(2026, 6, 1, 0, 5, 0))).toBe("2026-07-01"); + }); +}); diff --git a/tests/daemon-log.test.ts b/tests/daemon-log.test.ts new file mode 100644 index 00000000..8a3b59a0 --- /dev/null +++ b/tests/daemon-log.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { formatDaemonLog } from "../src/daemon-log.js"; + +describe("daemon heartbeat logs", () => { + it("emits structured JSON with cycle and result counters", () => { + const log = JSON.parse(formatDaemonLog({ + event: "daemon_cycle_complete", + cycle: 2, + dryRun: true, + result: { + reposScanned: 2, + pullsSeen: 4, + reviewed: 0, + skippedDraft: 1, + skippedCanary: 2, + skippedProcessed: 1 + } + }, new Date("2026-07-01T00:00:00.000Z"))); + + expect(log).toMatchObject({ + ts: "2026-07-01T00:00:00.000Z", + level: "info", + event: "daemon_cycle_complete", + cycle: 2, + dryRun: true, + result: { + reposScanned: 2, + reviewed: 0, + skippedProcessed: 1 + } + }); + }); + + it("redacts secret-looking strings before they reach launchd logs", () => { + const log = formatDaemonLog({ + event: "daemon_cycle_failed", + level: "error", + error: "request failed with ghp_1234567890abcdefghijklmnopqrstuvwx" + }, new Date("2026-07-01T00:00:00.000Z")); + + expect(log).toContain("[redacted-secret]"); + expect(log).not.toContain("ghp_1234567890abcdefghijklmnopqrstuvwx"); + }); +}); diff --git a/tests/github-app-read.test.ts b/tests/github-app-read.test.ts new file mode 100644 index 00000000..e38e6850 --- /dev/null +++ b/tests/github-app-read.test.ts @@ -0,0 +1,55 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GitHubApi } from "../src/github.js"; + +describe("GitHub App read authentication", () => { + const roots: string[] = []; + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("uses installation tokens for PR read calls when App credentials are configured", async () => { + const root = mkdtempSync(join(tmpdir(), "github-app-read-")); + roots.push(root); + const privateKeyPath = join(root, "app.pem"); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + writeFileSync(privateKeyPath, privateKey.export({ type: "pkcs1", format: "pem" })); + + const calls: Array<{ url: string; authorization?: string }> = []; + globalThis.fetch = vi.fn(async (url, init) => { + const authorization = new Headers(init?.headers).get("authorization") ?? undefined; + calls.push({ url: String(url), authorization }); + if (String(url).endsWith("/repos/owner/repo/installation")) { + return jsonResponse({ id: 123 }); + } + if (String(url).endsWith("/app/installations/123/access_tokens")) { + return jsonResponse({ token: "installation-token", expires_at: "2999-01-01T00:00:00Z" }); + } + if (String(url).endsWith("/repos/owner/repo/pulls?state=open&per_page=100")) { + return jsonResponse([]); + } + return jsonResponse({ message: "unexpected" }, 404); + }) as typeof fetch; + + const github = new GitHubApi({ appId: "4184532", privateKeyPath, token: "fallback-token" }); + await github.listOpenPulls("owner/repo"); + + const readCall = calls.find((call) => call.url.endsWith("/repos/owner/repo/pulls?state=open&per_page=100")); + expect(readCall?.authorization).toBe("Bearer installation-token"); + expect(readCall?.authorization).not.toBe("Bearer fallback-token"); + }); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" } + }); +} From 329c70f4f1fdd1de2ecc7e770a0bd85c9ee164ef Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 00:48:01 +0700 Subject: [PATCH 4/8] docs: align launchd pilot template --- docs/launchd.md | 2 ++ launchd/evaos-code-review-bot.plist.example | 20 +++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/launchd.md b/docs/launchd.md index 28ae5147..50b44914 100644 --- a/docs/launchd.md +++ b/docs/launchd.md @@ -19,6 +19,8 @@ export EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH=/Volumes/LEXAR/Codex/evaos-code-review- npm run daemon -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run true ``` +When installed as a LaunchAgent, write stdout/stderr to `~/Library/Logs/evaos-code-review-bot/`. On this Mac, launchd failed with `EX_CONFIG` when those paths pointed directly at the Lexar volume; copy the local launch logs into the Lexar evidence packet after each proof window. + Only switch to `--dry-run false` after: - current-head duplicate reruns post nothing, diff --git a/launchd/evaos-code-review-bot.plist.example b/launchd/evaos-code-review-bot.plist.example index bb20a188..0e68b6ed 100644 --- a/launchd/evaos-code-review-bot.plist.example +++ b/launchd/evaos-code-review-bot.plist.example @@ -9,10 +9,10 @@ /Volumes/LEXAR/repos/evaos-code-review-bot ProgramArguments - /opt/homebrew/bin/npm - run + /opt/homebrew/bin/node + /Volumes/LEXAR/repos/evaos-code-review-bot/node_modules/tsx/dist/cli.mjs + src/cli.ts daemon - -- --config /Volumes/LEXAR/Codex/evaos-code-review-bot/config/canary-dry-run.json --dry-run @@ -20,6 +20,16 @@ EnvironmentVariables + PATH + /opt/homebrew/bin:/opt/homebrew/sbin:/Users/lume/.bun/bin:/Users/lume/gbrain/bin:/Users/lume/.local/bin:/Users/lume/.openclaw/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + /Users/lume + USER + lume + LOGNAME + lume + SHELL + /bin/zsh EVAOS_REVIEW_BOT_APP_ID REPLACE_WITH_APP_ID EVAOS_REVIEW_BOT_PRIVATE_KEY_PATH @@ -30,8 +40,8 @@ KeepAlive StandardOutPath - /Volumes/LEXAR/Codex/evaos-code-review-bot/logs/launchd.out.log + /Users/lume/Library/Logs/evaos-code-review-bot/launchd.out.log StandardErrorPath - /Volumes/LEXAR/Codex/evaos-code-review-bot/logs/launchd.err.log + /Users/lume/Library/Logs/evaos-code-review-bot/launchd.err.log From ff92794dbcdc0d7bd88f5a33ba91dc281e870de0 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 00:56:07 +0700 Subject: [PATCH 5/8] fix: parse final zcode review json --- src/zcode.ts | 32 +++++++++++++++++++++++++------- tests/zcode-output.test.ts | 15 ++++++++++++++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/zcode.ts b/src/zcode.ts index 90886f38..c7d3c284 100644 --- a/src/zcode.ts +++ b/src/zcode.ts @@ -249,11 +249,29 @@ export function extractZCodeResponse(stdout: string): string { return response; } -function extractJsonObject(text: string): string { - const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); - if (fenced) return fenced[1]!.trim(); - const start = text.indexOf("{"); - const end = text.lastIndexOf("}"); - if (start < 0 || end < start) throw new Error("ZCode response did not contain a JSON object."); - return text.slice(start, end + 1); +export function extractJsonObject(text: string): string { + const fencedMatches = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]; + for (const fenced of fencedMatches) { + const candidate = fenced[1]!.trim(); + if (isReviewJsonObject(candidate)) return candidate; + } + + const starts = [...text.matchAll(/\{/g)].map((match) => match.index).filter((index): index is number => index !== undefined); + const ends = [...text.matchAll(/\}/g)].map((match) => match.index).filter((index): index is number => index !== undefined); + for (const start of starts.reverse()) { + for (const end of ends.filter((index) => index > start).reverse()) { + const candidate = text.slice(start, end + 1).trim(); + if (isReviewJsonObject(candidate)) return candidate; + } + } + throw new Error("ZCode response did not contain a parseable JSON review object."); +} + +function isReviewJsonObject(candidate: string): boolean { + try { + const parsed = JSON.parse(candidate) as { findings?: unknown }; + return typeof parsed === "object" && parsed !== null && Array.isArray(parsed.findings); + } catch { + return false; + } } diff --git a/tests/zcode-output.test.ts b/tests/zcode-output.test.ts index 704fb205..fc3a6582 100644 --- a/tests/zcode-output.test.ts +++ b/tests/zcode-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { extractZCodeResponse } from "../src/zcode.js"; +import { extractJsonObject, extractZCodeResponse } from "../src/zcode.js"; describe("ZCode output parsing", () => { it("accepts pretty JSON emitted by current ZCode CLI", () => { @@ -23,4 +23,17 @@ describe("ZCode output parsing", () => { expect(extractZCodeResponse(stdout)).toBe("{\"findings\":[]}"); }); + + it("extracts the final review JSON when ZCode adds prose with earlier braces", () => { + const response = [ + "I checked a callback like `confirmDrop(ctxMenu.item, () => postInvMove(...))` before finalizing.", + "Here is the result:", + "{\"findings\":[],\"summary\":\"No validated current-diff findings.\"}" + ].join("\n\n"); + + expect(JSON.parse(extractJsonObject(response))).toEqual({ + findings: [], + summary: "No validated current-diff findings." + }); + }); }); From 51a980f07f00d48d8cb97eab981dd4d9a8fd88de Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 01:00:44 +0700 Subject: [PATCH 6/8] chore: include github fetch path in errors --- src/github.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/github.ts b/src/github.ts index ff96f434..69b328e4 100644 --- a/src/github.ts +++ b/src/github.ts @@ -107,16 +107,21 @@ export class GitHubApi { options: { method?: string; token?: string; body?: unknown } = {} ): Promise { const token = options.token ?? this.token; - const response = await fetch(`${this.apiBaseUrl}${path}`, { - method: options.method ?? "GET", - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(options.body === undefined ? {} : { "Content-Type": "application/json" }) - }, - body: options.body === undefined ? undefined : JSON.stringify(options.body) - }); + let response: Response; + try { + response = await fetch(`${this.apiBaseUrl}${path}`, { + method: options.method ?? "GET", + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }) + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body) + }); + } catch (error) { + throw new Error(`GitHub API fetch failed for ${path}: ${error instanceof Error ? error.message : String(error)}`); + } if (!response.ok) { const text = await response.text(); From fa9932147934152f90acf0face3dd07bcb959bc8 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 01:05:56 +0700 Subject: [PATCH 7/8] chore: include github fetch failure cause --- src/github.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/github.ts b/src/github.ts index 69b328e4..6ed3502b 100644 --- a/src/github.ts +++ b/src/github.ts @@ -120,7 +120,7 @@ export class GitHubApi { body: options.body === undefined ? undefined : JSON.stringify(options.body) }); } catch (error) { - throw new Error(`GitHub API fetch failed for ${path}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`GitHub API fetch failed for ${path}: ${describeFetchError(error)}`); } if (!response.ok) { @@ -132,6 +132,13 @@ export class GitHubApi { } } +function describeFetchError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const cause = "cause" in error ? (error as Error & { cause?: unknown }).cause : undefined; + const causeMessage = cause instanceof Error ? `${cause.name}: ${cause.message}` : cause ? String(cause) : ""; + return causeMessage ? `${error.message}; cause=${causeMessage}` : error.message; +} + export function createAppJwt(appId: string, privateKey: string, nowSeconds = Math.floor(Date.now() / 1000)): string { const header = base64UrlJson({ alg: "RS256", typ: "JWT" }); const payload = base64UrlJson({ From b01f16db01043603e2ae39bfa497240042db8c08 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 1 Jul 2026 01:06:55 +0700 Subject: [PATCH 8/8] fix: post reviews with fresh app client --- src/worker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/worker.ts b/src/worker.ts index 55745d61..461d80d7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -134,7 +134,8 @@ export async function reviewPull(input: { return "reviewed"; } - const review = await github.createReview({ + const reviewGithub = new GitHubApi(config.github); + const review = await reviewGithub.createReview({ repo, pullNumber: pull.number, event,