qai-cli 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/qai-review-reusable.yml +82 -0
- package/.github/workflows/qai-review.yml +63 -0
- package/README.md +15 -2
- package/benchmarks/README.md +68 -0
- package/benchmarks/dataset/breaking-api-change.json +17 -0
- package/benchmarks/dataset/hardcoded-secrets.json +17 -0
- package/benchmarks/dataset/memory-leak.json +17 -0
- package/benchmarks/dataset/missing-error-handling.json +17 -0
- package/benchmarks/dataset/null-pointer.json +17 -0
- package/benchmarks/dataset/off-by-one.json +17 -0
- package/benchmarks/dataset/race-condition.json +17 -0
- package/benchmarks/dataset/sql-injection.json +17 -0
- package/benchmarks/dataset/unvalidated-input.json +17 -0
- package/benchmarks/dataset/xss-vulnerability.json +17 -0
- package/benchmarks/run.js +184 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
name: QAI Code Review (Reusable)
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
provider:
|
|
7
|
+
description: 'AI provider to use (anthropic or openai)'
|
|
8
|
+
required: false
|
|
9
|
+
type: string
|
|
10
|
+
default: 'anthropic'
|
|
11
|
+
node-version:
|
|
12
|
+
description: 'Node.js version'
|
|
13
|
+
required: false
|
|
14
|
+
type: string
|
|
15
|
+
default: '20'
|
|
16
|
+
qai-version:
|
|
17
|
+
description: 'qai-cli version (npm version specifier)'
|
|
18
|
+
required: false
|
|
19
|
+
type: string
|
|
20
|
+
default: 'latest'
|
|
21
|
+
secrets:
|
|
22
|
+
api-key:
|
|
23
|
+
description: 'API key for the chosen provider'
|
|
24
|
+
required: true
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
pull-requests: write
|
|
29
|
+
|
|
30
|
+
jobs:
|
|
31
|
+
review:
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- name: Checkout
|
|
35
|
+
uses: actions/checkout@v4
|
|
36
|
+
with:
|
|
37
|
+
fetch-depth: 0
|
|
38
|
+
|
|
39
|
+
- name: Setup Node.js
|
|
40
|
+
uses: actions/setup-node@v4
|
|
41
|
+
with:
|
|
42
|
+
node-version: ${{ inputs.node-version }}
|
|
43
|
+
|
|
44
|
+
- name: Install qai-cli
|
|
45
|
+
run: npm install -g qai-cli@${{ inputs.qai-version }}
|
|
46
|
+
|
|
47
|
+
- name: Run QAI Review
|
|
48
|
+
id: review
|
|
49
|
+
env:
|
|
50
|
+
ANTHROPIC_API_KEY: ${{ inputs.provider == 'anthropic' && secrets.api-key || '' }}
|
|
51
|
+
OPENAI_API_KEY: ${{ inputs.provider == 'openai' && secrets.api-key || '' }}
|
|
52
|
+
GH_TOKEN: ${{ github.token }}
|
|
53
|
+
run: |
|
|
54
|
+
set +e
|
|
55
|
+
REVIEW=$(qai review ${{ github.event.pull_request.number }} --json 2>&1)
|
|
56
|
+
EXIT_CODE=$?
|
|
57
|
+
echo "$REVIEW" > review-output.json
|
|
58
|
+
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
|
|
59
|
+
set -e
|
|
60
|
+
|
|
61
|
+
- name: Post Review Comment
|
|
62
|
+
env:
|
|
63
|
+
GH_TOKEN: ${{ github.token }}
|
|
64
|
+
run: |
|
|
65
|
+
BODY=$(cat review-output.json)
|
|
66
|
+
gh pr comment ${{ github.event.pull_request.number }} \
|
|
67
|
+
--body "## 🤖 QAI Code Review
|
|
68
|
+
|
|
69
|
+
<details>
|
|
70
|
+
<summary>Review Details</summary>
|
|
71
|
+
|
|
72
|
+
\`\`\`json
|
|
73
|
+
$BODY
|
|
74
|
+
\`\`\`
|
|
75
|
+
|
|
76
|
+
</details>"
|
|
77
|
+
|
|
78
|
+
- name: Fail on Critical Issues
|
|
79
|
+
if: steps.review.outputs.exit_code == '1'
|
|
80
|
+
run: |
|
|
81
|
+
echo "::error::QAI review found critical issues"
|
|
82
|
+
exit 1
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: QAI Code Review
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
review:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
|
|
20
|
+
- name: Setup Node.js
|
|
21
|
+
uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: 20
|
|
24
|
+
|
|
25
|
+
- name: Install qai-cli
|
|
26
|
+
run: npm install -g qai-cli
|
|
27
|
+
|
|
28
|
+
- name: Run QAI Review
|
|
29
|
+
id: review
|
|
30
|
+
env:
|
|
31
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
32
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
33
|
+
GH_TOKEN: ${{ github.token }}
|
|
34
|
+
run: |
|
|
35
|
+
set +e
|
|
36
|
+
REVIEW=$(qai review ${{ github.event.pull_request.number }} --json 2>&1)
|
|
37
|
+
EXIT_CODE=$?
|
|
38
|
+
echo "$REVIEW" > review-output.json
|
|
39
|
+
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
|
|
40
|
+
set -e
|
|
41
|
+
|
|
42
|
+
- name: Post Review Comment
|
|
43
|
+
env:
|
|
44
|
+
GH_TOKEN: ${{ github.token }}
|
|
45
|
+
run: |
|
|
46
|
+
BODY=$(cat review-output.json)
|
|
47
|
+
gh pr comment ${{ github.event.pull_request.number }} \
|
|
48
|
+
--body "## 🤖 QAI Code Review
|
|
49
|
+
|
|
50
|
+
<details>
|
|
51
|
+
<summary>Review Details</summary>
|
|
52
|
+
|
|
53
|
+
\`\`\`json
|
|
54
|
+
$BODY
|
|
55
|
+
\`\`\`
|
|
56
|
+
|
|
57
|
+
</details>"
|
|
58
|
+
|
|
59
|
+
- name: Fail on Critical Issues
|
|
60
|
+
if: steps.review.outputs.exit_code == '1'
|
|
61
|
+
run: |
|
|
62
|
+
echo "::error::QAI review found critical issues"
|
|
63
|
+
exit 1
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ VIEWPORTS=desktop,mobile,tablet qai scan https://mysite.com
|
|
|
29
29
|
FOCUS=accessibility qai scan https://mysite.com
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
### `qai review` — PR Code Review
|
|
32
|
+
### `qai review` — PR Code Review
|
|
33
33
|
|
|
34
34
|
Deep code review with full codebase context. Not just the diff — traces through dependencies, callers, and related tests.
|
|
35
35
|
|
|
@@ -41,7 +41,7 @@ qai review 42
|
|
|
41
41
|
qai review --base main
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
### `qai generate` — Test Generation
|
|
44
|
+
### `qai generate` — Test Generation
|
|
45
45
|
|
|
46
46
|
Auto-generate Playwright E2E tests from URLs or unit tests from source files.
|
|
47
47
|
|
|
@@ -106,6 +106,19 @@ Works with any major LLM. Set one env var:
|
|
|
106
106
|
- **Structured reports** — JSON + Markdown output
|
|
107
107
|
- **CI/CD ready** — GitHub Action + exit codes for pipelines
|
|
108
108
|
|
|
109
|
+
## How It Compares
|
|
110
|
+
|
|
111
|
+
| Feature | **qai** | Paragon | CodeRabbit | Cursor BugBot |
|
|
112
|
+
| ---------------------------------------------- | ----------------------- | --------- | ----------- | ------------- |
|
|
113
|
+
| Open source | ✅ | ❌ | ❌ | ❌ |
|
|
114
|
+
| Visual QA scanning | ✅ | ✅ | ❌ | ❌ |
|
|
115
|
+
| PR code review | ✅ | ❌ | ✅ | ✅ |
|
|
116
|
+
| Test generation | ✅ | ❌ | ❌ | ❌ |
|
|
117
|
+
| Multi-provider (Claude, GPT-4, Gemini, Ollama) | ✅ | ❌ | ❌ | ❌ |
|
|
118
|
+
| Local/offline mode (Ollama) | ✅ | ❌ | ❌ | ❌ |
|
|
119
|
+
| CLI + library + GitHub Action | ✅ | SaaS only | GitHub only | GitHub only |
|
|
120
|
+
| Free | ✅ (bring your own key) | Paid | Freemium | Freemium |
|
|
121
|
+
|
|
109
122
|
## License
|
|
110
123
|
|
|
111
124
|
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# qai Benchmark Suite
|
|
2
|
+
|
|
3
|
+
Measures code review accuracy across LLM providers.
|
|
4
|
+
|
|
5
|
+
## Methodology
|
|
6
|
+
|
|
7
|
+
The benchmark uses a curated dataset of **10 realistic code diffs**, each containing a known bug. Bug types span:
|
|
8
|
+
|
|
9
|
+
| Category | Cases |
|
|
10
|
+
| ---------------- | -------------------------------------------------------- |
|
|
11
|
+
| Security | SQL injection, XSS, hardcoded secrets, unvalidated input |
|
|
12
|
+
| Bugs | Null pointer / undefined access |
|
|
13
|
+
| Concurrency | Race condition (TOCTOU) |
|
|
14
|
+
| Error handling | Missing try/catch on file operations |
|
|
15
|
+
| Logic | Off-by-one in pagination |
|
|
16
|
+
| Performance | Memory leak / unclosed resources |
|
|
17
|
+
| Breaking changes | Public API signature change |
|
|
18
|
+
|
|
19
|
+
Each case includes:
|
|
20
|
+
|
|
21
|
+
- A unified diff (10-50 lines)
|
|
22
|
+
- Surrounding file context
|
|
23
|
+
- Expected issues with severity and category
|
|
24
|
+
|
|
25
|
+
## Scoring
|
|
26
|
+
|
|
27
|
+
For each test case the runner checks:
|
|
28
|
+
|
|
29
|
+
1. **True positive** — did the LLM identify the known bug? Matched via fuzzy keyword overlap on the issue description, category, and severity.
|
|
30
|
+
2. **False positives** — how many extra issues were reported beyond the expected ones.
|
|
31
|
+
3. **Latency** — wall-clock time per review call.
|
|
32
|
+
|
|
33
|
+
## Running
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Default provider (uses first available API key)
|
|
37
|
+
node benchmarks/run.js
|
|
38
|
+
|
|
39
|
+
# Specific provider
|
|
40
|
+
node benchmarks/run.js --provider anthropic
|
|
41
|
+
|
|
42
|
+
# JSON output to stdout
|
|
43
|
+
node benchmarks/run.js --json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Results are always saved to `benchmarks/results/`.
|
|
47
|
+
|
|
48
|
+
## Adding Test Cases
|
|
49
|
+
|
|
50
|
+
Create a new JSON file in `benchmarks/dataset/`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"name": "descriptive-slug",
|
|
55
|
+
"description": "What the bug is",
|
|
56
|
+
"diff": "... unified diff ...",
|
|
57
|
+
"context": { "files": { "path/to/file.js": "full file content" } },
|
|
58
|
+
"expectedIssues": [
|
|
59
|
+
{
|
|
60
|
+
"severity": "critical",
|
|
61
|
+
"category": "security",
|
|
62
|
+
"description": "Short description of expected finding"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then re-run the benchmark. The runner auto-discovers all `.json` files in the dataset directory.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "breaking-api-change",
|
|
3
|
+
"description": "Public API method signature changed without deprecation or major version bump",
|
|
4
|
+
"diff": "diff --git a/src/api/client.js b/src/api/client.js\nindex ab12cd3..ef45gh6 100644\n--- a/src/api/client.js\n+++ b/src/api/client.js\n@@ -15,14 +15,16 @@ class ApiClient {\n /**\n * Fetch a user by ID\n- * @param {string} userId\n- * @param {Object} [options]\n- * @returns {Promise<User>}\n+ * @param {Object} params\n+ * @param {string} params.userId\n+ * @param {string[]} [params.fields]\n+ * @returns {Promise<UserResponse>}\n */\n- async getUser(userId, options = {}) {\n- const res = await this.http.get(`/users/${userId}`, { params: options });\n- return res.data;\n+ async getUser({ userId, fields = ['id', 'name', 'email'] } = {}) {\n+ const query = fields.length ? `?fields=${fields.join(',')}` : '';\n+ const res = await this.http.get(`/users/${userId}${query}`);\n+ return { user: res.data, meta: { fields } };\n }\n \n /**\n * List all users",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/api/client.js": "const axios = require('axios');\n\nclass ApiClient {\n constructor(baseURL) {\n this.http = axios.create({ baseURL });\n }\n\n async getUser(userId, options = {}) {\n const res = await this.http.get(`/users/${userId}`, { params: options });\n return res.data;\n }\n}\n\nmodule.exports = { ApiClient };"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "high",
|
|
13
|
+
"category": "breaking-change",
|
|
14
|
+
"description": "Breaking API change: getUser() signature changed from (userId, options) to ({ userId, fields }), return type also changed"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hardcoded-secrets",
|
|
3
|
+
"description": "API keys and database credentials hardcoded in source",
|
|
4
|
+
"diff": "diff --git a/src/config/database.js b/src/config/database.js\nindex aabb112..ccdd334 100644\n--- a/src/config/database.js\n+++ b/src/config/database.js\n@@ -1,10 +1,14 @@\n-const dbUrl = process.env.DATABASE_URL;\n-const apiKey = process.env.STRIPE_API_KEY;\n+const dbUrl = 'postgresql://admin:s3cretPassw0rd!@prod-db.internal.company.com:5432/maindb';\n+const apiKey = 'sk_live_FAKE_EXAMPLE_KEY_NOT_REAL_1234567890';\n+const jwtSecret = 'my-super-secret-jwt-key-do-not-share';\n \n module.exports = {\n database: {\n connectionString: dbUrl,\n ssl: true,\n+ pool: { min: 2, max: 10 },\n },\n- stripe: { apiKey },\n+ stripe: { apiKey },\n+ jwt: { secret: jwtSecret, expiresIn: '7d' },\n };",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/config/database.js": "const dbUrl = process.env.DATABASE_URL;\nconst apiKey = process.env.STRIPE_API_KEY;\n\nmodule.exports = {\n database: {\n connectionString: dbUrl,\n ssl: true,\n },\n stripe: { apiKey },\n};"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "critical",
|
|
13
|
+
"category": "security",
|
|
14
|
+
"description": "Hardcoded production database credentials, Stripe live API key, and JWT secret in source code"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "memory-leak",
|
|
3
|
+
"description": "Database connection pool never closed, event listeners accumulate",
|
|
4
|
+
"diff": "diff --git a/src/services/analytics.js b/src/services/analytics.js\nindex 9a8b7c6..5d4e3f2 100644\n--- a/src/services/analytics.js\n+++ b/src/services/analytics.js\n@@ -3,18 +3,22 @@ const { Pool } = require('pg');\n class AnalyticsService {\n constructor(config) {\n this.config = config;\n+ this.pools = [];\n }\n \n async trackEvent(event) {\n- const pool = new Pool(this.config.database);\n- try {\n- const client = await pool.connect();\n- await client.query('INSERT INTO events (type, data, ts) VALUES ($1, $2, NOW())', [\n- event.type,\n- JSON.stringify(event.data),\n- ]);\n- client.release();\n- } finally {\n- await pool.end();\n- }\n+ const pool = new Pool(this.config.database);\n+ this.pools.push(pool);\n+ const client = await pool.connect();\n+ await client.query(\n+ 'INSERT INTO events (type, data, ts) VALUES ($1, $2, NOW())',\n+ [event.type, JSON.stringify(event.data)]\n+ );\n+ // client.release() removed for \"performance\"\n+ pool.on('error', (err) => {\n+ console.error('Pool error:', err);\n+ });\n+ return { success: true };\n }\n }",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/services/analytics.js": "const { Pool } = require('pg');\n\nclass AnalyticsService {\n constructor(config) {\n this.config = config;\n }\n\n async trackEvent(event) {\n const pool = new Pool(this.config.database);\n try {\n const client = await pool.connect();\n await client.query('INSERT INTO events (type, data, ts) VALUES ($1, $2, NOW())', [\n event.type, JSON.stringify(event.data)\n ]);\n client.release();\n } finally {\n await pool.end();\n }\n }\n}"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "high",
|
|
13
|
+
"category": "performance",
|
|
14
|
+
"description": "Memory leak: new Pool created per call and never closed, client never released, error listeners accumulate"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "missing-error-handling",
|
|
3
|
+
"description": "File operations without error handling or cleanup",
|
|
4
|
+
"diff": "diff --git a/src/utils/config.js b/src/utils/config.js\nindex 2a3b4c5..6d7e8f9 100644\n--- a/src/utils/config.js\n+++ b/src/utils/config.js\n@@ -5,15 +5,12 @@ const yaml = require('js-yaml');\n \n /**\n * Load and merge configuration from multiple sources\n- * @param {string[]} configPaths\n+ * @param {string} configPath\n * @returns {Object}\n */\n-function loadConfig(configPaths) {\n- const configs = configPaths.map((p) => {\n- try {\n- const raw = fs.readFileSync(p, 'utf8');\n- return yaml.load(raw);\n- } catch (err) {\n- console.warn(`Config not found: ${p}, skipping`);\n- return {};\n- }\n- });\n- return Object.assign({}, ...configs);\n+function loadConfig(configPath) {\n+ const raw = fs.readFileSync(configPath, 'utf8');\n+ const config = yaml.load(raw);\n+ const overridePath = configPath.replace('.yml', '.local.yml');\n+ const overrideRaw = fs.readFileSync(overridePath, 'utf8');\n+ const override = yaml.load(overrideRaw);\n+ return { ...config, ...override };\n }",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/utils/config.js": "const fs = require('fs');\nconst yaml = require('js-yaml');\n\nfunction loadConfig(configPaths) {\n const configs = configPaths.map((p) => {\n try {\n const raw = fs.readFileSync(p, 'utf8');\n return yaml.load(raw);\n } catch (err) {\n console.warn(`Config not found: ${p}, skipping`);\n return {};\n }\n });\n return Object.assign({}, ...configs);\n}\n\nmodule.exports = { loadConfig };"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "high",
|
|
13
|
+
"category": "error-handling",
|
|
14
|
+
"description": "No error handling for file read operations; will crash if config or override file is missing"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "null-pointer",
|
|
3
|
+
"description": "Null pointer access when optional nested object is missing",
|
|
4
|
+
"diff": "diff --git a/src/services/order.js b/src/services/order.js\nindex a1b2c3d..e4f5678 100644\n--- a/src/services/order.js\n+++ b/src/services/order.js\n@@ -24,9 +24,15 @@ class OrderService {\n * @returns {Object} formatted order summary\n */\n formatOrderSummary(order) {\n- return {\n- id: order.id,\n- total: order.total,\n- };\n+ const address = order.customer.shippingAddress;\n+ return {\n+ id: order.id,\n+ total: order.total,\n+ customerName: order.customer.name,\n+ shippingCity: address.city,\n+ shippingZip: address.zipCode,\n+ formattedAddress: `${address.street}, ${address.city}, ${address.state} ${address.zipCode}`,\n+ };\n }\n }",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/services/order.js": "class OrderService {\n constructor(db) {\n this.db = db;\n }\n\n async getOrder(id) {\n const order = await this.db.orders.findById(id);\n return order; // order.customer.shippingAddress may be null\n }\n\n formatOrderSummary(order) {\n return { id: order.id, total: order.total };\n }\n}"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "high",
|
|
13
|
+
"category": "bug",
|
|
14
|
+
"description": "Null/undefined access on order.customer.shippingAddress without null check"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "off-by-one",
|
|
3
|
+
"description": "Off-by-one error in pagination logic",
|
|
4
|
+
"diff": "diff --git a/src/utils/paginate.js b/src/utils/paginate.js\nindex 1122334..5566778 100644\n--- a/src/utils/paginate.js\n+++ b/src/utils/paginate.js\n@@ -8,10 +8,11 @@\n */\n function paginate(items, page, pageSize = 20) {\n const total = items.length;\n- const totalPages = Math.ceil(total / pageSize);\n- const start = (page - 1) * pageSize;\n+ const totalPages = Math.floor(total / pageSize);\n+ const start = page * pageSize;\n const end = start + pageSize;\n return {\n data: items.slice(start, end),\n page,\n+ pageSize,\n totalPages,\n total,\n };\n }",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/utils/paginate.js": "function paginate(items, page, pageSize = 20) {\n const total = items.length;\n const totalPages = Math.ceil(total / pageSize);\n const start = (page - 1) * pageSize;\n const end = start + pageSize;\n return {\n data: items.slice(start, end),\n page,\n totalPages,\n total,\n };\n}\n\nmodule.exports = { paginate };"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "medium",
|
|
13
|
+
"category": "logic",
|
|
14
|
+
"description": "Off-by-one: page 1 skips first pageSize items (0-indexed page with 1-indexed expectation), and Math.floor loses last partial page"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "race-condition",
|
|
3
|
+
"description": "TOCTOU race condition in balance check and withdrawal",
|
|
4
|
+
"diff": "diff --git a/src/services/wallet.js b/src/services/wallet.js\nindex 1a2b3c4..5d6e7f8 100644\n--- a/src/services/wallet.js\n+++ b/src/services/wallet.js\n@@ -10,12 +10,18 @@ class WalletService {\n }\n \n async withdraw(userId, amount) {\n- return this.db.transaction(async (trx) => {\n- const wallet = await trx('wallets').where({ user_id: userId }).forUpdate().first();\n- if (wallet.balance < amount) throw new Error('Insufficient funds');\n- await trx('wallets').where({ user_id: userId }).update({ balance: wallet.balance - amount });\n- return { newBalance: wallet.balance - amount };\n- });\n+ const wallet = await this.db('wallets').where({ user_id: userId }).first();\n+ if (!wallet) {\n+ throw new Error('Wallet not found');\n+ }\n+ if (wallet.balance < amount) {\n+ throw new Error('Insufficient funds');\n+ }\n+ const newBalance = wallet.balance - amount;\n+ await this.db('wallets')\n+ .where({ user_id: userId })\n+ .update({ balance: newBalance });\n+ return { newBalance };\n }\n }",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/services/wallet.js": "class WalletService {\n constructor(db) {\n this.db = db;\n }\n\n async withdraw(userId, amount) {\n return this.db.transaction(async (trx) => {\n const wallet = await trx('wallets').where({ user_id: userId }).forUpdate().first();\n if (wallet.balance < amount) throw new Error('Insufficient funds');\n await trx('wallets').where({ user_id: userId }).update({ balance: wallet.balance - amount });\n return { newBalance: wallet.balance - amount };\n });\n }\n}"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "critical",
|
|
13
|
+
"category": "concurrency",
|
|
14
|
+
"description": "Race condition: balance check and update are not atomic, removed transaction and FOR UPDATE lock"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sql-injection",
|
|
3
|
+
"description": "SQL injection via string concatenation in user lookup query",
|
|
4
|
+
"diff": "diff --git a/src/routes/users.js b/src/routes/users.js\nindex 3a1b2c3..4d5e6f7 100644\n--- a/src/routes/users.js\n+++ b/src/routes/users.js\n@@ -12,8 +12,12 @@ const db = require('../db');\n \n router.get('/users/search', async (req, res) => {\n const { username } = req.query;\n- const users = await db.query('SELECT id, username, email FROM users WHERE username = $1', [username]);\n- res.json(users.rows);\n+ if (!username) {\n+ return res.status(400).json({ error: 'username is required' });\n+ }\n+ const query = `SELECT id, username, email FROM users WHERE username = '${username}'`;\n+ const users = await db.query(query);\n+ return res.json(users.rows);\n });\n \n router.get('/users/:id', async (req, res) => {",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/routes/users.js": "const express = require('express');\nconst router = express.Router();\nconst db = require('../db');\n\nrouter.get('/users/search', async (req, res) => {\n const { username } = req.query;\n const users = await db.query('SELECT id, username, email FROM users WHERE username = $1', [username]);\n res.json(users.rows);\n});"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "critical",
|
|
13
|
+
"category": "security",
|
|
14
|
+
"description": "SQL injection via string interpolation instead of parameterized query"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "unvalidated-input",
|
|
3
|
+
"description": "User input used directly in file path and shell command without validation",
|
|
4
|
+
"diff": "diff --git a/src/routes/export.js b/src/routes/export.js\nindex aabb123..ccdd456 100644\n--- a/src/routes/export.js\n+++ b/src/routes/export.js\n@@ -4,12 +4,18 @@ const { execSync } = require('child_process');\n \n router.post('/export', async (req, res) => {\n- const { format } = req.body;\n- const allowed = ['csv', 'json', 'xml'];\n- if (!allowed.includes(format)) {\n- return res.status(400).json({ error: 'Invalid format' });\n- }\n- const data = await db.reports.getAll();\n- const file = exportService.generate(data, format);\n- res.download(file);\n+ const { format, filename, startDate, endDate } = req.body;\n+ const data = await db.reports.getAll();\n+ const outputPath = `/tmp/exports/${filename}.${format}`;\n+ fs.writeFileSync(outputPath, exportService.serialize(data, format));\n+ // Compress for large exports\n+ if (req.body.compress) {\n+ execSync(`gzip ${outputPath}`);\n+ return res.download(`${outputPath}.gz`);\n+ }\n+ res.download(outputPath);\n });",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/routes/export.js": "const express = require('express');\nconst router = express.Router();\nconst db = require('../db');\nconst fs = require('fs');\nconst exportService = require('../services/export');\nconst { execSync } = require('child_process');\n\nrouter.post('/export', async (req, res) => {\n const { format } = req.body;\n const allowed = ['csv', 'json', 'xml'];\n if (!allowed.includes(format)) {\n return res.status(400).json({ error: 'Invalid format' });\n }\n const data = await db.reports.getAll();\n const file = exportService.generate(data, format);\n res.download(file);\n});\n\nmodule.exports = router;"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "critical",
|
|
13
|
+
"category": "security",
|
|
14
|
+
"description": "Unvalidated input: filename and format used in file path (path traversal) and shell command (command injection), format whitelist removed"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xss-vulnerability",
|
|
3
|
+
"description": "User input rendered as raw HTML without sanitization",
|
|
4
|
+
"diff": "diff --git a/src/views/profile.js b/src/views/profile.js\nindex 1234abc..5678def 100644\n--- a/src/views/profile.js\n+++ b/src/views/profile.js\n@@ -6,11 +6,17 @@ const express = require('express');\n router.get('/profile/:id', async (req, res) => {\n const user = await db.users.findById(req.params.id);\n- res.render('profile', {\n- name: user.displayName,\n- bio: user.bio,\n- });\n+ const html = `\n+ <html>\n+ <body>\n+ <h1>${user.displayName}</h1>\n+ <div class=\"bio\">${user.bio}</div>\n+ <div class=\"website\"><a href=\"${user.website}\">${user.website}</a></div>\n+ <div class=\"location\">${user.location}</div>\n+ </body>\n+ </html>`;\n+ res.setHeader('Content-Type', 'text/html');\n+ res.send(html);\n });",
|
|
5
|
+
"context": {
|
|
6
|
+
"files": {
|
|
7
|
+
"src/views/profile.js": "const express = require('express');\nconst router = express.Router();\nconst db = require('../db');\n\nrouter.get('/profile/:id', async (req, res) => {\n const user = await db.users.findById(req.params.id);\n res.render('profile', {\n name: user.displayName,\n bio: user.bio,\n });\n});\n\nmodule.exports = router;"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"expectedIssues": [
|
|
11
|
+
{
|
|
12
|
+
"severity": "critical",
|
|
13
|
+
"category": "security",
|
|
14
|
+
"description": "XSS vulnerability: user-controlled fields (displayName, bio, website) interpolated directly into HTML without escaping"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Benchmark runner for qai code review accuracy.
|
|
5
|
+
*
|
|
6
|
+
* Loads curated diffs with known bugs, runs them through each available
|
|
7
|
+
* provider, and scores true-positive rate, false-positive count, and latency.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node benchmarks/run.js [--provider <name>] [--json]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { getProvider } = require('../src/providers');
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function loadDataset() {
|
|
22
|
+
const dir = path.join(__dirname, 'dataset');
|
|
23
|
+
return fs
|
|
24
|
+
.readdirSync(dir)
|
|
25
|
+
.filter((f) => f.endsWith('.json'))
|
|
26
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Determine whether the review found the expected issue.
|
|
31
|
+
* We do a fuzzy keyword match on severity, category, and description.
|
|
32
|
+
*/
|
|
33
|
+
function scoreResult(review, expected) {
|
|
34
|
+
if (!review || !review.issues || !Array.isArray(review.issues)) {
|
|
35
|
+
return { detected: false, falsePositives: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const found = expected.every((exp) => {
|
|
39
|
+
return review.issues.some((issue) => {
|
|
40
|
+
const descMatch = matchDescription(issue.description || issue.message || '', exp.description);
|
|
41
|
+
const catMatch =
|
|
42
|
+
!exp.category || (issue.category || '').toLowerCase().includes(exp.category.toLowerCase());
|
|
43
|
+
const sevMatch =
|
|
44
|
+
!exp.severity || (issue.severity || '').toLowerCase().includes(exp.severity.toLowerCase());
|
|
45
|
+
// A match on description alone is sufficient; category/severity are bonus signals
|
|
46
|
+
return descMatch || (catMatch && sevMatch);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// False positives = total issues minus expected matches
|
|
51
|
+
const falsePositives = Math.max(0, review.issues.length - expected.length);
|
|
52
|
+
|
|
53
|
+
return { detected: found, falsePositives };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchDescription(actual, expected) {
|
|
57
|
+
// Extract key terms from the expected description and check if most appear
|
|
58
|
+
const keywords = expected
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.split(/[\s,/]+/)
|
|
61
|
+
.filter((w) => w.length > 3);
|
|
62
|
+
const normalised = actual.toLowerCase();
|
|
63
|
+
const hits = keywords.filter((kw) => normalised.includes(kw));
|
|
64
|
+
return hits.length >= Math.ceil(keywords.length * 0.4);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Main
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const args = process.argv.slice(2);
|
|
73
|
+
const jsonOutput = args.includes('--json');
|
|
74
|
+
const providerIdx = args.indexOf('--provider');
|
|
75
|
+
const providerName = providerIdx !== -1 ? args[providerIdx + 1] : undefined;
|
|
76
|
+
|
|
77
|
+
const dataset = loadDataset();
|
|
78
|
+
console.log(`\nLoaded ${dataset.length} benchmark cases\n`);
|
|
79
|
+
|
|
80
|
+
let provider;
|
|
81
|
+
try {
|
|
82
|
+
provider = getProvider(providerName);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(
|
|
85
|
+
`Failed to initialise provider${providerName ? ` "${providerName}"` : ''}: ${err.message}`,
|
|
86
|
+
);
|
|
87
|
+
console.error('Set an API key (e.g. ANTHROPIC_API_KEY) or specify --provider <name>');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const providerLabel = provider.constructor.name || 'unknown';
|
|
92
|
+
console.log(`Provider: ${providerLabel}\n`);
|
|
93
|
+
|
|
94
|
+
const results = [];
|
|
95
|
+
let detected = 0;
|
|
96
|
+
let totalFP = 0;
|
|
97
|
+
let totalMs = 0;
|
|
98
|
+
|
|
99
|
+
for (const testCase of dataset) {
|
|
100
|
+
const label = testCase.name.padEnd(25);
|
|
101
|
+
process.stdout.write(` ${label} … `);
|
|
102
|
+
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
let review;
|
|
105
|
+
let error = null;
|
|
106
|
+
try {
|
|
107
|
+
review = await provider.reviewCode(testCase.diff, testCase.context || {}, {
|
|
108
|
+
focus: 'all',
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
error = err.message;
|
|
112
|
+
}
|
|
113
|
+
const elapsed = Date.now() - start;
|
|
114
|
+
totalMs += elapsed;
|
|
115
|
+
|
|
116
|
+
if (error) {
|
|
117
|
+
console.log(`ERROR (${elapsed}ms) — ${error}`);
|
|
118
|
+
results.push({
|
|
119
|
+
name: testCase.name,
|
|
120
|
+
detected: false,
|
|
121
|
+
falsePositives: 0,
|
|
122
|
+
elapsed,
|
|
123
|
+
error,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const score = scoreResult(review, testCase.expectedIssues);
|
|
129
|
+
if (score.detected) detected++;
|
|
130
|
+
totalFP += score.falsePositives;
|
|
131
|
+
|
|
132
|
+
const icon = score.detected ? '✅' : '❌';
|
|
133
|
+
console.log(
|
|
134
|
+
`${icon} ${elapsed}ms (FP: ${score.falsePositives}, issues: ${(review.issues || []).length})`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
results.push({
|
|
138
|
+
name: testCase.name,
|
|
139
|
+
detected: score.detected,
|
|
140
|
+
falsePositives: score.falsePositives,
|
|
141
|
+
issuesFound: (review.issues || []).length,
|
|
142
|
+
elapsed,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Summary
|
|
147
|
+
const tpr = ((detected / dataset.length) * 100).toFixed(1);
|
|
148
|
+
const avgMs = (totalMs / dataset.length).toFixed(0);
|
|
149
|
+
|
|
150
|
+
console.log('\n' + '═'.repeat(60));
|
|
151
|
+
console.log(` True-positive rate : ${detected}/${dataset.length} (${tpr}%)`);
|
|
152
|
+
console.log(` Total false positives: ${totalFP}`);
|
|
153
|
+
console.log(` Avg time per review : ${avgMs}ms (total ${(totalMs / 1000).toFixed(1)}s)`);
|
|
154
|
+
console.log('═'.repeat(60) + '\n');
|
|
155
|
+
|
|
156
|
+
const report = {
|
|
157
|
+
provider: providerLabel,
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
cases: results,
|
|
160
|
+
summary: {
|
|
161
|
+
total: dataset.length,
|
|
162
|
+
detected,
|
|
163
|
+
truePositiveRate: parseFloat(tpr),
|
|
164
|
+
totalFalsePositives: totalFP,
|
|
165
|
+
avgTimeMs: parseInt(avgMs, 10),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (jsonOutput) {
|
|
170
|
+
console.log(JSON.stringify(report, null, 2));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Always write report to disk
|
|
174
|
+
const outDir = path.join(__dirname, 'results');
|
|
175
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
176
|
+
const outFile = path.join(outDir, `report-${providerLabel.toLowerCase()}-${Date.now()}.json`);
|
|
177
|
+
fs.writeFileSync(outFile, JSON.stringify(report, null, 2));
|
|
178
|
+
console.log(`Report saved to ${outFile}\n`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
main().catch((err) => {
|
|
182
|
+
console.error('Benchmark failed:', err);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
});
|