
Kodevu
- 361 installs
- Updated April 4, 2026
- gyteng/kodevu
Kodevu is an agent skill that fetches Git or SVN diffs and produces AI-generated code review reports via the kodevu CLI.
About
Kodevu is a Node.js CLI skill that teaches your coding agent how to pull Git or SVN diffs, send them to a supported AI reviewer CLI, and save configurable review reports. Solo builders use it when they want commit-level or pre-commit feedback without building a bespoke review bot or pasting large diffs into chat. Typical flows include reviewing the latest commit on the current repo, scanning the last few commits before a release, or checking uncommitted changes before opening a PR. The default reviewer mode auto-detects available tools in PATH; you can pin OpenAI, Gemini, Codex, or Copilot and pass model and API options. Output defaults to Markdown in the kodevu home directory but can be switched to JSON for CI or dashboards. Extra reviewer instructions go through --prompt so you can emphasize security, style, or architecture in one command.
- Reviews latest commit, a hash, last N commits, or uncommitted working tree via npx kodevu
- Routes diffs to AI backends: auto, openai, gemini, codex, or copilot with optional API keys and models
- Writes Markdown or JSON reports under ~/.kodevu or a custom --output directory
- Optional ~/.kodevu/config.json for settings that persist across sessions
- Custom --prompt to steer reviewers (e.g. security-focused passes)
Kodevu by the numbers
- 361 all-time installs (skills.sh)
- Ranked #267 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gyteng/kodevu --skill kodevuAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 361 |
|---|---|
| Security audit | 1 / 3 scanners passed |
| Last updated | April 4, 2026 |
| Repository | gyteng/kodevu ↗ |
What it does
Run AI-powered reviews on Git or SVN diffs from the terminal without wiring a custom review pipeline.
Who is it for?
Best when you already use git or svn locally and want one npx command to batch-review commits with your preferred AI CLI.
Skip if: Skip if you need in-IDE inline review only, or repos where diffs must never leave the machine without a self-hosted reviewer you configure separately.
When should I use this skill?
When you need to fetch Git/SVN diffs and generate AI review reports with npx kodevu.
What you get
You get Markdown or JSON review artifacts for chosen revisions so you can fix issues or attach reports to PR discussion.
- Markdown or JSON review report files
- Configurable output directory and optional persistent ~/.kodevu/config.json
By the numbers
- 5 reviewer backends: auto, openai, gemini, codex, copilot
Files
Kodevu Skill
Kodevu is a Node.js tool that fetches Git commits or SVN revisions, sends the diff to a supported AI reviewer CLI, and writes review results to report files. It supports an optional persistent config file at ~/.kodevu/config.json for settings that should survive across sessions.
Usage
Use npx kodevu to review a codebase.
Reviewing the latest commit
npx kodevu .Reviewing a specific commit
npx kodevu . --rev <commit-hash>Reviewing the last N commits
npx kodevu . --last 3Reviewing uncommitted changes
npx kodevu . --uncommittedSupported Reviewers
kodevu supports several AI reviewer backends: auto, openai, gemini, codex, copilot. The default is auto, which probes available CLI tools in your PATH.
Example using OpenAI:
npx kodevu . --reviewer openai --openai-api-key <YOUR_API_KEY> --openai-model gpt-5-miniGenerating JSON Reports
By default, review reports are generated as Markdown files in ~/.kodevu/. You can specify --format json or change the output directory using --output <dir>.
npx kodevu . --format json --output ./reportsCustom Prompts
You can provide additional instructions to the reviewer using --prompt:
npx kodevu . --prompt "Focus on security issues and suggest optimizations."Or from a file: --prompt @my-rules.txt
Environment Variables
All options can also be set via environment variables to avoid repetitive flags:
KODEVU_REVIEWER– Default reviewer.KODEVU_LANG– Default output language.KODEVU_OUTPUT_DIR– Default output directory.KODEVU_PROMPT– Default prompt instructions.KODEVU_OPENAI_API_KEY– API key foropenai.KODEVU_OPENAI_BASE_URL– Base URL foropenai.KODEVU_OPENAI_MODEL– Model foropenai.
Configuration File
For persistent settings that survive across shells and AI tool invocations, create ~/.kodevu/config.json:
{
"reviewer": "openai",
"openaiApiKey": "sk-...",
"openaiBaseUrl": "https://your-gateway.example.com/v1",
"openaiModel": "gpt-4o",
"lang": "zh"
}The file is optional and silently ignored if absent. Priority: CLI flags > ENV vars > config file > defaults.
Working with Target Repositories
- Git:
targetmust be a local repository or subdirectory. - SVN:
targetcan be a working copy path or repository URL.
npx kodevu /path/to/project --last 1{
"env": {
"node": true,
"es2023": true
},
"extends": ["eslint:recommended"],
"parserOptions": {
"ecmaVersion": 2023,
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["warn"],
"no-console": "off"
}
}
name: Publish to npm
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify package
run: npm run check
- name: Check published version
id: version_check
shell: bash
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
PUBLISHED_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || true)
echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
if [ -z "$PUBLISHED_VERSION" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "published_version=$PUBLISHED_VERSION" >> "$GITHUB_OUTPUT"
if node -e "const next=process.argv[1]; const current=process.argv[2]; const parse=v=>v.split('.').map(Number); const [na=0,nb=0,nc=0]=parse(next); const [ca=0,cb=0,cc=0]=parse(current); process.exit(na>ca || (na===ca && (nb>cb || (nb===cb && nc>cc))) ? 0 : 1);" "$PACKAGE_VERSION" "$PUBLISHED_VERSION"; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "Version unchanged or not greater than npm: $PACKAGE_VERSION <= $PUBLISHED_VERSION"
fi
- name: Publish package
if: steps.version_check.outputs.should_publish == 'true'
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
node_modules/
.vscode
export default [
{
ignores: [
"node_modules/",
"dist/",
"coverage/",
".nyc_output/",
"*.log",
".vscode/"
]
},
{
files: ["**/*.js"],
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
globals: {
node: true
}
},
rules: {
// mirror .eslintrc.json rules
"no-unused-vars": ["warn"],
"no-console": "off"
},
settings: {}
}
];
MIT License
Copyright (c) 2026 gyt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "kodevu",
"version": "0.1.70",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kodevu",
"version": "0.1.70",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6",
"fast-xml-parser": "^5.2.5",
"iconv-lite": "^0.7.2"
},
"bin": {
"kodevu": "src/index.js"
},
"devDependencies": {
"eslint": "^10.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz",
"integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.4",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz",
"integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz",
"integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz",
"integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz",
"integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"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-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.4",
"@eslint/config-helpers": "^0.5.4",
"@eslint/core": "^1.2.0",
"@eslint/plugin-kit": "^0.7.0",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz",
"integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.1",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"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/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
"type-check": "^0.4.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
{
"name": "kodevu",
"version": "0.1.70",
"license": "MIT",
"type": "module",
"description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
"bin": {
"kodevu": "./src/index.js"
},
"files": [
"src",
"README.md",
"SKILL.md"
],
"scripts": {
"start": "node src/index.js",
"check": "node --check src/index.js && node --check src/config.js && node --check src/review-runner.js && node --check src/svn-client.js && node --check src/git-client.js && node --check src/vcs-client.js && node --check src/shell.js && node --check src/progress-ui.js",
"lint": "eslint . --ext .js",
"lint:fix": "eslint . --ext .js --fix"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"cross-spawn": "^7.0.6",
"fast-xml-parser": "^5.2.5",
"iconv-lite": "^0.7.2"
},
"devDependencies": {
"eslint": "^10.1.0"
}
}
Kodevu
The name Kodevu is a phonetic play on "code review".
A Node.js tool that fetches Git commits or SVN revisions, sends the diff to a supported AI reviewer CLI, and writes review results to report files.
Pure & Zero Config
Kodevu is designed to be stateless and requires no mandatory configuration. All settings work out-of-the-box via command-line arguments and environment variables, with an optional persistent config file for convenience.
1. Automatic Detection: Detects repository type (Git/SVN), language, and available reviewers. 2. Stateless: Does not track history; reviews exactly what you ask for. 3. Flexible: Every setting can be set via config file, ENV var, or CLI flag, with CLI taking highest priority.
Quick Start
Get a review of your latest commit in seconds:
npx kodevu .Review reports are saved to ~/.kodevu/ by default. Console output is intentionally concise by default; detailed execution logs are written to ~/.kodevu/logs/.
Install as an AI Agent Skill
Kodevu includes a natively supported SKILL.md file, which allows it to be installed as a specialized skill in AI agent coding assistants.
To install:
npx skills add gyteng/kodevuUsage
npx kodevu [target] [options]Options
target: Repository path (Git) or SVN URL/Working copy (default:.).--reviewer, -r:codex,gemini,copilot,openai,opencodeorauto(default:auto).--rev, -v: A specific revision or commit hash to review.--last, -n: Number of latest revisions to review (default: 1). Use negative values (e.g.,-3) to review only the 3rd commit from the top.--uncommitted, -u: Review current uncommitted changes in the target working tree.--lang, -l: Output language (e.g.,zh,en,auto).--prompt, -p: Additional instructions for the reviewer. Use@file.txtto read from a file.--output, -o: Report output directory (default:~/.kodevu).--format, -f: Output formats (e.g.,markdown,json, ormarkdown,json).--openai-api-key: API key used when--reviewer openai.--openai-base-url: Base URL used when--reviewer openai(default:https://api.openai.com/v1).--openai-model: Model used when--reviewer openai(default:gpt-5-mini).--openai-org: Optional OpenAI organization ID.--openai-project: Optional OpenAI project ID.--debug, -d: Show extra debug information on the console.--version, -V: Print the current version and exit.
[!IMPORTANT]
--revand--lastare mutually exclusive. Specifying both will result in an error.
[!IMPORTANT]
--uncommittedis mutually exclusive with--revand--last.
Environment Variables
You can set these in your shell to change default behavior without typing flags every time:
KODEVU_REVIEWER: Default reviewer.KODEVU_LANG: Default language.KODEVU_OUTPUT_DIR: Default output directory.KODEVU_PROMPT: Default prompt instructions.KODEVU_TIMEOUT: Reviewer execution timeout in milliseconds.KODEVU_OPENAI_API_KEY: API key foropenai.KODEVU_OPENAI_BASE_URL: Base URL foropenai.KODEVU_OPENAI_MODEL: Model foropenai.KODEVU_OPENAI_ORG: Optional organization ID foropenai.KODEVU_OPENAI_PROJECT: Optional project ID foropenai.
Configuration File
For persistent settings that survive across shells and AI tools, create ~/.kodevu/config.json:
{
"reviewer": "openai",
"openaiApiKey": "sk-...",
"openaiBaseUrl": "https://your-gateway.example.com/v1",
"openaiModel": "gpt-4o",
"lang": "zh"
}The file is optional and silently ignored if absent. Priority order (highest wins):
CLI flags > Environment variables > Config file > Built-in defaultsSupported keys: reviewer, lang, outputDir, prompt, commandTimeoutMs, outputFormats, openaiApiKey, openaiBaseUrl, openaiModel, openaiOrganization, openaiProject.
Examples
Selecting Revisions
Review the latest 3 commits:
npx kodevu . --last 3Review only the 3rd latest commit:
npx kodevu . --last -3Review a specific commit hash:
npx kodevu . --rev abc1234Review current uncommitted changes (Git/SVN working copy):
npx kodevu . --uncommittedOptions & Formatting
Review using custom instructions from a file:
npx kodevu . --prompt @my-rules.txtGenerate JSON reports in a local folder:
npx kodevu . --format json --output ./reportsEnvironment Variables
Set a persistent reviewer for your shell session:
export KODEVU_REVIEWER=gemini
npx kodevu .Use the OpenAI API directly with a small set of extra settings:
export KODEVU_REVIEWER=openai
export KODEVU_OPENAI_API_KEY=sk-...
export KODEVU_OPENAI_MODEL=gpt-5-mini
npx kodevu .Use a custom OpenAI-compatible endpoint:
npx kodevu . \
--reviewer openai \
--openai-api-key sk-... \
--openai-base-url https://your-gateway.example.com/v1 \
--openai-model gpt-5-miniLicense
MIT
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { findCommandOnPath } from "./shell.js";
const require = createRequire(import.meta.url);
const { version: packageVersion } = require("../package.json");
const defaultStorageDir = path.join(os.homedir(), ".kodevu");
const SUPPORTED_REVIEWERS = ["codex", "gemini", "copilot", "openai", "opencode"];
const AUTO_SUPPORTED_REVIEWERS = ["codex", "gemini", "copilot", "opencode"];
const defaultConfig = {
reviewer: "auto",
target: "",
lang: "auto",
outputDir: defaultStorageDir,
logsDir: path.join(defaultStorageDir, "logs"),
commandTimeoutMs: 120000,
prompt: "",
maxRevisionsPerRun: 5,
outputFormats: ["markdown"],
rev: "",
last: 0,
uncommitted: false,
openaiApiKey: "",
openaiBaseUrl: "https://api.openai.com/v1",
openaiModel: "gpt-5-mini",
openaiOrganization: "",
openaiProject: ""
};
function mkCliError(message) {
const e = new Error(message);
// exit code 2 = usage / configuration errors
e.exitCode = 2;
return e;
}
const ENV_MAP = {
KODEVU_REVIEWER: "reviewer",
KODEVU_LANG: "lang",
KODEVU_OUTPUT_DIR: "outputDir",
KODEVU_PROMPT: "prompt",
KODEVU_TIMEOUT: "commandTimeoutMs",
KODEVU_MAX_REVISIONS: "maxRevisionsPerRun",
KODEVU_FORMATS: "outputFormats",
KODEVU_OPENAI_API_KEY: "openaiApiKey",
KODEVU_OPENAI_BASE_URL: "openaiBaseUrl",
KODEVU_OPENAI_MODEL: "openaiModel",
KODEVU_OPENAI_ORG: "openaiOrganization",
KODEVU_OPENAI_PROJECT: "openaiProject"
};
const CONFIG_FILE_KEYS = new Set(Object.values(ENV_MAP));
const defaultConfigFilePath = path.join(defaultStorageDir, "config.json");
function resolvePath(value) {
if (!value) return value;
if (value === "~") return os.homedir();
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(os.homedir(), value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
}
async function loadConfigFile(configPath = defaultConfigFilePath) {
const resolvedPath = resolvePath(configPath);
let content;
try {
content = await fs.readFile(resolvedPath, "utf8");
} catch (err) {
if (err.code === "ENOENT") return {};
throw mkCliError(`Failed to read config file ${resolvedPath}: ${err.message}`);
}
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
throw mkCliError(`Invalid JSON in config file ${resolvedPath}: ${err.message}`);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw mkCliError(`Config file ${resolvedPath} must contain a JSON object`);
}
const result = {};
for (const [key, value] of Object.entries(parsed)) {
if (CONFIG_FILE_KEYS.has(key)) {
result[key] = value;
}
}
return result;
}
function normalizeOutputFormats(outputFormats) {
const source = outputFormats == null ? ["markdown"] : outputFormats;
const values = Array.isArray(source) ? source : String(source).split(",");
const normalized = [...new Set(values.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean))];
const supported = ["markdown", "json"];
const invalid = normalized.filter((item) => !supported.includes(item));
if (invalid.length > 0) {
throw mkCliError(`Unsupported output format(s): ${invalid.join(", ")}. Use: ${supported.join(", ")}`);
}
return normalized.length === 0 ? ["markdown"] : normalized;
}
export function detectLanguage() {
const envLang = (process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || "").toLowerCase();
const intlLocale = (() => {
try {
return Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase();
} catch {
return "";
}
})();
const locales = [envLang, intlLocale].filter(l => l && l !== "und");
// 1. Search for Chinese in any source first, to avoid "fake" English defaults in some shells on Windows
for (const loc of locales) {
if (loc.startsWith("zh")) return "zh";
}
// 2. Search for English
for (const loc of locales) {
if (loc.startsWith("en")) return "en";
}
// 3. Fallback to the first part of the first detected locale, or "en"
return locales[0]?.split(/[._-]/)[0] || "en";
}
async function resolveAutoReviewers(debug) {
const availableReviewers = [];
for (const reviewerName of AUTO_SUPPORTED_REVIEWERS) {
const commandPath = await findCommandOnPath(reviewerName, { debug });
if (commandPath) availableReviewers.push({ reviewerName, commandPath });
}
if (availableReviewers.length === 0) {
throw mkCliError(`No reviewer CLI found in PATH. Install one of: ${AUTO_SUPPORTED_REVIEWERS.join(", ")}`);
}
// Shuffle for variety
for (let i = availableReviewers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableReviewers[i], availableReviewers[j]] = [availableReviewers[j], availableReviewers[i]];
}
return availableReviewers;
}
export function parseCliArgs(argv) {
const args = {
target: "",
debug: false,
help: false,
version: false,
reviewer: "",
lang: "",
prompt: "",
rev: "",
last: "",
uncommitted: false,
outputDir: "",
outputFormats: "",
openaiApiKey: "",
openaiBaseUrl: "",
openaiModel: "",
openaiOrganization: "",
openaiProject: ""
};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === "--help" || value === "-h") {
args.help = true;
continue;
}
if (value === "--version" || value === "-V") {
args.version = true;
continue;
}
if (value === "--debug" || value === "-d") {
args.debug = true;
continue;
}
const nextValue = argv[index + 1];
const hasNextValue = nextValue && !nextValue.startsWith("-");
if (value === "--reviewer" || value === "-r") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.reviewer = nextValue;
index += 1;
continue;
}
if (value === "--prompt" || value === "-p") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.prompt = nextValue;
index += 1;
continue;
}
if (value === "--lang" || value === "-l") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.lang = nextValue;
index += 1;
continue;
}
if (value === "--rev" || value === "-v") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.rev = nextValue;
index += 1;
continue;
}
if (value === "--last" || value === "-n") {
const hasLastValue = nextValue !== undefined && /^-?\d+$/.test(nextValue);
if (!hasLastValue) throw mkCliError(`Missing value for ${value}`);
args.last = nextValue;
index += 1;
continue;
}
if (value === "--uncommitted" || value === "-u") {
args.uncommitted = true;
continue;
}
if (value === "--output" || value === "-o") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.outputDir = nextValue;
index += 1;
continue;
}
if (value === "--format" || value === "-f") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.outputFormats = nextValue;
index += 1;
continue;
}
if (value === "--openai-api-key") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.openaiApiKey = nextValue;
index += 1;
continue;
}
if (value === "--openai-base-url") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.openaiBaseUrl = nextValue;
index += 1;
continue;
}
if (value === "--openai-model") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.openaiModel = nextValue;
index += 1;
continue;
}
if (value === "--openai-org") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.openaiOrganization = nextValue;
index += 1;
continue;
}
if (value === "--openai-project") {
if (!hasNextValue) throw mkCliError(`Missing value for ${value}`);
args.openaiProject = nextValue;
index += 1;
continue;
}
if (!value.startsWith("-") && !args.target) {
args.target = value;
continue;
}
throw mkCliError(`Unexpected argument: ${value}`);
}
return args;
}
export async function resolveConfig(cliArgs = {}) {
const config = { ...defaultConfig };
// 0. Merge Config File (lowest priority: overridden by env vars and CLI args)
const fileConfig = await loadConfigFile();
for (const key of CONFIG_FILE_KEYS) {
if (fileConfig[key] !== undefined && fileConfig[key] !== "") {
config[key] = fileConfig[key];
}
}
// 1. Merge Environment Variables
for (const [envVar, configKey] of Object.entries(ENV_MAP)) {
if (process.env[envVar] !== undefined) {
config[configKey] = process.env[envVar];
}
}
// 2. Merge CLI Arguments
for (const key of [
"target",
"reviewer",
"prompt",
"lang",
"rev",
"last",
"uncommitted",
"outputDir",
"outputFormats",
"openaiApiKey",
"openaiBaseUrl",
"openaiModel",
"openaiOrganization",
"openaiProject"
]) {
if (cliArgs[key] !== undefined && cliArgs[key] !== "") {
config[key] = cliArgs[key];
}
}
if (cliArgs.rev && cliArgs.last) {
throw mkCliError("Parameters --rev and --last are mutually exclusive. Please specify only one.");
}
if (cliArgs.uncommitted && (cliArgs.rev || cliArgs.last)) {
throw mkCliError("Parameter --uncommitted is mutually exclusive with --rev and --last.");
}
if (!config.target) {
config.target = process.cwd();
}
config.baseDir = process.cwd();
config.debug = Boolean(cliArgs.debug);
config.reviewer = String(config.reviewer || "auto").toLowerCase();
config.lang = String(config.lang || "auto").toLowerCase();
config.resolvedLang = config.lang === "auto" ? detectLanguage() : config.lang;
// Handle @file prompt
if (config.prompt.startsWith("@")) {
const promptPath = resolvePath(config.prompt.slice(1));
try {
config.prompt = await fs.readFile(promptPath, "utf8");
} catch (err) {
throw mkCliError(`Failed to read prompt file: ${promptPath} (${err.message})`);
}
}
if (config.reviewer === "auto") {
const availableReviewers = await resolveAutoReviewers(config.debug);
const selectedReviewer = availableReviewers[0];
config.reviewer = selectedReviewer.reviewerName;
config.reviewerCommandPath = selectedReviewer.commandPath;
config.fallbackReviewers = availableReviewers.map(r => r.reviewerName).slice(1);
config.reviewerWasAutoSelected = true;
} else if (!SUPPORTED_REVIEWERS.includes(config.reviewer)) {
throw mkCliError(`"reviewer" must be one of: ${SUPPORTED_REVIEWERS.join(", ")}, or "auto"`);
}
config.outputDir = resolvePath(config.outputDir);
config.logsDir = path.join(config.outputDir, "logs");
config.maxRevisionsPerRun = Number(config.maxRevisionsPerRun);
config.commandTimeoutMs = Number(config.commandTimeoutMs);
config.last = Number(config.last);
config.uncommitted = Boolean(config.uncommitted);
config.outputFormats = normalizeOutputFormats(config.outputFormats);
config.openaiApiKey = String(config.openaiApiKey || "").trim();
config.openaiBaseUrl = String(config.openaiBaseUrl || defaultConfig.openaiBaseUrl).trim().replace(/\/+$/, "");
config.openaiModel = String(config.openaiModel || defaultConfig.openaiModel).trim();
config.openaiOrganization = String(config.openaiOrganization || "").trim();
config.openaiProject = String(config.openaiProject || "").trim();
if (!config.uncommitted && !config.rev && (isNaN(config.last) || config.last === 0)) {
config.last = 1;
}
if (config.reviewer === "openai" && !config.openaiApiKey) {
throw mkCliError('Reviewer "openai" requires an API key. Set KODEVU_OPENAI_API_KEY or pass --openai-api-key.');
}
return config;
}
export function printHelp() {
console.log(`Kodevu v${packageVersion}
Usage:
npx kodevu [target] [options]
Options:
--target, <path> Target repository path (default: current directory)
--reviewer, -r Reviewer (codex | gemini | copilot | openai | opencode | auto, default: auto)
--prompt, -p Additional instructions or @file.txt to read from file
--lang, -l Output language (e.g. zh, en, auto)
--rev, -v Review specific revision(s), hashes, branches or ranges (comma-separated)
--last, -n Review the latest N revisions; use negative (-N) to review only the Nth-from-last revision (default: 1)
--uncommitted, -u Review current uncommitted changes (mutually exclusive with --rev and --last)
--output, -o Output directory (default: ~/.kodevu)
--format, -f Output formats (markdown, json, comma-separated)
--openai-api-key API key used when reviewer=openai
--openai-base-url Base URL used when reviewer=openai (default: https://api.openai.com/v1)
--openai-model Model used when reviewer=openai (default: gpt-5-mini)
--openai-org Optional OpenAI organization ID
--openai-project Optional OpenAI project ID
--debug, -d Show extra debug information on the console
--help, -h Show help
--version, -V Show version
Environment Variables:
KODEVU_REVIEWER Default reviewer
KODEVU_LANG Default language
KODEVU_OUTPUT_DIR Default output directory
KODEVU_PROMPT Default prompt text
KODEVU_TIMEOUT Reviewer timeout in ms
KODEVU_OPENAI_API_KEY API key for reviewer=openai
KODEVU_OPENAI_BASE_URL Base URL for reviewer=openai
KODEVU_OPENAI_MODEL Model for reviewer=openai
KODEVU_OPENAI_ORG Organization ID for reviewer=openai
KODEVU_OPENAI_PROJECT Project ID for reviewer=openai
Config File:
~/.kodevu/config.json Optional persistent settings (overridden by env vars and CLI flags)
Supported keys: reviewer, lang, outputDir, prompt, commandTimeoutMs, outputFormats,
openaiApiKey, openaiBaseUrl, openaiModel, openaiOrganization, openaiProject
Example: { "reviewer": "openai", "openaiApiKey": "sk-...", "openaiModel": "gpt-4o" }
`);
}
export function printVersion() {
console.log(packageVersion);
}
import { countLines } from "./utils.js";
export const DIFF_LIMITS = {
review: {
maxLines: 4000,
maxChars: 120000
},
report: {
maxLines: 1500,
maxChars: 40000
},
tailLines: 200
};
export function trimBlockToChars(text, maxChars, keepTail = false) {
if (text.length <= maxChars) {
return text;
}
if (maxChars <= 3) {
return ".".repeat(Math.max(maxChars, 0));
}
return keepTail ? `...${text.slice(-(maxChars - 3))}` : `${text.slice(0, maxChars - 3)}...`;
}
export function truncateDiffText(diffText, maxLines, maxChars, tailLines, purposeLabel) {
const normalizedDiff = diffText.replace(/\r\n/g, "\n");
const originalLineCount = countLines(normalizedDiff);
const originalCharCount = normalizedDiff.length;
if (originalLineCount <= maxLines && originalCharCount <= maxChars) {
return {
text: diffText,
wasTruncated: false,
originalLineCount,
originalCharCount,
outputLineCount: originalLineCount,
outputCharCount: originalCharCount
};
}
const lines = normalizedDiff.split("\n");
const safeTailLines = Math.min(Math.max(tailLines, 0), Math.max(maxLines - 2, 0));
const headLineCount = Math.max(maxLines - safeTailLines - 1, 1);
let headBlock = lines.slice(0, headLineCount).join("\n");
let tailBlock = safeTailLines > 0 ? lines.slice(-safeTailLines).join("\n") : "";
const omittedLineCount = Math.max(originalLineCount - headLineCount - safeTailLines, 0);
const markerBlock = [
`... diff truncated for ${purposeLabel} ...`,
`original lines: ${originalLineCount}, original chars: ${originalCharCount}`,
`omitted lines: ${omittedLineCount}`
].join("\n");
let truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
if (truncatedText.length > maxChars) {
const reservedChars = markerBlock.length + (tailBlock ? 2 : 1);
const remainingChars = Math.max(maxChars - reservedChars, 0);
const headBudget = tailBlock ? Math.floor(remainingChars * 0.7) : remainingChars;
const tailBudget = tailBlock ? Math.max(remainingChars - headBudget, 0) : 0;
headBlock = trimBlockToChars(headBlock, headBudget, false);
tailBlock = trimBlockToChars(tailBlock, tailBudget, true);
truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
}
return {
text: truncatedText,
wasTruncated: true,
originalLineCount,
originalCharCount,
outputLineCount: countLines(truncatedText),
outputCharCount: truncatedText.length
};
}
export function prepareDiffPayloads(config, diffText) {
return {
review: truncateDiffText(
diffText,
DIFF_LIMITS.review.maxLines,
DIFF_LIMITS.review.maxChars,
DIFF_LIMITS.tailLines,
"reviewer input"
),
report: truncateDiffText(
diffText,
DIFF_LIMITS.report.maxLines,
DIFF_LIMITS.report.maxChars,
Math.min(DIFF_LIMITS.tailLines, DIFF_LIMITS.report.maxLines),
"report output"
)
};
}
import fs from "node:fs/promises";
import path from "node:path";
import { runCommand } from "./shell.js";
const GIT_COMMAND = "git";
const COMMAND_ENCODING = "utf8";
function toPosixPath(filePath) {
return filePath.split(path.sep).join("/");
}
function splitLines(text) {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function buildPathArgs(targetInfo) {
return targetInfo.targetPathspec ? ["--", targetInfo.targetPathspec] : [];
}
async function statPath(targetPath) {
try {
return await fs.stat(targetPath);
} catch {
return null;
}
}
async function runGit(config, args, options = {}) {
return await runCommand(GIT_COMMAND, args, {
encoding: COMMAND_ENCODING,
debug: config.debug,
...options
});
}
export async function getTargetInfo(config) {
const requestedTargetPath = path.resolve(config.baseDir, config.target);
const targetStat = await statPath(requestedTargetPath);
if (!targetStat) {
throw new Error(`Git target path does not exist: ${requestedTargetPath}`);
}
const lookupCwd = targetStat.isDirectory() ? requestedTargetPath : path.dirname(requestedTargetPath);
const topLevelResult = await runGit(config, ["rev-parse", "--show-toplevel"], {
cwd: lookupCwd,
trim: true,
allowFailure: true
});
if (topLevelResult.code !== 0) {
throw new Error(`Git target path is not within a Git repository: ${requestedTargetPath}`);
}
const repoRootPath = path.resolve(topLevelResult.stdout);
const relativeTargetPath = toPosixPath(path.relative(repoRootPath, requestedTargetPath));
const branchResult = await runGit(config, ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: repoRootPath,
trim: true,
allowFailure: true
});
return {
repoRootPath,
requestedTargetPath,
targetDisplay: requestedTargetPath,
targetPathspec: relativeTargetPath ? relativeTargetPath : "",
branchName: branchResult.stdout || "HEAD"
};
}
export async function getLatestCommit(config, targetInfo) {
const result = await runGit(
config,
["log", "--format=%H", "-n", "1", "HEAD", ...buildPathArgs(targetInfo)],
{ cwd: targetInfo.repoRootPath, trim: true }
);
const latestCommit = splitLines(result.stdout)[0];
if (!latestCommit) {
throw new Error(`Unable to determine the latest Git commit for ${targetInfo.targetDisplay}`);
}
return latestCommit;
}
export async function resolveCommits(config, targetInfo, revSpec) {
const result = await runGit(
config,
["rev-list", revSpec],
{ cwd: targetInfo.repoRootPath, trim: true, allowFailure: true }
);
if (result.code !== 0) {
// Attempt fallback to a single hash resolution if rev-list fails (e.g. for non-standard specs)
const single = await runGit(config, ["rev-parse", revSpec], {
cwd: targetInfo.repoRootPath, trim: true, allowFailure: true
});
if (single.code === 0) return [single.stdout.trim()];
throw new Error(`Failed to resolve Git revision: ${revSpec}`);
}
return splitLines(result.stdout);
}
export async function getLatestCommitIds(config, targetInfo, limit) {
const result = await runGit(
config,
["rev-list", "-n", String(limit), "HEAD", ...buildPathArgs(targetInfo)],
{ cwd: targetInfo.repoRootPath, trim: true }
);
// Reverse to get chronological order (oldest to newest among the latest n)
return splitLines(result.stdout).reverse();
}
export async function getCommitDiff(config, targetInfo, commitHash) {
const result = await runGit(
config,
[
"show",
"--format=",
"--find-renames",
"--find-copies",
"--no-ext-diff",
commitHash,
...buildPathArgs(targetInfo)
],
{ cwd: targetInfo.repoRootPath, trim: false }
);
return result.stdout;
}
function parseNameStatus(stdout) {
const entries = stdout.split("\0").filter(Boolean);
const changedPaths = [];
for (let index = 0; index < entries.length; index += 1) {
const status = entries[index];
if (!status) {
continue;
}
const action = status[0];
if (status.startsWith("R") || status.startsWith("C")) {
const oldPath = entries[index + 1];
const newPath = entries[index + 2];
if (newPath) {
changedPaths.push({
action,
relativePath: newPath,
previousPath: oldPath || null
});
}
index += 2;
continue;
}
const filePath = entries[index + 1];
if (filePath) {
changedPaths.push({
action,
relativePath: filePath,
previousPath: null
});
}
index += 1;
}
return changedPaths;
}
function parseNameStatusLines(stdout) {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split("\t"))
.map((parts) => {
const status = (parts[0] || "M").trim();
const action = status[0] || "M";
if ((action === "R" || action === "C") && parts.length >= 3) {
return {
action,
relativePath: parts[2],
previousPath: parts[1] || null
};
}
return {
action,
relativePath: parts[1] || "",
previousPath: null
};
})
.filter((item) => item.relativePath);
}
function mergeChangedPaths(...groups) {
const merged = [];
const seen = new Set();
for (const group of groups) {
for (const item of group) {
const key = `${item.action}|${item.relativePath}|${item.previousPath || ""}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(item);
}
}
return merged;
}
export async function getCommitDetails(config, targetInfo, commitHash) {
const metaResult = await runGit(
config,
["show", "--no-patch", "--format=%H%x00%an%x00%aI%x00%B", commitHash],
{ cwd: targetInfo.repoRootPath, trim: false }
);
const [hash = "", author = "", date = "", ...messageParts] = metaResult.stdout.split("\0");
const message = messageParts.join("\0").trim();
const changedFilesResult = await runGit(
config,
[
"diff-tree",
"--no-commit-id",
"--name-status",
"-r",
"--root",
"-z",
"-M",
"-C",
commitHash,
...buildPathArgs(targetInfo)
],
{ cwd: targetInfo.repoRootPath, trim: false }
);
return {
commitHash: hash.trim() || commitHash,
author: author.trim() || "unknown",
date: date.trim(),
message,
changedPaths: parseNameStatus(changedFilesResult.stdout)
};
}
export async function getUncommittedDiff(config, targetInfo) {
const unstaged = await runGit(
config,
[
"diff",
"--find-renames",
"--find-copies",
"--no-ext-diff",
...buildPathArgs(targetInfo)
],
{ cwd: targetInfo.repoRootPath, trim: false }
);
const staged = await runGit(
config,
[
"diff",
"--cached",
"--find-renames",
"--find-copies",
"--no-ext-diff",
...buildPathArgs(targetInfo)
],
{ cwd: targetInfo.repoRootPath, trim: false }
);
const sections = [];
if (unstaged.stdout.trim()) {
sections.push("# Unstaged changes", unstaged.stdout.trimEnd());
}
if (staged.stdout.trim()) {
sections.push("# Staged changes", staged.stdout.trimEnd());
}
return sections.join("\n\n");
}
export async function getUncommittedDetails(config, targetInfo) {
const unstagedFiles = await runGit(
config,
["diff", "--name-status", "-M", "-C", ...buildPathArgs(targetInfo)],
{ cwd: targetInfo.repoRootPath, trim: false }
);
const stagedFiles = await runGit(
config,
["diff", "--cached", "--name-status", "-M", "-C", ...buildPathArgs(targetInfo)],
{ cwd: targetInfo.repoRootPath, trim: false }
);
const unstagedChanged = parseNameStatusLines(unstagedFiles.stdout);
const stagedChanged = parseNameStatusLines(stagedFiles.stdout);
return {
commitHash: "UNCOMMITTED",
author: "working-tree",
date: new Date().toISOString(),
message: "Uncommitted changes (staged + unstaged).",
changedPaths: mergeChangedPaths(unstagedChanged, stagedChanged)
};
}
#!/usr/bin/env node
import { resolveConfig, parseCliArgs, printHelp, printVersion } from "./config.js";
import { runReviewCycle } from "./review-runner.js";
import { logger } from "./logger.js";
let cliArgs;
try {
cliArgs = parseCliArgs(process.argv.slice(2));
} catch (error) {
console.error(error?.message || String(error));
printHelp();
// CLI/usage errors -> exit code 2
const code = Number(error?.exitCode) || 2;
process.exit(code);
}
if (cliArgs.help) {
printHelp();
process.exit(0);
}
if (cliArgs.version) {
printVersion();
process.exit(0);
}
try {
const config = await resolveConfig(cliArgs);
logger.init(config);
if (config.reviewerWasAutoSelected) {
logger.info(
`Reviewer "auto" selected ${config.reviewer}${config.reviewerCommandPath ? ` (${config.reviewerCommandPath})` : ""}.`,
{
scope: "session",
reviewer: config.reviewer,
reviewerCommandPath: config.reviewerCommandPath || "",
console: true
}
);
}
logger.debug("Resolved config", {
scope: "session",
reviewer: config.reviewer,
reviewerCommandPath: config.reviewerCommandPath || "",
reviewerWasAutoSelected: config.reviewerWasAutoSelected || false,
openaiBaseUrl: config.openaiBaseUrl,
openaiModel: config.openaiModel,
openaiOrganization: config.openaiOrganization || "",
openaiProject: config.openaiProject || "",
target: config.target,
outputDir: config.outputDir,
lang: config.lang,
resolvedLang: config.resolvedLang,
debug: config.debug,
outputFormats: config.outputFormats,
mode: config.uncommitted ? "uncommitted" : config.rev ? "rev" : "last",
rev: config.rev || "",
last: config.last || 0,
console: "debug"
});
logger.info("Session started", {
scope: "session",
target: config.target,
reviewer: config.reviewer,
outputDir: config.outputDir,
mode: config.uncommitted ? "uncommitted" : config.rev ? "rev" : "last",
rev: config.rev || "",
last: config.last || 0
});
await runReviewCycle(config);
logger.info("Session completed successfully", {
scope: "session",
target: config.target,
reviewer: config.reviewer
});
} catch (error) {
logger.error("Session failed", error, {
scope: "session",
console: true
});
// Map any attached exitCode (from thrown errors) to process exitCode, fallback to 1
process.exitCode = Number(error?.exitCode) || 1;
}
process.exit(process.exitCode || 0);
import fs from "node:fs";
import path from "node:path";
import { formatDate } from "./utils.js";
// Top-level helpers moved into Logger as private methods (#name).
class Logger {
constructor () {
this.config = null;
this.logFile = null;
this.sessionId = null;
this.initialized = false;
}
#formatValue(value) {
if (value == null) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
#formatMeta(meta = {}) {
const parts = [];
for (const [key, rawValue] of Object.entries(meta || {})) {
if (rawValue == null || rawValue === "") continue;
const value = this.#formatValue(rawValue);
if (!value) continue;
if (typeof rawValue === "string") {
parts.push(`${key}=${JSON.stringify(value)}`);
continue;
}
if (typeof rawValue === "number" || typeof rawValue === "boolean") {
parts.push(`${key}=${rawValue}`);
continue;
}
parts.push(`${key}=${value}`);
}
return parts.join(" ");
}
#formatErrorDetails(error) {
if (!error) return "";
if (error instanceof Error) return error.stack ?? error.message ?? String(error);
if (typeof error === "string") return error;
try {
return JSON.stringify(error, null, 2);
} catch {
return String(error);
}
}
init(config) {
if (this.initialized) return;
this.config = config;
this.sessionId = this.#createSessionId();
if (config.logsDir) {
try {
if (!fs.existsSync(config.logsDir)) {
fs.mkdirSync(config.logsDir, { recursive: true });
}
const date = formatDate(new Date()).split(" ")[0];
this.logFile = path.join(config.logsDir, `run-${date}.log`);
// Simple rotation: Keep the most recent 7 log files
this.#cleanupOldLogs(config.logsDir);
this.initialized = true;
} catch (err) {
console.error(`[logger] Failed to initialize log file: ${err.message}`);
}
}
}
info(message, meta) {
this.#log("INFO", message, meta);
}
warn(message, meta) {
this.#log("WARN", message, { ...meta, console: meta?.console ?? true });
}
error(message, error, meta) {
this.#log("ERROR", message, {
...meta,
console: meta?.console ?? true,
error: this.#formatErrorDetails(error)
});
}
debug(message, meta) {
this.#log("DEBUG", message, meta);
}
#log(level, message, meta = {}) {
const timestamp = formatDate(new Date());
const { console: consoleMode, ...details } = meta;
const fields = {
session: this.sessionId || "uninitialized",
...details
};
const metaSuffix = this.#formatMeta(fields);
const logLine = `[${timestamp}] [${level}] ${message}${metaSuffix ? ` | ${metaSuffix}` : ""}`;
if (this.logFile) {
try {
fs.appendFileSync(this.logFile, logLine + "\n");
} catch (err) {
// Ignore file errors during logging to prevent crashes
}
}
if (!this.#shouldWriteToConsole(level, consoleMode)) {
return;
}
if (level === "ERROR" || level === "WARN") {
console.error(logLine);
} else {
console.log(logLine);
}
}
#shouldWriteToConsole(level, consoleMode) {
if (consoleMode === false) return false;
if (level === "ERROR" || level === "WARN") return true;
if (level === "DEBUG") {
if (consoleMode === true) return Boolean(this.config?.debug);
return false;
}
if (consoleMode === true) return true;
if (consoleMode === "debug") return Boolean(this.config?.debug);
return false;
}
#createSessionId() {
return [
Date.now().toString(36),
process.pid.toString(36),
Math.random().toString(36).slice(2, 8)
].join("-");
}
#cleanupOldLogs(logsDir) {
try {
const files = fs.readdirSync(logsDir);
const logFiles = files
.filter((file) => file.startsWith("run-") && file.endsWith(".log"))
.map((file) => {
const filePath = path.join(logsDir, file);
try {
const stats = fs.statSync(filePath);
return { file, path: filePath, mtime: stats.mtimeMs };
} catch {
return null;
}
})
.filter(Boolean)
.sort((a, b) => b.mtime - a.mtime);
const KEEP_COUNT = 7;
const toRemove = logFiles.slice(KEEP_COUNT);
for (const entry of toRemove) {
try {
fs.unlinkSync(entry.path);
} catch {
// Ignore individual deletion errors
}
}
} catch (err) {
// Ignore cleanup errors
}
}
}
export const logger = new Logger();
function clampProgress(value) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
function formatStatusLine(label, progress, stage) {
const pct = `${Math.round(progress * 100)}`.padStart(3, " ");
return `[progress] ${pct}% ${label}${stage ? ` | ${stage}` : ""}`;
}
export function createProgressReporter(label, options = {}) {
const stream = options.stream || process.stdout;
let lastStatusLine = "";
function writeLine(message) {
stream.write(`${message}\n`);
}
return {
update(progress, stage = "") {
const line = formatStatusLine(label, clampProgress(progress), stage);
if (line === lastStatusLine) {
return;
}
lastStatusLine = line;
writeLine(line);
},
finish(status, message) {
const prefix = status === "fail" ? "[fail]" : "[done]";
writeLine(`${prefix} ${message || label}`);
}
};
}
import { formatDate } from "./utils.js";
const LOCALIZED_DATA = {
en: {
displayName: "English",
systemRole: `You are a Senior Software Engineer and Code Review Expert with over 10 years of experience. Your task is to perform a rigorous and high-quality review of the following code changes.
Your goals are to:
1. Identify bugs, logical flaws, and potential regression risks.
2. Spot performance bottlenecks, memory leaks, or unnecessary computations.
3. Check for security vulnerabilities (e.g., injection, authorization issues, sensitive data leaks).
4. Evaluate maintainability, readability, and adherence to best practices.
5. Verify coverage of necessary unit tests and boundary conditions.`,
outputFormat: `Your output must be structured using the following Markdown headers:
### 1. Summary
Briefly describe the purpose and impact of this change.
### 2. Critical Issues
List bugs, security risks, or problems that could cause crashes or logical errors. Include file names and line numbers.
### 3. Suggestions
List points for improvement regarding code style, performance, or architectural design.
### 4. Conclusion
Summarize with a "Pass" or "Needs Revision". If no clear flaws are found, state "No clear flaws found" and mention any residual risks.`,
constraints: `Note: You are in a read-only review mode. Do not attempt to call any external tools or MCP (Model Context Protocol) tools. Do not act as if you are "applying the patch" or "executing code". Provide only textual analysis and feedback.`,
phrases: {
workspaceRoot: "Workspace context (read-only):",
noWorkspace: "No local repository workspace is available for this review run.",
besidesDiff: "You can read related files in the workspace to understand call sites, shared utilities, configuration, or data flow.",
reviewFromDiff: "Review primarily based on the provided diff. Do not assume access to other local files or shell commands. You MUST NOT call any MCP tools.",
fileRefs: "Reference files using plain text like 'path/to/file.js:123'. Do not generate clickable workspace links.",
repoType: "Repository Type",
changeId: "Change ID",
author: "Author",
date: "Date",
changedFiles: "Changed files",
commitMessage: "Commit message",
diffNoteTruncated: "Note: The diff was truncated to fit size limits. Original: {originalLineCount} lines / {originalCharCount} chars. Included: {outputLineCount} lines / {outputCharCount} chars.",
diffNoteFull: "Note: Full diff provided ({originalLineCount} lines / {originalCharCount} chars).",
langRule: "--- LANGUAGE RULE ---\nYour entire response must be in {langName}. No other language allowed.",
outputDirective: "--- BEGIN REVIEW ---\nNow output your COMPLETE code review. Cover ALL four sections (Summary, Critical Issues, Suggestions, Conclusion). Do NOT ask clarifying questions, do NOT acknowledge these instructions, do NOT say you are ready. Start your response directly with the review content."
}
},
zh: {
displayName: "Simplified Chinese (简体中文)",
systemRole: `你是一位拥有 10 年以上经验的高级软件架构师和代码审查专家。你的任务是对以下代码变更进行严格且高质量的审查。
你的目标是:
1. 发现代码中的 Bug、逻辑缺陷和潜在的回归风险。
2. 识别性能瓶颈、内存泄漏或不必要的计算。
3. 检查安全漏洞(如注入、越权、敏感信息泄露等)。
4. 评估代码的可维护性、可读性和是否符合最佳实践。
5. 检查是否涵盖了必要的单元测试和边界条件。`,
outputFormat: `你的输出格式必须清晰,请使用以下 Markdown 结构:
### 1. 变更总结 (Summary)
简要描述这次提交的主要目的和影响面。
### 2. 核心缺陷 (Critical Issues)
列出 Bug、安全隐患或会导致程序崩溃/逻辑错误的问题。请注明文件名和行号。
### 3. 改进建议 (Suggestions)
列出关于代码风格、性能优化或架构设计的改进点。
### 4. 审查结论 (Conclusion)
如果发现明显缺陷,总结修复建议;如果未发现明显缺陷,请说明“未发现明显缺陷”并指出可能的残留风险。`,
constraints: `注意:你正处于只读审查模式,禁止调用任何外部工具或 MCP (Model Context Protocol) 工具。请勿表现出“正在应用补丁”或“准备执行代码”的行为。只需提供文字审查分析。`,
phrases: {
workspaceRoot: "只读工作区上下文:",
noWorkspace: "此审查运行没有可用的本地仓库工作区。",
besidesDiff: "你可以阅读工作区中的其他文件以了解调用点、工具类、配置或数据流。",
reviewFromDiff: "主要根据提供的 Diff 进行审查。不要假设可以访问其他文件或执行 Shell 命令。你绝不能调用任何 MCP 工具。",
fileRefs: "使用纯文本引用文件,如 'path/to/file.js:123'。不要生成可点击的链接。",
repoType: "仓库类型",
changeId: "变更 ID",
author: "作者",
date: "日期",
changedFiles: "已变更文件",
commitMessage: "提交信息",
diffNoteTruncated: "注意:Diff 已截断。原始:{originalLineCount} 行 / {originalCharCount} 字符。包含:{outputLineCount} 行 / {outputCharCount} 字符。",
diffNoteFull: "注意:包含完整 Diff ({originalLineCount} 行 / {originalCharCount} 字符)。",
langRule: "--- 语言规则 ---\n你必须完全使用 {langName} 进行回复。不得使用其他语言进行解释或总结。",
outputDirective: "--- 开始输出审查结果 ---\n请立即输出完整的代码审查结果,必须包含全部四个章节(变更总结、核心缺陷、改进建议、审查结论)。不要提问,不要确认收到指令,不要说准备好了,直接以审查内容开始输出。"
}
},
"zh-tw": {
displayName: "Traditional Chinese (繁體中文)",
systemRole: `你是一位擁有 10 年以上經驗的高級軟體架構師和代碼審查專家。你的任務是對以下代碼變更進行嚴格且高質量的審查。
你的目標是:
1. 發現代碼中的 Bug、邏輯缺陷和潛在的回歸風險。
2. 識別性能瓶頸、記憶體洩漏或不必要的計算。
3. 檢查安全漏洞(如注入、越權、敏感信息洩露等)。
4. 評估代碼的可維護性、可讀性和是否符合最佳實踐。
5. 檢查是否涵蓋了必要的单元測試和邊界條件。`,
outputFormat: `你的輸出格式必須清晰,請使用以下 Markdown 結構:
### 1. 變更總結 (Summary)
簡要描述這次提交的主要目的和影響面。
### 2. 核心缺陷 (Critical Issues)
列出 Bug、安全隱患或會導致程序崩潰/邏輯錯誤的問題。請註明檔案名和行號。
### 3. 改進建議 (Suggestions)
列出關於代碼風格、性能優化或架構設計的改進點。
### 4. 審查結論 (Conclusion)
如果發現明顯缺陷,總結修復建議;如果未發現明顯缺陷,請說明「未發現明顯缺陷」並指出可能的殘留風險。`,
constraints: `注意:你正處於唯讀審查模式,禁止調用任何外部工具或 MCP (Model Context Protocol) 工具。請勿表現出「正在應用補丁」或「準備執行代碼」的行為。只需提供文字審查分析。`,
phrases: {
workspaceRoot: "唯讀工作區上下文:",
noWorkspace: "此審查運行沒有可用的本地倉庫工作區。",
besidesDiff: "你可以閱讀工作區中的其他文件以了解調用點、工具類、配置或資料流。",
reviewFromDiff: "主要根據提供的 Diff 進行審查。不要假設可以訪問其他文件或執行 Shell 命令。你絕不能調用任何 MCP 工具。",
fileRefs: "使用純文本引用文件,如 'path/to/file.js:123'。不要生成可點擊的連結。",
repoType: "倉庫類型",
changeId: "變更 ID",
author: "作者",
date: "日期",
changedFiles: "已變更文件",
commitMessage: "提交信息",
diffNoteTruncated: "注意:Diff 已截斷。原始:{originalLineCount} 行 / {originalCharCount} 字符。包含:{outputLineCount} 行 / {outputCharCount} 字符。",
diffNoteFull: "注意:包含完整 Diff ({originalLineCount} 行 / {originalCharCount} 字符)。",
langRule: "--- 語言規則 ---\n你必須完全使用 {langName} 進行回覆。不得使用其他語言進行解釋或總結。",
outputDirective: "--- 開始輸出審查結果 ---\n請立即輸出完整的代碼審查結果,必須包含全部四個章節(變更總結、核心缺陷、改進建議、審查結論)。不要提問,不要確認指令,不要說準備好了,直接以審查內容開始輸出。"
}
}
};
function getLangKey(lang) {
const lowArg = (lang || "en").toLowerCase();
if (lowArg.startsWith("zh")) {
return (lowArg === "zh-tw" || lowArg === "zh-hk") ? "zh-tw" : "zh";
}
return "en";
}
function getLanguageDisplayName(lang) {
const key = getLangKey(lang);
const data = LOCALIZED_DATA[key];
if (data && lang.toLowerCase().startsWith("zh")) return data.displayName;
const extras = {
en: "English",
jp: "Japanese (日本語)", ja: "Japanese (日本語)",
kr: "Korean (한국어)", ko: "Korean (한국어)",
fr: "French (Français)", de: "German (Deutsch)",
es: "Spanish (Español)", it: "Italian (Italiano)",
ru: "Russian (Русский)"
};
const low = (lang || "en").toLowerCase();
return extras[low] || extras[low.split("-")[0]] || lang || "English";
}
function getPhrase(key, lang, placeholders = {}) {
const langKey = getLangKey(lang);
let phrase = LOCALIZED_DATA[langKey]?.phrases[key] || LOCALIZED_DATA.en.phrases[key];
if (!phrase) return "";
for (const [k, v] of Object.entries(placeholders)) {
phrase = phrase.replace(`{${k}}`, v);
}
return phrase;
}
export class PromptBuilder {
constructor(config, backend, targetInfo, details) {
this.config = config;
this.backend = backend;
this.targetInfo = targetInfo;
this.details = details;
this.lang = config.resolvedLang || "en";
this.langKey = getLangKey(this.lang);
this.data = LOCALIZED_DATA[this.langKey] || LOCALIZED_DATA.en;
}
getLangInstruction() {
const langName = getLanguageDisplayName(this.lang);
const lowLang = this.lang.toLowerCase();
let instruction = `CRITICAL: YOUR ENTIRE RESPONSE MUST BE IN ${langName.toUpperCase()}.`;
if (lowLang.startsWith("zh")) {
if (lowLang === "zh-tw" || lowLang === "zh-hk") {
instruction += "\n請務必完全使用繁體中文進行回覆。";
} else {
instruction += "\n请务必完全使用简体中文进行回复。";
}
}
return instruction;
}
getMetadata() {
const fileList = this.details.changedPaths.map((item) => `${item.action} ${item.relativePath}`).join("\n");
return [
`${getPhrase("repoType", this.lang)}: ${this.backend.displayName}`,
`${getPhrase("changeId", this.lang)}: ${this.details.displayId}`,
`${getPhrase("author", this.lang)}: ${this.details.author}`,
`${getPhrase("date", this.lang)}: ${formatDate(this.details.date) || "unknown"}`,
`${getPhrase("changedFiles", this.lang)}:\n${fileList || "(none)"}`,
`${getPhrase("commitMessage", this.lang)}:\n${this.details.message || "(empty)"}`
].join("\n");
}
getEnvironment(workspaceRoot, canReadRelatedFiles) {
return canReadRelatedFiles
? `${getPhrase("workspaceRoot", this.lang)} ${workspaceRoot}\n${getPhrase("besidesDiff", this.lang)}`
: getPhrase("noWorkspace", this.lang);
}
build(diffPayload) {
const workspaceRoot = (this.backend.kind === "git" ? this.targetInfo.repoRootPath : this.targetInfo.workingCopyPath) || this.config.baseDir;
const canReadRelatedFiles = this.backend.kind === "git" || Boolean(this.targetInfo.workingCopyPath);
const langName = getLanguageDisplayName(this.lang);
const diffNote = diffPayload.wasTruncated
? getPhrase("diffNoteTruncated", this.lang, {
originalLineCount: diffPayload.originalLineCount,
originalCharCount: diffPayload.originalCharCount,
outputLineCount: diffPayload.outputLineCount,
outputCharCount: diffPayload.outputCharCount
})
: getPhrase("diffNoteFull", this.lang, {
originalLineCount: diffPayload.originalLineCount,
originalCharCount: diffPayload.originalCharCount
});
const sections = [
"## Instructions",
this.getLangInstruction(),
this.data.systemRole,
this.data.outputFormat,
this.data.constraints,
this.config.prompt ? `### Additional User Instructions:\n${this.config.prompt}` : null,
"## Change Context",
this.getMetadata(),
diffNote,
"## Environment",
this.getEnvironment(workspaceRoot, canReadRelatedFiles),
getPhrase("reviewFromDiff", this.lang),
getPhrase("fileRefs", this.lang),
"## Final Rule",
getPhrase("langRule", this.lang, { langName }),
getPhrase("outputDirective", this.lang)
];
let prompt = sections.filter(Boolean).join("\n\n");
// Reviewer-specific adjustments
if (this.config.reviewer === "copilot") {
// For Copilot, we reinforce the tool isolation even more
prompt += "\n\nCRITICAL FOR COPILOT: Do not use any MCP tools. Do not use @workspace. Use only the provided diff and the files you can read via provided paths.";
}
return prompt;
}
}
export function buildReviewPrompt(config, backend, targetInfo, details, diffPayload) {
const builder = new PromptBuilder(config, backend, targetInfo, details);
return builder.build(diffPayload);
}
export { LOCALIZED_DATA, getPhrase, getLanguageDisplayName };
import { formatDate } from "./utils.js";
import { PromptBuilder, getPhrase, getLanguageDisplayName } from "./prompts.js";
export function getReviewWorkspaceRoot(config, backend, targetInfo) {
return (backend.kind === "git" ? targetInfo.repoRootPath : targetInfo.workingCopyPath) || config.baseDir;
}
export function buildPrompt(config, backend, targetInfo, details, reviewDiffPayload) {
const builder = new PromptBuilder(config, backend, targetInfo, details);
return builder.build(reviewDiffPayload);
}
export function formatTokenUsage(tokenUsage) {
const sourceLabel = tokenUsage.source === "reviewer" ? "reviewer reported" : "estimated (~4 chars/token)";
return [
`- Input Tokens: \`${tokenUsage.inputTokens}\``,
`- Output Tokens: \`${tokenUsage.outputTokens}\``,
`- Total Tokens: \`${tokenUsage.totalTokens}\``,
`- Token Source: \`${sourceLabel}\``
].join("\n");
}
export function formatDiffHandling(diffPayload, label) {
return [
`- ${label} Original Lines: \`${diffPayload.originalLineCount}\``,
`- ${label} Original Chars: \`${diffPayload.originalCharCount}\``,
`- ${label} Included Lines: \`${diffPayload.outputLineCount}\``,
`- ${label} Included Chars: \`${diffPayload.outputCharCount}\``,
`- ${label} Truncated: \`${diffPayload.wasTruncated ? "yes" : "no"}\``
].join("\n");
}
export function formatChangedPaths(changedPaths) {
if (changedPaths.length === 0) {
return "_No changed files captured._";
}
return changedPaths
.map((item) => {
const renameSuffix = item.previousPath ? ` (from ${item.previousPath})` : "";
return `- \`${item.action}\` ${item.relativePath}${renameSuffix}`;
})
.join("\n");
}
export function formatChangeList(backend, changeIds) {
return changeIds.map((changeId) => backend.formatChangeId(changeId)).join(", ");
}
export function shouldWriteFormat(config, format) {
return Array.isArray(config.outputFormats) && config.outputFormats.includes(format);
}
export function buildReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage) {
const lang = config.resolvedLang || "en";
const lines = [
`# ${backend.displayName} Review Report: ${details.displayId}`,
"",
`- ${getPhrase("repoType", lang)}: \`${backend.displayName}\``,
`- Target: \`${targetInfo.targetDisplay || config.target}\``,
`- ${getPhrase("changeId", lang)}: \`${details.displayId}\``,
`- ${getPhrase("author", lang)}: \`${details.author}\``,
`- ${getPhrase("date", lang)}: \`${formatDate(details.date)}\``,
`- Generated At: \`${formatDate(new Date())}\``,
`- Reviewer: \`${reviewer.displayName}\``,
"",
"## Token Usage",
"",
formatTokenUsage(tokenUsage),
"",
`## ${getPhrase("changedFiles", lang)}`,
"",
formatChangedPaths(details.changedPaths),
"",
`## ${getPhrase("commitMessage", lang)}`,
"",
details.message ? "```text\n" + details.message + "\n```" : "_Empty_",
"",
"## Diff",
"",
"```diff",
diffPayloads.report.text.trim() || "(empty diff)",
"```",
"",
`## ${reviewer.responseSectionTitle}`,
"",
reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
];
return `${lines.join("\n")}\n`;
}
export function buildJsonReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage) {
return {
repositoryType: backend.displayName,
target: targetInfo.targetDisplay || config.target,
changeId: details.displayId,
author: details.author,
commitDate: formatDate(details.date),
generatedAt: formatDate(new Date()),
reviewer: {
name: reviewer.displayName,
exitCode: reviewerResult.code,
timedOut: Boolean(reviewerResult.timedOut)
},
tokenUsage: {
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
totalTokens: tokenUsage.totalTokens,
source: tokenUsage.source
},
changedFiles: details.changedPaths.map((item) => ({
action: item.action,
path: item.relativePath,
previousPath: item.previousPath || null
})),
commitMessage: details.message || "",
reviewContext: buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
diffHandling: {
reviewerInput: {
originalLines: diffPayloads.review.originalLineCount,
originalChars: diffPayloads.review.originalCharCount,
includedLines: diffPayloads.review.outputLineCount,
includedChars: diffPayloads.review.outputCharCount,
truncated: diffPayloads.review.wasTruncated
},
reportDiff: {
originalLines: diffPayloads.report.originalLineCount,
originalChars: diffPayloads.report.originalCharCount,
includedLines: diffPayloads.report.outputLineCount,
includedChars: diffPayloads.report.outputCharCount,
truncated: diffPayloads.report.wasTruncated
}
},
diff: diffPayloads.report.text.trim(),
reviewerDiagnostics: reviewerResult.stderr?.trim() || "",
reviewerResponse: reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
};
}
import path from "node:path";
import { createProgressReporter } from "./progress-ui.js";
import { resolveRepositoryContext } from "./vcs-client.js";
import { logger } from "./logger.js";
import {
ensureDir,
writeTextFile,
writeJsonFile,
formatDate
} from "./utils.js";
import {
shouldWriteFormat,
buildReport,
buildJsonReport,
formatChangeList
} from "./report-generator.js";
import { runReviewerPrompt } from "./reviewers.js";
async function reviewChange(config, backend, targetInfo, changeId, progress) {
const displayId = backend.formatChangeId(changeId);
logger.info(`Starting review for ${backend.changeName} ${displayId}`, {
scope: "review",
repository: backend.kind,
changeId: displayId
});
progress?.update(0.05, "loading change details");
const details = await backend.getChangeDetails(config, targetInfo, changeId);
const resolvedChangeId = details.id;
if (details.changedPaths.length === 0) {
progress?.update(0.7, "writing skipped report");
const skippedReport = [
`# ${backend.displayName} Review Report: ${details.displayId}`,
"",
"No file changes were captured for this change under the configured target."
].join("\n");
const markdownReportFile = path.join(config.outputDir, backend.getReportFileName(resolvedChangeId));
const jsonReportFile = markdownReportFile.replace(/\.md$/i, ".json");
if (shouldWriteFormat(config, "markdown")) {
await writeTextFile(markdownReportFile, `${skippedReport}\n`);
}
if (shouldWriteFormat(config, "json")) {
await writeJsonFile(jsonReportFile, {
repositoryType: backend.displayName,
target: targetInfo.targetDisplay || config.target,
changeId: details.displayId,
generatedAt: formatDate(new Date()),
skipped: true,
message: "No file changes were captured for this change under the configured target."
});
}
return {
success: true,
outputFile: shouldWriteFormat(config, "markdown") ? markdownReportFile : null,
jsonOutputFile: shouldWriteFormat(config, "json") ? jsonReportFile : null
};
}
progress?.update(0.2, "loading diff");
const diffText = await backend.getChangeDiff(config, targetInfo, resolvedChangeId);
const reviewersToTry = [config.reviewer, ...(config.fallbackReviewers || [])];
let reviewer;
let diffPayloads;
let reviewerResult;
let tokenUsage;
let currentReviewerConfig;
for (const reviewerName of reviewersToTry) {
currentReviewerConfig = { ...config, reviewer: reviewerName };
logger.debug(`Trying reviewer: ${reviewerName}`, {
scope: "review",
repository: backend.kind,
changeId: details.displayId,
reviewer: reviewerName,
console: "debug"
});
progress?.update(0.45, `running reviewer ${reviewerName}`);
try {
const res = await runReviewerPrompt(
currentReviewerConfig,
backend,
targetInfo,
details,
diffText
);
reviewer = res.reviewer;
diffPayloads = res.diffPayloads;
reviewerResult = res.result;
tokenUsage = res.tokenUsage;
if (reviewerResult.code === 0 && !reviewerResult.timedOut) {
break;
}
} catch (err) {
logger.error(`Reviewer prompt failed for ${reviewerName}`, err, {
scope: "review",
repository: backend.kind,
changeId: details.displayId,
reviewer: reviewerName,
console: false
});
// Store the result so the final throw can include stderr/exit code details
reviewerResult = err?.result ?? null;
}
if (reviewerName !== reviewersToTry[reviewersToTry.length - 1]) {
const msg = `${reviewer?.displayName || reviewerName} failed for ${details.displayId}; trying next reviewer...`;
logger.warn(msg, {
scope: "review",
repository: backend.kind,
changeId: details.displayId,
reviewer: reviewerName
});
}
}
if (!reviewerResult || reviewerResult.code !== 0 || reviewerResult.timedOut) {
const displayName = reviewer?.displayName || config.reviewer;
const reasonParts = [`${displayName} failed for ${details.displayId}`];
if (reviewerResult?.timedOut) {
reasonParts.push("(timed out)");
} else if (reviewerResult?.code != null && reviewerResult.code !== 0) {
reasonParts.push(`(exit code: ${reviewerResult.code})`);
} else if (!reviewerResult) {
reasonParts.push("(reviewer produced no result)");
}
const detail = (reviewerResult?.stderr || reviewerResult?.stdout || "").trim();
if (detail) {
reasonParts.push(`\n${detail}`);
}
const err = new Error(reasonParts.join(" "));
// Prefer reviewer-provided exit code when available (HTTP status or child exit code), otherwise 3
try {
err.exitCode = reviewerResult?.code || 3;
} catch {
err.exitCode = 3;
}
throw err;
}
progress?.update(0.82, "writing report");
logger.debug("Token usage recorded", {
scope: "review",
repository: backend.kind,
changeId: details.displayId,
reviewer: reviewer.displayName,
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
totalTokens: tokenUsage.totalTokens,
source: tokenUsage.source,
console: "debug"
});
const report = buildReport(currentReviewerConfig, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage);
const outputFile = path.join(config.outputDir, backend.getReportFileName(resolvedChangeId));
const jsonOutputFile = outputFile.replace(/\.md$/i, ".json");
if (shouldWriteFormat(config, "markdown")) {
await writeTextFile(outputFile, report);
}
if (shouldWriteFormat(config, "json")) {
await writeJsonFile(
jsonOutputFile,
buildJsonReport(currentReviewerConfig, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage)
);
}
const outputLabels = [
shouldWriteFormat(config, "markdown") ? `md: ${outputFile}` : null,
shouldWriteFormat(config, "json") ? `json: ${jsonOutputFile}` : null
].filter(Boolean);
logger.info(`Completed review for ${displayId}: ${outputLabels.join(" | ") || "(no report file generated)"}`, {
scope: "review",
repository: backend.kind,
changeId: displayId,
reviewer: reviewer.displayName,
console: true
});
return {
success: true,
outputFile: shouldWriteFormat(config, "markdown") ? outputFile : null,
jsonOutputFile: shouldWriteFormat(config, "json") ? jsonOutputFile : null,
details
};
}
function updateOverallProgress(progress, completedCount, totalCount, currentFraction, stage) {
if (!progress || totalCount <= 0) {
return;
}
const overallFraction = (completedCount + currentFraction) / totalCount;
progress.update(overallFraction, `${completedCount}/${totalCount} completed${stage ? ` | ${stage}` : ""}`);
}
export async function runReviewCycle(config) {
await ensureDir(config.outputDir);
const { backend, targetInfo } = await resolveRepositoryContext(config);
logger.debug("Resolved repository context", {
scope: "session",
backend: backend.kind,
target: targetInfo.targetDisplay || config.target,
console: "debug"
});
let changeIdsToReview = [];
if (config.uncommitted) {
changeIdsToReview = ["UNCOMMITTED"];
} else if (config.rev) {
changeIdsToReview = await backend.resolveChangeIds(config, targetInfo, config.rev);
} else if (config.last < 0) {
const candidates = await backend.getLatestChangeIds(config, targetInfo, Math.abs(config.last));
changeIdsToReview = candidates.length > 0 ? [candidates[0]] : [];
} else {
changeIdsToReview = await backend.getLatestChangeIds(config, targetInfo, config.last || 1);
}
if (changeIdsToReview.length === 0) {
logger.info("No changes found to review.", {
scope: "session",
backend: backend.kind,
target: targetInfo.targetDisplay || config.target,
console: true
});
return;
}
const isUncommittedBatch =
changeIdsToReview.length === 1 && changeIdsToReview[0] === "UNCOMMITTED";
const batchSummary = isUncommittedBatch
? `Reviewing ${backend.displayName} uncommitted changes`
: `Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`;
logger.info(batchSummary, {
scope: "session",
backend: backend.kind,
count: changeIdsToReview.length,
console: true
});
const progress = createProgressReporter(`${backend.displayName} ${backend.changeName} batch`);
progress.update(0, `0/${changeIdsToReview.length} completed`);
for (const [index, changeId] of changeIdsToReview.entries()) {
logger.debug(`Starting review for ${backend.formatChangeId(changeId)}`, {
scope: "review",
repository: backend.kind,
changeId: backend.formatChangeId(changeId),
console: "debug"
});
const displayId = backend.formatChangeId(changeId);
updateOverallProgress(progress, index, changeIdsToReview.length, 0, `starting ${displayId}`);
const syncOverallProgress = (fraction, stage) => {
updateOverallProgress(progress, index, changeIdsToReview.length, fraction, `${displayId} | ${stage}`);
};
try {
await reviewChange(config, backend, targetInfo, changeId, { update: syncOverallProgress });
updateOverallProgress(progress, index + 1, changeIdsToReview.length, 0, `finished ${displayId}`);
} catch (error) {
progress.finish("fail", `failed at ${displayId} (${index}/${changeIdsToReview.length} completed)`);
throw error;
}
}
progress.finish("done", `${backend.displayName} ${backend.changeName} batch complete`);
}
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runCommand } from "./shell.js";
import { prepareDiffPayloads } from "./diff-processor.js";
import { buildPrompt, getReviewWorkspaceRoot } from "./report-generator.js";
import { resolveTokenUsage } from "./token-usage.js";
function buildOpenAiRequestHeaders(config) {
const headers = {
"content-type": "application/json",
authorization: `Bearer ${config.openaiApiKey}`
};
if (config.openaiOrganization) {
headers["OpenAI-Organization"] = config.openaiOrganization;
}
if (config.openaiProject) {
headers["OpenAI-Project"] = config.openaiProject;
}
return headers;
}
function extractOpenAiMessageContent(content) {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((item) => {
if (typeof item === "string") {
return item;
}
if (item?.type === "text" && typeof item.text === "string") {
return item.text;
}
return "";
})
.filter(Boolean)
.join("\n");
}
export const REVIEWERS = {
codex: {
displayName: "Codex",
responseSectionTitle: "Codex Response",
emptyResponseText: "_No final response returned from codex exec._",
async run(config, workingDir, promptText, diffText) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kodevu-"));
const outputFile = path.join(tempDir, "codex-last-message.md");
const args = [
"exec",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--color",
"never",
"--output-last-message",
outputFile,
"-"
];
try {
const execResult = await runCommand("codex", args, {
cwd: workingDir,
input: [promptText, "Unified diff:", diffText].join("\n\n"),
allowFailure: true,
timeoutMs: config.commandTimeoutMs,
debug: config.debug
});
let message = "";
try {
message = await fs.readFile(outputFile, "utf8");
} catch {
message = execResult.stdout;
}
return {
...execResult,
message
};
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
},
gemini: {
displayName: "Gemini",
responseSectionTitle: "Gemini Response",
emptyResponseText: "_No final response returned from gemini._",
async run(config, workingDir, promptText, diffText) {
const execResult = await runCommand("gemini", ["-p", promptText], {
cwd: workingDir,
input: ["Unified diff:", diffText].join("\n\n"),
allowFailure: true,
timeoutMs: config.commandTimeoutMs,
debug: config.debug
});
return {
...execResult,
message: execResult.stdout
};
}
},
copilot: {
displayName: "Copilot",
responseSectionTitle: "Copilot Response",
emptyResponseText: "_No final response returned from copilot._",
async run(config, workingDir, promptText, diffText) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kodevu-copilot-"));
const reviewInputFile = path.join(tempDir, "review-input.md");
const copilotPrompt = [
`Use the file-reading tools to open this exact file path: ${reviewInputFile}`,
"That file contains the full review instructions and the unified diff to review.",
"Follow the instructions from that file exactly and output the final review directly.",
"Do not call any MCP tools or external tools except for reading the initial instruction file.",
"Do not ask clarifying questions. Do not mention tool usage. Do not say you are ready.",
"Start immediately with the review content."
].join("\n");
const args = [
"-p",
copilotPrompt,
"-s",
"--no-color",
"--no-ask-user",
"--no-custom-instructions",
"--allow-all-tools",
"--add-dir",
workingDir,
"--add-dir",
tempDir
];
try {
await fs.writeFile(
reviewInputFile,
[promptText, "### Unified Diff", "```diff", diffText, "```"].join("\n\n"),
"utf8"
);
const execResult = await runCommand("copilot", args, {
cwd: workingDir,
allowFailure: true,
timeoutMs: config.commandTimeoutMs,
debug: config.debug
});
return {
...execResult,
message: execResult.stdout
};
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
},
opencode: {
displayName: "OpenCode",
responseSectionTitle: "OpenCode Response",
emptyResponseText: "_No final response returned from opencode._",
async run(config, workingDir, promptText, diffText) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kodevu-opencode-"));
const reviewInputFile = path.join(tempDir, "review-input.md");
try {
await fs.writeFile(
reviewInputFile,
[promptText, "### Unified Diff", "```diff", diffText, "```"].join("\n\n"),
"utf8"
);
// Use a short message on the command line and pass full instructions via -f file
const args = ["run", "Review attached file", "-f", reviewInputFile, "--pure"];
const execResult = await runCommand("opencode", args, {
cwd: workingDir,
allowFailure: true,
timeoutMs: config.commandTimeoutMs,
debug: config.debug
});
const message = execResult.stdout || execResult.stderr || "";
// Treat non-zero exit codes as failures and throw so callers can handle them.
if (typeof execResult.code === "number" && execResult.code !== 0) {
const err = new Error(
`OpenCode exited with code ${execResult.code}: ${execResult.stderr || execResult.stdout || ""}`
);
// attach execResult for callers that want more details
err.execResult = execResult;
throw err;
}
return {
...execResult,
message
};
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
},
openai: {
displayName: "OpenAI API",
responseSectionTitle: "OpenAI Response",
emptyResponseText: "_No final response returned from the OpenAI API._",
async run(config, workingDir, promptText, diffText) {
const requestBody = {
model: config.openaiModel,
stream: true,
stream_options: { include_usage: true },
messages: [
{
role: "user",
content: [promptText, "Unified diff:", diffText].join("\n\n")
}
]
};
try {
const response = await fetch(`${config.openaiBaseUrl}/chat/completions`, {
method: "POST",
headers: buildOpenAiRequestHeaders(config),
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(config.commandTimeoutMs)
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
let responseText = "";
try {
responseText = await response.text();
const payload = JSON.parse(responseText);
errorMessage = payload?.error?.message || responseText;
} catch {
if (responseText) errorMessage = responseText;
}
return {
code: response.status,
timedOut: false,
stdout: "",
stderr: errorMessage,
message: ""
};
}
let message = "";
let usage = null;
let stdoutText = "";
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
const decoder = new TextDecoder("utf8");
let buffer = "";
for await (const chunk of response.body) {
const textChunk = decoder.decode(chunk, { stream: true });
stdoutText += textChunk;
buffer += textChunk;
const parts = buffer.split("\n");
buffer = parts.pop() || "";
for (const line of parts) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
if (trimmed === "data: [DONE]") continue;
try {
const data = JSON.parse(trimmed.slice(6));
if (data.choices?.[0]?.delta?.content) {
message += data.choices[0].delta.content;
}
if (data.usage) {
usage = {
inputTokens: Number(data.usage.prompt_tokens || 0),
outputTokens: Number(data.usage.completion_tokens || 0),
totalTokens: Number(data.usage.total_tokens || 0)
};
}
} catch (e) {
// Ignore JSON parse errors for individual chunks
}
}
}
}
} else {
stdoutText = await response.text();
let payload;
try {
payload = stdoutText ? JSON.parse(stdoutText) : {};
} catch {
payload = null;
}
message = extractOpenAiMessageContent(payload?.choices?.[0]?.message?.content);
if (payload?.usage) {
usage = {
inputTokens: Number(payload.usage.prompt_tokens || 0),
outputTokens: Number(payload.usage.completion_tokens || 0),
totalTokens: Number(payload.usage.total_tokens || 0)
};
}
}
return {
code: 0,
timedOut: false,
stdout: stdoutText,
stderr: "",
message,
usage
};
} catch (error) {
const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
const baseMessage = error?.message || String(error);
const causeMessage = error?.cause ? ` (Cause: ${error.cause.message || String(error.cause)})` : "";
return {
code: 1,
timedOut,
stdout: "",
stderr: `${baseMessage}${causeMessage}`,
message: ""
};
}
}
}
};
export async function runReviewerPrompt(config, backend, targetInfo, details, diffText) {
const reviewer = REVIEWERS[config.reviewer];
const reviewWorkspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
const diffPayloads = prepareDiffPayloads(config, diffText);
const promptText = buildPrompt(config, backend, targetInfo, details, diffPayloads.review);
const result = await reviewer.run(config, reviewWorkspaceRoot, promptText, diffPayloads.review.text);
const tokenUsage = resolveTokenUsage(
config.reviewer,
result.usage,
result.stderr,
promptText,
diffPayloads.review.text,
result.message
);
return {
reviewer,
diffPayloads,
result,
tokenUsage
};
}
import spawn from "cross-spawn";
import iconv from "iconv-lite";
import { logger } from "./logger.js";
function summarizeOutput(text) {
if (!text) {
return "(empty)";
}
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > 400 ? `${normalized.slice(0, 400)}...` : normalized;
}
export async function runCommand(command, args = [], options = {}) {
const {
cwd,
env,
input,
encoding = "utf8",
allowFailure = false,
timeoutMs = 0,
trim = false,
debug = false
} = options;
logger.debug(`run: ${command}`, {
scope: "command",
command,
args,
cwd: cwd || "",
timeoutMs: timeoutMs || 0,
input: input ? summarizeOutput(input) : "",
console: debug ? "debug" : false
});
return await new Promise((resolve, reject) => {
const startedAt = Date.now();
const child = spawn(command, args, {
cwd,
env: {
...process.env,
...env
},
stdio: ["pipe", "pipe", "pipe"]
});
const stdoutChunks = [];
const stderrChunks = [];
let timedOut = false;
let timer = null;
child.stdout.on("data", (chunk) => {
stdoutChunks.push(Buffer.from(chunk));
});
child.stderr.on("data", (chunk) => {
stderrChunks.push(Buffer.from(chunk));
});
child.on("error", (err) => {
logger.error(`spawn error: ${command}`, err, {
scope: "command",
command,
args,
cwd: cwd || ""
});
// Mark spawn/runtime errors as runtime failures (exit code 3)
try {
if (!err || typeof err !== "object") err = new Error(String(err));
if (err.exitCode == null) err.exitCode = 3;
} catch (e) {
// ignore
}
reject(err);
});
child.on("close", (code) => {
if (timer) {
clearTimeout(timer);
}
const stdout = iconv.decode(Buffer.concat(stdoutChunks), encoding);
const stderr = iconv.decode(Buffer.concat(stderrChunks), encoding);
const result = {
code: code ?? 1,
timedOut,
stdout: trim ? stdout.trim() : stdout,
stderr: trim ? stderr.trim() : stderr
};
const durationMs = Date.now() - startedAt;
const exitMeta = {
scope: "command",
command,
args,
cwd: cwd || "",
code: result.code,
timedOut: result.timedOut,
allowFailure,
durationMs,
stdout: summarizeOutput(result.stdout),
stderr: summarizeOutput(result.stderr),
console: debug ? "debug" : false
};
if ((result.code !== 0 || result.timedOut) && !allowFailure) {
logger.error(`exit: ${command}`, null, exitMeta);
} else {
logger.debug(`exit: ${command}`, exitMeta);
}
if ((result.code !== 0 || result.timedOut) && !allowFailure) {
const error = new Error(
`Command failed: ${command} ${args.join(" ")}\n${result.stderr || result.stdout}`.trim()
);
error.result = result;
// Map command failures to exit code 3 (runtime / external command failure)
error.exitCode = 3;
reject(error);
return;
}
resolve(result);
});
if (input) {
child.stdin.write(input);
}
child.stdin.end();
if (timeoutMs > 0) {
timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
}
});
}
export async function findCommandOnPath(command, options = {}) {
const locator = process.platform === "win32" ? "where" : "which";
const result = await runCommand(locator, [command], {
allowFailure: true,
trim: true,
debug: options.debug
});
if (result.code !== 0 || result.timedOut || !result.stdout) {
return null;
}
return (
result.stdout
.split(/\r?\n/)
.map((item) => item.trim())
.find(Boolean) || null
);
}
import path from "node:path";
import { XMLParser } from "fast-xml-parser";
import { runCommand } from "./shell.js";
const SVN_COMMAND = "svn";
const COMMAND_ENCODING = "utf8";
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: ""
});
function asArray(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function normalizeRepoPath(repoPath) {
if (!repoPath) {
return "/";
}
return repoPath.startsWith("/") ? repoPath : `/${repoPath}`;
}
function repoPathFromUrl(rootUrl, url) {
if (!url.startsWith(rootUrl)) {
throw new Error(`URL ${url} is not under repository root ${rootUrl}`);
}
const suffix = url.slice(rootUrl.length) || "/";
return normalizeRepoPath(suffix);
}
export async function getTargetInfo(config) {
const result = await runCommand(SVN_COMMAND, ["info", "--xml", config.target], {
encoding: COMMAND_ENCODING,
trim: true,
debug: config.debug
});
const parsed = xmlParser.parse(result.stdout);
const entry = parsed?.info?.entry;
if (!entry?.url || !entry?.repository?.root) {
throw new Error(`Unable to read svn info for target ${config.target}`);
}
const repoRootUrl = entry.repository.root;
const targetUrl = entry.url;
const targetRepoPath = repoPathFromUrl(repoRootUrl, targetUrl);
return {
repoRootUrl,
targetUrl,
targetRepoPath,
targetDisplay: config.target,
workingCopyPath:
entry["wc-info"]?.["wcroot-abspath"] || (path.isAbsolute(config.target) ? config.target : null)
};
}
function getRemoteTarget(targetInfo, config) {
return targetInfo?.targetUrl || config.target;
}
export async function getLatestRevision(config, targetInfo) {
const result = await runCommand(
SVN_COMMAND,
["log", "--xml", "-r", "HEAD:1", "-l", "1", getRemoteTarget(targetInfo, config)],
{ encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
);
const parsed = xmlParser.parse(result.stdout);
const entry = parsed?.log?.logentry;
const revision = Number(entry?.revision);
if (!Number.isInteger(revision)) {
throw new Error(`Unable to determine latest SVN revision for ${config.target}`);
}
return revision;
}
export async function getLatestRevisionIds(config, targetInfo, limit) {
const result = await runCommand(
SVN_COMMAND,
["log", "--xml", "--quiet", "-l", String(limit), "-r", "HEAD:1", getRemoteTarget(targetInfo, config)],
{ encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
);
const parsed = xmlParser.parse(result.stdout);
return asArray(parsed?.log?.logentry)
.map((entry) => Number(entry?.revision))
.filter((revision) => Number.isInteger(revision))
.sort((left, right) => left - right);
}
export async function getRevisionDiff(config, revision) {
const result = await runCommand(
SVN_COMMAND,
["diff", "--git", "--internal-diff", "--ignore-properties", "-c", String(revision), config.target],
{ encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
);
return result.stdout;
}
function isPathInsideTarget(targetRepoPath, repoPath) {
if (targetRepoPath === "/") {
return true;
}
return repoPath === targetRepoPath || repoPath.startsWith(`${targetRepoPath}/`);
}
function toRelativePath(targetRepoPath, repoPath) {
if (repoPath === targetRepoPath) {
return path.posix.basename(repoPath);
}
if (targetRepoPath === "/") {
return repoPath.replace(/^\/+/, "");
}
return repoPath.slice(targetRepoPath.length).replace(/^\/+/, "");
}
function parseSvnStatus(statusText) {
return statusText
.split(/\r?\n/)
.map((line) => line.replace(/\r$/, ""))
.filter(Boolean)
.map((line) => {
const action = (line[0] || " ").trim();
const relativePath = line.length > 8 ? line.slice(8).trim() : "";
return {
action,
relativePath,
previousPath: null
};
})
.filter((item) => item.relativePath)
.filter((item) => ["A", "D", "M", "R"].includes(item.action));
}
export async function getRevisionDetails(config, targetInfo, revision) {
const result = await runCommand(
SVN_COMMAND,
["log", "--xml", "-v", "-c", String(revision), getRemoteTarget(targetInfo, config)],
{ encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
);
const parsed = xmlParser.parse(result.stdout);
const entry = parsed?.log?.logentry;
if (!entry?.revision) {
throw new Error(`Unable to load SVN log for revision r${revision}`);
}
const changedPaths = asArray(entry.paths?.path)
.map((item) => {
const repoPath = normalizeRepoPath(item["#text"] || item);
return {
action: item.action || "M",
kind: item.kind || "unknown",
repoPath,
relativePath: toRelativePath(targetInfo.targetRepoPath, repoPath),
copyFromPath: item["copyfrom-path"] ? normalizeRepoPath(item["copyfrom-path"]) : null,
copyFromRev: item["copyfrom-rev"] ? Number(item["copyfrom-rev"]) : null
};
})
.filter((item) => isPathInsideTarget(targetInfo.targetRepoPath, item.repoPath))
.filter((item) => item.relativePath.length > 0)
.filter((item) => item.kind === "file" || item.kind === "unknown");
return {
revision: Number(entry.revision),
author: entry.author || "unknown",
date: entry.date || "",
message: entry.msg || "",
changedPaths
};
}
export async function getUncommittedDiff(config, targetInfo) {
if (!targetInfo.workingCopyPath) {
throw new Error("SVN --uncommitted requires a working copy path target.");
}
const result = await runCommand(
SVN_COMMAND,
["diff", "--git", "--internal-diff", "--ignore-properties", config.target],
{ encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
);
return result.stdout;
}
export async function getUncommittedDetails(config, targetInfo) {
if (!targetInfo.workingCopyPath) {
throw new Error("SVN --uncommitted requires a working copy path target.");
}
const statusResult = await runCommand(
SVN_COMMAND,
["status", config.target],
{ encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
);
return {
revision: "UNCOMMITTED",
author: "working-copy",
date: new Date().toISOString(),
message: "Uncommitted changes in working copy.",
changedPaths: parseSvnStatus(statusResult.stdout)
};
}
Related skills
How it compares
Use instead of manually copying diffs into chat for every commit when you want saved, repeatable review files.
FAQ
Who is kodevu for?
and small-team developers who ship from Git or SVN and want agent-guided runs of the kodevu CLI for commit or uncommitted diff reviews.
When should I use kodevu?
Use it in Ship (review) before merging—after your last commits, on a specific hash, on the last N commits, or on uncommitted changes when you want a report file.
Is kodevu safe to install?
The skill documents sending diffs to external AI reviewer CLIs; review the Security Audits panel on this page and treat API keys and diff content according to your repo policy.