lore-review 0.1.0__tar.gz
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.
- lore_review-0.1.0/.github/workflows/lore-review.yml +90 -0
- lore_review-0.1.0/.gitignore +6 -0
- lore_review-0.1.0/PKG-INFO +9 -0
- lore_review-0.1.0/README.md +274 -0
- lore_review-0.1.0/action.yml +84 -0
- lore_review-0.1.0/docs/QUICKSTART.md +30 -0
- lore_review-0.1.0/lore_review/__init__.py +2 -0
- lore_review-0.1.0/lore_review/agents/__init__.py +0 -0
- lore_review-0.1.0/lore_review/agents/council.py +128 -0
- lore_review-0.1.0/lore_review/agents/scout.py +16 -0
- lore_review-0.1.0/lore_review/agents/sentinel.py +71 -0
- lore_review-0.1.0/lore_review/cli.py +76 -0
- lore_review-0.1.0/lore_review/darwin_store.py +64 -0
- lore_review-0.1.0/lore_review/graph_reader.py +37 -0
- lore_review-0.1.0/lore_review/models.py +44 -0
- lore_review-0.1.0/lore_review/review_pipeline.py +42 -0
- lore_review-0.1.0/pyproject.toml +18 -0
- lore_review-0.1.0/tests/__init__.py +0 -0
- lore_review-0.1.0/tests/test_darwin_store.py +39 -0
- lore_review-0.1.0/tests/test_models.py +22 -0
- lore_review-0.1.0/tests/test_pipeline.py +26 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
name: Lore Review
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
lore-review:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
pull-requests: write
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
|
|
20
|
+
- name: Install lore-review from source
|
|
21
|
+
run: pip install -e .
|
|
22
|
+
|
|
23
|
+
- name: Get PR diff
|
|
24
|
+
run: git diff origin/${{ github.base_ref }}...HEAD > /tmp/pr.diff
|
|
25
|
+
|
|
26
|
+
- name: Run lore-review (GitHub annotations + fail on CRITICAL)
|
|
27
|
+
run: |
|
|
28
|
+
lore-review \
|
|
29
|
+
--repo . \
|
|
30
|
+
--diff /tmp/pr.diff \
|
|
31
|
+
--pr-id ${{ github.event.number }} \
|
|
32
|
+
--format github \
|
|
33
|
+
--fail-on critical \
|
|
34
|
+
2>&1 | tee /tmp/review_annotations.txt
|
|
35
|
+
# Capture exit code before tee swallows it
|
|
36
|
+
exit ${PIPESTATUS[0]}
|
|
37
|
+
|
|
38
|
+
- name: Run lore-review JSON (for PR comment)
|
|
39
|
+
if: always()
|
|
40
|
+
run: |
|
|
41
|
+
lore-review \
|
|
42
|
+
--repo . \
|
|
43
|
+
--diff /tmp/pr.diff \
|
|
44
|
+
--pr-id ${{ github.event.number }} \
|
|
45
|
+
--output json \
|
|
46
|
+
--fail-on never \
|
|
47
|
+
> /tmp/review.json || true
|
|
48
|
+
|
|
49
|
+
- name: Post PR comment
|
|
50
|
+
if: always()
|
|
51
|
+
uses: actions/github-script@v7
|
|
52
|
+
with:
|
|
53
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
54
|
+
script: |
|
|
55
|
+
const fs = require('fs');
|
|
56
|
+
const result = JSON.parse(fs.readFileSync('/tmp/review.json', 'utf8'));
|
|
57
|
+
const findings = result.verdict.findings;
|
|
58
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
59
|
+
findings.forEach(f => counts[f.severity] = (counts[f.severity] || 0) + 1);
|
|
60
|
+
|
|
61
|
+
const rows = findings.slice(0, 20).map(f => {
|
|
62
|
+
const icon = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '⚪' }[f.severity] || '⚪';
|
|
63
|
+
return `| ${icon} ${f.severity.toUpperCase()} | ${f.file_path}:${f.line_start} | ${f.message} |`;
|
|
64
|
+
}).join('\n');
|
|
65
|
+
|
|
66
|
+
const hasCritical = counts.critical > 0;
|
|
67
|
+
const statusLine = hasCritical ? '❌ **FAILED** — CRITICAL findings block merge.' : '✅ **PASSED** — No critical issues.';
|
|
68
|
+
|
|
69
|
+
const body = [
|
|
70
|
+
'## 🔍 Lore Review',
|
|
71
|
+
'',
|
|
72
|
+
statusLine,
|
|
73
|
+
'',
|
|
74
|
+
`**Findings:** ${findings.length} issues (${counts.critical} critical, ${counts.high} high, ${counts.medium} medium)`,
|
|
75
|
+
`**Darwin rules applied:** ${result.verdict.immunity_rules_applied}`,
|
|
76
|
+
`**Cost:** $${result.total_cost_usd.toFixed(4)}`,
|
|
77
|
+
'',
|
|
78
|
+
findings.length > 0
|
|
79
|
+
? '| Severity | File | Issue |\n|----------|------|-------|\n' + rows
|
|
80
|
+
: '✅ No issues found.',
|
|
81
|
+
'',
|
|
82
|
+
'*Powered by [lore-review](https://github.com/Miles0sage/lore-review) — gets smarter with every PR*'
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
github.rest.issues.createComment({
|
|
86
|
+
owner: context.repo.owner,
|
|
87
|
+
repo: context.repo.repo,
|
|
88
|
+
issue_number: context.issue.number,
|
|
89
|
+
body
|
|
90
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lore-review
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The code reviewer that knows your codebase — and gets smarter every time it's wrong
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.110.0
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0.0
|
|
9
|
+
Requires-Dist: uvicorn>=0.27.0
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
  
|
|
2
|
+
|
|
3
|
+
# lore-review
|
|
4
|
+
|
|
5
|
+
**$0.004 per PR. Not $15.**
|
|
6
|
+
|
|
7
|
+
Anthropic charges $15/PR for code review. CodeRabbit is $20/month with a per-seat cap. lore-review runs 4 specialist AI workers in parallel, catches critical security bugs, and costs less than a rounding error — because it uses cheap frontier models routed intelligently, not a vendor margin.
|
|
8
|
+
|
|
9
|
+
And it gets smarter the longer you run it. Every repo gets its own Darwin learning layer. Failures cluster into rules. Rules become immunity. Your team's false positives stop recurring automatically.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it caught in one real run
|
|
14
|
+
|
|
15
|
+
36 findings. $0.004. 1.8 seconds. 0.85 consensus score.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
$ git diff main...HEAD | lore-review scan -
|
|
19
|
+
|
|
20
|
+
lore-review v0.3.1 — Scout → Council → Sentinel → Darwin
|
|
21
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
22
|
+
|
|
23
|
+
[Scout] Mapped 6 changed files, 247 lines added
|
|
24
|
+
[Council] Dispatching 4 workers in parallel...
|
|
25
|
+
› security ████████████ done (0.61s)
|
|
26
|
+
› performance ████████████ done (0.58s)
|
|
27
|
+
› correctness ████████████ done (0.63s)
|
|
28
|
+
› style ████████████ done (0.52s)
|
|
29
|
+
[Sentinel] Deduplicating 41 raw findings → 36 unique
|
|
30
|
+
[Darwin] Checked 36 findings against repo ruleset (0 suppressed)
|
|
31
|
+
|
|
32
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
33
|
+
FINDINGS (36 total · 4 critical · 3 high · 2 medium)
|
|
34
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
35
|
+
|
|
36
|
+
[CRITICAL] db/queries.py:47 SQL Injection
|
|
37
|
+
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
|
38
|
+
→ f-string interpolation in SQL. Use parameterized queries.
|
|
39
|
+
|
|
40
|
+
[CRITICAL] utils/runner.py:112 Command Injection
|
|
41
|
+
subprocess.run(cmd, shell=True) # cmd contains user input
|
|
42
|
+
→ shell=True with unsanitized input. Attacker can escape.
|
|
43
|
+
|
|
44
|
+
[CRITICAL] config/settings.py:8 Hardcoded Secret
|
|
45
|
+
API_KEY = "sk-prod-xK92mLpQ..."
|
|
46
|
+
→ Live API key in source. Rotate immediately. Use env vars.
|
|
47
|
+
|
|
48
|
+
[CRITICAL] api/eval.py:31 Arbitrary Code Execution
|
|
49
|
+
result = eval(user_expression)
|
|
50
|
+
→ eval() on untrusted input. Use ast.literal_eval or sandbox.
|
|
51
|
+
|
|
52
|
+
[HIGH] analytics/reports.py:89 O(n²) Complexity
|
|
53
|
+
for item in items:
|
|
54
|
+
for other in items: # nested scan
|
|
55
|
+
→ Quadratic loop over same list. Use set() for O(n) lookup.
|
|
56
|
+
|
|
57
|
+
[HIGH] workers/poller.py:203 Infinite Loop
|
|
58
|
+
while True:
|
|
59
|
+
process_queue()
|
|
60
|
+
# no exit condition, no sleep, no break
|
|
61
|
+
→ Loop has no exit path. Will spin CPU to 100% and hang.
|
|
62
|
+
|
|
63
|
+
[HIGH] db/connection.py:61 Resource Leak
|
|
64
|
+
conn = psycopg2.connect(dsn)
|
|
65
|
+
# ... 40 lines of logic, no conn.close(), no context manager
|
|
66
|
+
→ DB connection never closed. Use `with` or explicit close().
|
|
67
|
+
|
|
68
|
+
[MEDIUM] api/fetch.py:18 Missing Timeout
|
|
69
|
+
response = urllib.request.urlopen(url)
|
|
70
|
+
→ No timeout set. Will hang indefinitely on slow servers.
|
|
71
|
+
|
|
72
|
+
[MEDIUM] utils/dedup.py:34 Logic Error
|
|
73
|
+
def find_duplicates(items):
|
|
74
|
+
seen = []
|
|
75
|
+
return [x for x in items if x in seen or seen.append(x)]
|
|
76
|
+
→ seen.append() always returns None. Duplicates never found.
|
|
77
|
+
|
|
78
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
79
|
+
COST $0.004 | TIME 1.83s | CONSENSUS 0.85
|
|
80
|
+
Darwin learned 0 new rules (36/36 findings are novel)
|
|
81
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Four criticals in one diff. SQL injection, command injection, a live API key, and an eval() call — all in the same PR. All caught before merge. For less than half a cent.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Try the live demo
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/Miles0sage/lore-review-demo
|
|
92
|
+
cd lore-review-demo
|
|
93
|
+
bash run_demo.sh
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
See lore-review catch SQL injection, command injection, hardcoded secrets, `eval()`, `exec()`, and O(n²) in a realistic production-style agent. Real output, real cost ($0.004).
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Install
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Not on PyPI yet — install from source:
|
|
104
|
+
git clone https://github.com/Miles0sage/lore-review
|
|
105
|
+
cd lore-review
|
|
106
|
+
pip install -e .
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Set your model provider key (any OpenAI-compatible endpoint works):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
export OPENAI_API_KEY=sk-... # OpenAI, Together, Groq, etc.
|
|
113
|
+
# or
|
|
114
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Usage
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Scan a diff file
|
|
123
|
+
lore-review scan changes.patch
|
|
124
|
+
|
|
125
|
+
# Pipe from git directly
|
|
126
|
+
git diff main...HEAD | lore-review scan -
|
|
127
|
+
|
|
128
|
+
# Review a GitHub PR by URL
|
|
129
|
+
lore-review pr https://github.com/owner/repo/pull/123
|
|
130
|
+
|
|
131
|
+
# JSON output (for CI pipelines)
|
|
132
|
+
lore-review scan changes.patch --output json
|
|
133
|
+
|
|
134
|
+
# Fail CI on critical/high findings
|
|
135
|
+
lore-review scan changes.patch --fail-on critical,high
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### GitHub Actions
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
- name: lore-review
|
|
142
|
+
run: |
|
|
143
|
+
pip install lore-review
|
|
144
|
+
git diff ${{ github.base_ref }}...${{ github.sha }} | \
|
|
145
|
+
lore-review scan - --fail-on critical
|
|
146
|
+
env:
|
|
147
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Darwin: gets smarter every review
|
|
153
|
+
|
|
154
|
+
Most code reviewers have no memory. They make the same false-positive call on your test utilities every single PR. You suppress it manually. It comes back next week.
|
|
155
|
+
|
|
156
|
+
Darwin fixes this at the repo level.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
Review 1: Council flags "eval() usage" in tests/sandbox.py ← false positive
|
|
160
|
+
You mark it: lore-review suppress --id FINDING_42
|
|
161
|
+
|
|
162
|
+
Review 5: Same pattern flagged again in tests/another.py
|
|
163
|
+
Darwin: 2 occurrences of same pattern → compile immunity rule
|
|
164
|
+
|
|
165
|
+
Review 6+: Council receives rule before analysis:
|
|
166
|
+
"eval() in tests/sandbox.py is intentional — skip"
|
|
167
|
+
Finding never surfaces again.
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Immunity rules live in `.lore-review/darwin.db` — a SQLite file in your repo root. Commit it, share it, version it. Your team's collective suppressions become the ruleset every new contributor inherits automatically.
|
|
171
|
+
|
|
172
|
+
Over time, the Council stops wasting your time on known patterns and focuses on novel issues. The longer you run lore-review, the higher the signal-to-noise ratio gets.
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# View learned rules
|
|
176
|
+
lore-review darwin list
|
|
177
|
+
|
|
178
|
+
# Export rules (share with another repo)
|
|
179
|
+
lore-review darwin export > rules.json
|
|
180
|
+
|
|
181
|
+
# Import rules
|
|
182
|
+
lore-review darwin import rules.json
|
|
183
|
+
|
|
184
|
+
# Manually suppress a finding
|
|
185
|
+
lore-review suppress --id FINDING_42 --reason "intentional sandbox"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Architecture
|
|
191
|
+
|
|
192
|
+
Four stages, each single-responsibility:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
PR Diff
|
|
196
|
+
│
|
|
197
|
+
▼
|
|
198
|
+
Scout — reads the diff, maps changed files, extracts symbol graph context
|
|
199
|
+
│
|
|
200
|
+
▼
|
|
201
|
+
Council — 4 specialist workers in parallel: Security / Perf / Correctness / Style
|
|
202
|
+
│ each scores independently, no cross-contamination
|
|
203
|
+
▼
|
|
204
|
+
Sentinel — deduplicates overlapping findings, computes consensus score
|
|
205
|
+
│
|
|
206
|
+
▼
|
|
207
|
+
Darwin — checks findings against repo immunity rules, clusters new patterns,
|
|
208
|
+
promotes recurring patterns to rules after threshold hit
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The Council workers run in parallel. Total latency is bounded by the slowest worker, not their sum. On a typical PR, that's under 2 seconds.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Cost comparison
|
|
216
|
+
|
|
217
|
+
| Tool | Cost | Notes |
|
|
218
|
+
|------|------|-------|
|
|
219
|
+
| Anthropic Claude (direct) | ~$15/PR | Estimated at typical PR size and Claude pricing |
|
|
220
|
+
| CodeRabbit | $20/month | Per-seat, capped PRs, no per-repo learning |
|
|
221
|
+
| GitHub Copilot PR review | $19/month | Per seat, limited to Copilot model |
|
|
222
|
+
| **lore-review** | **$0.004/PR** | Parallel cheap models, Darwin learning, your API key |
|
|
223
|
+
|
|
224
|
+
lore-review uses your API key directly. No margin, no middleman. The $0.004 figure is from a real 247-line diff reviewed across 4 workers using Qwen/Groq-tier pricing. On Claude or GPT-4o, expect $0.01–$0.05/PR — still 300x cheaper than going through a vendor.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## What the Council checks
|
|
229
|
+
|
|
230
|
+
**Security worker** — OWASP Top 10, injection vectors, hardcoded secrets, insecure deserialization, path traversal, XXE, broken auth patterns, exposed credentials.
|
|
231
|
+
|
|
232
|
+
**Performance worker** — algorithmic complexity, N+1 queries, missing indexes, memory leaks, blocking I/O in async contexts, resource exhaustion vectors.
|
|
233
|
+
|
|
234
|
+
**Correctness worker** — logic errors, off-by-one, null dereferences, race conditions, missing error handling, unclosed resources, silent failures.
|
|
235
|
+
|
|
236
|
+
**Style worker** — dead code, naming conventions, duplication, overly complex expressions, missing timeouts, unclear error messages.
|
|
237
|
+
|
|
238
|
+
Each worker scores its findings independently. Sentinel computes consensus — findings where multiple workers agree get surfaced first. Findings with low consensus and no Darwin backing get de-prioritized.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Configuration
|
|
243
|
+
|
|
244
|
+
```toml
|
|
245
|
+
# .lore-review/config.toml
|
|
246
|
+
|
|
247
|
+
[council]
|
|
248
|
+
model = "gpt-4o-mini" # any OpenAI-compatible model
|
|
249
|
+
workers = ["security", "perf", "correctness", "style"]
|
|
250
|
+
consensus_threshold = 0.6 # minimum agreement to surface finding
|
|
251
|
+
|
|
252
|
+
[darwin]
|
|
253
|
+
immunity_threshold = 2 # suppressions before a rule is compiled
|
|
254
|
+
db_path = ".lore-review/darwin.db"
|
|
255
|
+
|
|
256
|
+
[output]
|
|
257
|
+
fail_on = ["critical"] # severity levels that exit non-zero
|
|
258
|
+
format = "text" # text | json | sarif
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Contributing
|
|
264
|
+
|
|
265
|
+
PRs welcome. The pipeline is modular — adding a new Council worker is ~50 lines. See `lore_review/agents/` for examples.
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
git clone https://github.com/your-org/lore-review
|
|
269
|
+
cd lore-review
|
|
270
|
+
pip install -e ".[dev]"
|
|
271
|
+
pytest tests/
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
MIT License.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
name: 'Lore Review'
|
|
2
|
+
description: 'AI code review that gets smarter on your codebase'
|
|
3
|
+
inputs:
|
|
4
|
+
github-token:
|
|
5
|
+
description: 'GitHub token'
|
|
6
|
+
required: true
|
|
7
|
+
default: ${{ github.token }}
|
|
8
|
+
repo-path:
|
|
9
|
+
description: 'Path to repository'
|
|
10
|
+
required: false
|
|
11
|
+
default: '.'
|
|
12
|
+
fail-on:
|
|
13
|
+
description: 'Fail check if findings reach this severity: critical|high|medium|low|info|never'
|
|
14
|
+
required: false
|
|
15
|
+
default: 'critical'
|
|
16
|
+
runs:
|
|
17
|
+
using: 'composite'
|
|
18
|
+
steps:
|
|
19
|
+
- name: Install lore-review from source
|
|
20
|
+
run: pip install -e .
|
|
21
|
+
shell: bash
|
|
22
|
+
- name: Get PR diff
|
|
23
|
+
run: git diff origin/${{ github.base_ref }}...HEAD > /tmp/lore_pr.diff
|
|
24
|
+
shell: bash
|
|
25
|
+
- name: Run review (GitHub annotations)
|
|
26
|
+
run: |
|
|
27
|
+
lore-review \
|
|
28
|
+
--repo ${{ inputs.repo-path }} \
|
|
29
|
+
--diff /tmp/lore_pr.diff \
|
|
30
|
+
--pr-id ${{ github.event.number }} \
|
|
31
|
+
--format github \
|
|
32
|
+
--fail-on ${{ inputs.fail-on }}
|
|
33
|
+
shell: bash
|
|
34
|
+
- name: Run review (JSON for comment)
|
|
35
|
+
if: always()
|
|
36
|
+
run: |
|
|
37
|
+
lore-review \
|
|
38
|
+
--repo ${{ inputs.repo-path }} \
|
|
39
|
+
--diff /tmp/lore_pr.diff \
|
|
40
|
+
--pr-id ${{ github.event.number }} \
|
|
41
|
+
--output json \
|
|
42
|
+
--fail-on never \
|
|
43
|
+
> /tmp/lore_review.json || true
|
|
44
|
+
shell: bash
|
|
45
|
+
- name: Post comment
|
|
46
|
+
if: always()
|
|
47
|
+
uses: actions/github-script@v7
|
|
48
|
+
with:
|
|
49
|
+
github-token: ${{ inputs.github-token }}
|
|
50
|
+
script: |
|
|
51
|
+
const fs = require('fs');
|
|
52
|
+
const result = JSON.parse(fs.readFileSync('/tmp/lore_review.json', 'utf8'));
|
|
53
|
+
const findings = result.verdict.findings;
|
|
54
|
+
const counts = {critical:0, high:0, medium:0, low:0, info:0};
|
|
55
|
+
findings.forEach(f => counts[f.severity] = (counts[f.severity]||0) + 1);
|
|
56
|
+
|
|
57
|
+
const rows = findings.slice(0,20).map(f => {
|
|
58
|
+
const icon = {critical:'🔴',high:'🟠',medium:'🟡',low:'🔵',info:'⚪'}[f.severity]||'⚪';
|
|
59
|
+
return `| ${icon} ${f.severity.toUpperCase()} | ${f.file_path}:${f.line_start} | ${f.message} |`;
|
|
60
|
+
}).join('\n');
|
|
61
|
+
|
|
62
|
+
const hasCritical = counts.critical > 0;
|
|
63
|
+
const statusLine = hasCritical ? '❌ **FAILED** — CRITICAL findings block merge.' : '✅ **PASSED** — No critical issues.';
|
|
64
|
+
|
|
65
|
+
const body = [
|
|
66
|
+
'## 🔍 Lore Review',
|
|
67
|
+
'',
|
|
68
|
+
statusLine,
|
|
69
|
+
'',
|
|
70
|
+
`**Findings:** ${findings.length} issues (${counts.critical} critical, ${counts.high} high, ${counts.medium} medium)`,
|
|
71
|
+
`**Darwin rules applied:** ${result.verdict.immunity_rules_applied}`,
|
|
72
|
+
`**Cost:** $${result.total_cost_usd.toFixed(4)}`,
|
|
73
|
+
'',
|
|
74
|
+
findings.length > 0 ? '| Severity | File | Issue |\n|----------|------|-------|\n' + rows : '✅ No issues found.',
|
|
75
|
+
'',
|
|
76
|
+
'*Powered by [lore-review](https://github.com/Miles0sage/lore-review) — gets smarter with every PR*'
|
|
77
|
+
].join('\n');
|
|
78
|
+
|
|
79
|
+
github.rest.issues.createComment({
|
|
80
|
+
owner: context.repo.owner,
|
|
81
|
+
repo: context.repo.repo,
|
|
82
|
+
issue_number: context.issue.number,
|
|
83
|
+
body
|
|
84
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
## Install in your repo (30 seconds)
|
|
4
|
+
|
|
5
|
+
Add to `.github/workflows/lore-review.yml`:
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
name: Lore Review
|
|
9
|
+
on: [pull_request]
|
|
10
|
+
jobs:
|
|
11
|
+
review:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
- uses: Miles0sage/lore-review@main
|
|
18
|
+
with:
|
|
19
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That's it. lore-review will comment on every PR with findings, and get smarter with each review.
|
|
23
|
+
|
|
24
|
+
## CLI usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install lore-review
|
|
28
|
+
git diff main...HEAD > pr.diff
|
|
29
|
+
lore-review --repo . --diff pr.diff
|
|
30
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Council — 4 specialist AI workers review the PR in parallel."""
|
|
2
|
+
import subprocess, json, re, concurrent.futures, time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ..models import Finding, CouncilVerdict
|
|
5
|
+
|
|
6
|
+
COUNCIL_ROLES = {
|
|
7
|
+
"security": "You are a security code reviewer. Analyze this PR diff for: SQL injection, XSS, hardcoded secrets, auth bypass, OWASP Top 10 violations, insecure dependencies. Output JSON list of findings.",
|
|
8
|
+
"performance": "You are a performance engineer. Analyze this PR diff for: N+1 queries, blocking I/O in async contexts, memory leaks, O(n²) algorithms, unnecessary re-renders. Output JSON list of findings.",
|
|
9
|
+
"correctness": "You are a correctness reviewer. Analyze this PR diff for: logic errors, off-by-one errors, null/undefined dereferences, race conditions, unhandled exceptions, incorrect error propagation. Output JSON list of findings.",
|
|
10
|
+
"style": "You are a code quality reviewer. Analyze this PR diff for: dead code, overly complex functions (>50 lines), missing error handling, unclear variable names, code duplication. Output JSON list of findings.",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
AI_FACTORY = Path("/root/ai-factory/orchestrator.py")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_findings(raw: list, role: str, confidence: float) -> list[Finding]:
|
|
17
|
+
findings = []
|
|
18
|
+
for item in raw:
|
|
19
|
+
if isinstance(item, dict) and "message" in item:
|
|
20
|
+
findings.append(Finding(
|
|
21
|
+
severity=item.get("severity", "medium"),
|
|
22
|
+
category=role,
|
|
23
|
+
message=item.get("message", ""),
|
|
24
|
+
file_path=item.get("file_path", ""),
|
|
25
|
+
line_start=item.get("line_start", 0),
|
|
26
|
+
confidence=confidence,
|
|
27
|
+
))
|
|
28
|
+
return findings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_worker(role: str, prompt: str, diff: str, immunity_rules: list) -> list[Finding]:
|
|
32
|
+
"""Run one council worker — tries AI Factory first, falls back to direct API."""
|
|
33
|
+
rules_context = ""
|
|
34
|
+
if immunity_rules:
|
|
35
|
+
rules_context = f"\n\nKnown patterns to watch for:\n" + \
|
|
36
|
+
"\n".join(f"- {r.pattern} ({r.category})" for r in immunity_rules[:10])
|
|
37
|
+
|
|
38
|
+
full_prompt = f"{prompt}{rules_context}\n\nPR DIFF:\n{diff[:6000]}\n\nRespond with ONLY a JSON array of findings. Each: {{\"severity\": \"critical|high|medium|low|info\", \"message\": \"description\", \"file_path\": \"path\", \"line_start\": 0}}. If none found, return []."
|
|
39
|
+
|
|
40
|
+
# Try 1: AI Factory (short timeout so fallback has room within 120s window)
|
|
41
|
+
if AI_FACTORY.exists():
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
["python3", str(AI_FACTORY), full_prompt, "--worker", "alibaba", "--timeout", "8"],
|
|
45
|
+
capture_output=True, text=True, timeout=12
|
|
46
|
+
)
|
|
47
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
48
|
+
output = result.stdout.strip()
|
|
49
|
+
match = re.search(r'\[.*\]', output, re.DOTALL)
|
|
50
|
+
if match:
|
|
51
|
+
raw = json.loads(match.group())
|
|
52
|
+
return _parse_findings(raw, role, 0.8)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass # Fall through to next method
|
|
55
|
+
|
|
56
|
+
# Try 2: Direct Anthropic API (api key or OAuth token)
|
|
57
|
+
try:
|
|
58
|
+
import anthropic
|
|
59
|
+
import os
|
|
60
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
61
|
+
if not api_key:
|
|
62
|
+
# Try OAuth token from Claude credentials
|
|
63
|
+
creds_path = Path.home() / ".claude" / ".credentials.json"
|
|
64
|
+
if creds_path.exists():
|
|
65
|
+
creds = json.loads(creds_path.read_text())
|
|
66
|
+
api_key = creds.get("claudeAiOauth", {}).get("accessToken", "")
|
|
67
|
+
if api_key:
|
|
68
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
69
|
+
msg = client.messages.create(
|
|
70
|
+
model="claude-haiku-4-5-20251001",
|
|
71
|
+
max_tokens=1024,
|
|
72
|
+
messages=[{"role": "user", "content": full_prompt}]
|
|
73
|
+
)
|
|
74
|
+
text = msg.content[0].text
|
|
75
|
+
match = re.search(r'\[.*\]', text, re.DOTALL)
|
|
76
|
+
if match:
|
|
77
|
+
raw = json.loads(match.group())
|
|
78
|
+
return _parse_findings(raw, role, 0.85)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# Try 3: claude CLI
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
["claude", "-p", full_prompt, "--model", "claude-haiku-4-5-20251001"],
|
|
86
|
+
capture_output=True, text=True, timeout=60
|
|
87
|
+
)
|
|
88
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
89
|
+
text = result.stdout.strip()
|
|
90
|
+
match = re.search(r'\[.*\]', text, re.DOTALL)
|
|
91
|
+
if match:
|
|
92
|
+
raw = json.loads(match.group())
|
|
93
|
+
return _parse_findings(raw, role, 0.75)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
def run_council(scout_context: dict, immunity_rules: list, dry_run: bool = False) -> CouncilVerdict:
|
|
100
|
+
"""Run 4 specialist reviews in parallel via AI Factory."""
|
|
101
|
+
diff = scout_context.get("diff", "")
|
|
102
|
+
if dry_run or not diff.strip():
|
|
103
|
+
return CouncilVerdict(findings=[], consensus_score=1.0, cost_usd=0.0, immunity_rules_applied=len(immunity_rules))
|
|
104
|
+
|
|
105
|
+
start = time.time()
|
|
106
|
+
all_findings = []
|
|
107
|
+
|
|
108
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
|
109
|
+
futures = {
|
|
110
|
+
executor.submit(_run_worker, role, prompt, diff, immunity_rules): role
|
|
111
|
+
for role, prompt in COUNCIL_ROLES.items()
|
|
112
|
+
}
|
|
113
|
+
for future in concurrent.futures.as_completed(futures, timeout=120):
|
|
114
|
+
try:
|
|
115
|
+
all_findings.extend(future.result())
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
elapsed = time.time() - start
|
|
120
|
+
# Estimate cost: 4 workers x ~$0.001 each
|
|
121
|
+
cost = 0.004
|
|
122
|
+
|
|
123
|
+
return CouncilVerdict(
|
|
124
|
+
findings=all_findings,
|
|
125
|
+
consensus_score=0.85,
|
|
126
|
+
cost_usd=cost,
|
|
127
|
+
immunity_rules_applied=len(immunity_rules),
|
|
128
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Scout — maps the territory before the review."""
|
|
2
|
+
from ..graph_reader import GraphReader
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def run_scout(diff: str, repo_path: str, graph_reader: GraphReader) -> dict:
|
|
6
|
+
context = graph_reader.get_pr_context(diff, repo_path)
|
|
7
|
+
changed_files = context.get("changed_files", [])
|
|
8
|
+
lines_changed = sum(1 for l in diff.splitlines() if l.startswith(("+", "-")) and not l.startswith(("+++", "---")))
|
|
9
|
+
return {
|
|
10
|
+
"diff": diff,
|
|
11
|
+
"changed_files": changed_files,
|
|
12
|
+
"lines_changed": lines_changed,
|
|
13
|
+
"graph_context": context,
|
|
14
|
+
"graph_available": context.get("graph_available", False),
|
|
15
|
+
"risk_score": context.get("risk_score", 0.5),
|
|
16
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Sentinel — validates Council findings, deduplicates cross-worker noise."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from ..models import CouncilVerdict, Finding
|
|
6
|
+
|
|
7
|
+
_SEV_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
|
|
8
|
+
|
|
9
|
+
# Bug-type keywords → canonical label for dedup
|
|
10
|
+
_BUG_PATTERNS = [
|
|
11
|
+
(r"sql.inject|interpolat.*sql|parameteriz", "sql_injection"),
|
|
12
|
+
(r"command.inject|shell=true|shell inject", "cmd_injection"),
|
|
13
|
+
(r"hardcod|api.key|secret.*expos|expos.*secret", "hardcoded_secret"),
|
|
14
|
+
(r"\beval\b.*untrust|arbitrary.*exec|remote.*code", "eval_exec"),
|
|
15
|
+
(r"o\(n.2\)|nested.loop|quadratic|n\^2", "quadratic"),
|
|
16
|
+
(r"resource.leak|connection.*clos|conn\.close", "resource_leak"),
|
|
17
|
+
(r"infinite.loop|no.exit|while true", "infinite_loop"),
|
|
18
|
+
(r"timeout|urlopen.*timeout", "missing_timeout"),
|
|
19
|
+
(r"logic.error|duplicate.*mult|find_dup", "logic_error"),
|
|
20
|
+
(r"pickle|deserializ", "insecure_deserialization"),
|
|
21
|
+
(r"timing.attack|compare_digest|constant.time", "timing_attack"),
|
|
22
|
+
(r"race.condition|thread.*lock|global.*hit", "race_condition"),
|
|
23
|
+
(r"path.traversal|directory.traversal", "path_traversal"),
|
|
24
|
+
(r"weak.*prng|random.*token|md5.*token", "weak_prng"),
|
|
25
|
+
(r"unbounded.*thread|thread.*pool", "unbounded_threads"),
|
|
26
|
+
(r"mutable.*default|default.*arg.*list", "mutable_default"),
|
|
27
|
+
(r"redos|catastrophic.backtrack", "redos"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bug_type(msg: str) -> str:
|
|
32
|
+
lower = msg.lower()
|
|
33
|
+
for pattern, label in _BUG_PATTERNS:
|
|
34
|
+
if re.search(pattern, lower):
|
|
35
|
+
return label
|
|
36
|
+
# fallback: first 5 significant words
|
|
37
|
+
words = re.sub(r"[^a-z\s]", "", lower).split()
|
|
38
|
+
return "_".join(words[:5])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fingerprint(finding: Finding) -> str:
|
|
42
|
+
"""Stable key: file + 10-line bucket + bug type. Collapses cross-worker duplicates."""
|
|
43
|
+
file_key = (finding.file_path or "").split("/")[-1] # basename only
|
|
44
|
+
line_bucket = ((finding.line_start or 0) // 10) * 10
|
|
45
|
+
bug = _bug_type(finding.message)
|
|
46
|
+
return f"{file_key}:{line_bucket}:{bug}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dedup(findings: list[Finding]) -> list[Finding]:
|
|
50
|
+
"""Keep one finding per (file, line-bucket, bug-type), highest severity wins."""
|
|
51
|
+
best: dict[str, Finding] = {}
|
|
52
|
+
for f in findings:
|
|
53
|
+
key = _fingerprint(f)
|
|
54
|
+
current = best.get(key)
|
|
55
|
+
if current is None or _SEV_RANK.get(f.severity.lower(), 0) > _SEV_RANK.get(current.severity.lower(), 0):
|
|
56
|
+
best[key] = f
|
|
57
|
+
return sorted(best.values(), key=lambda f: _SEV_RANK.get(f.severity.lower(), 0), reverse=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_sentinel(verdict: CouncilVerdict, scout_context: dict) -> CouncilVerdict:
|
|
61
|
+
"""Deduplicate cross-worker findings, then filter hallucinations by file path."""
|
|
62
|
+
deduped = _dedup(verdict.findings)
|
|
63
|
+
known_files = set(scout_context.get("changed_files", []))
|
|
64
|
+
if known_files:
|
|
65
|
+
deduped = [f for f in deduped if not f.file_path or f.file_path in known_files]
|
|
66
|
+
return CouncilVerdict(
|
|
67
|
+
findings=deduped,
|
|
68
|
+
consensus_score=verdict.consensus_score,
|
|
69
|
+
cost_usd=verdict.cost_usd,
|
|
70
|
+
immunity_rules_applied=verdict.immunity_rules_applied,
|
|
71
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CLI: lore-review --repo /path --diff patch.diff"""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .models import ReviewRequest
|
|
6
|
+
from .review_pipeline import review_pr
|
|
7
|
+
|
|
8
|
+
SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _severity_gte(severity: str, threshold: str) -> bool:
|
|
12
|
+
"""Return True if severity is >= threshold (more severe or equal)."""
|
|
13
|
+
order = {s: i for i, s in enumerate(SEVERITY_ORDER)}
|
|
14
|
+
return order.get(severity, 99) <= order.get(threshold, 99)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
parser = argparse.ArgumentParser(description="Lore Review — AI code review that learns")
|
|
19
|
+
parser.add_argument("--repo", required=True, help="Path to repository")
|
|
20
|
+
parser.add_argument("--diff", required=True, help="Path to diff file or '-' for stdin")
|
|
21
|
+
parser.add_argument("--pr-id", default="local")
|
|
22
|
+
parser.add_argument("--output", choices=["text", "json", "github"], default="text")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--format",
|
|
25
|
+
choices=["text", "json", "github"],
|
|
26
|
+
dest="format_",
|
|
27
|
+
default=None,
|
|
28
|
+
help="Output format (alias for --output; github emits ::error:: annotations)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--fail-on",
|
|
32
|
+
choices=["critical", "high", "medium", "low", "info", "never"],
|
|
33
|
+
default="critical",
|
|
34
|
+
help="Exit code 1 if any finding meets or exceeds this severity (default: critical)",
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
# --format overrides --output when provided
|
|
39
|
+
output_format = args.format_ if args.format_ is not None else args.output
|
|
40
|
+
|
|
41
|
+
diff = sys.stdin.read() if args.diff == "-" else Path(args.diff).read_text()
|
|
42
|
+
request = ReviewRequest(repo_path=args.repo, pr_diff=diff, pr_id=args.pr_id)
|
|
43
|
+
result = review_pr(request)
|
|
44
|
+
|
|
45
|
+
if output_format == "json":
|
|
46
|
+
print(result.model_dump_json(indent=2))
|
|
47
|
+
elif output_format == "github":
|
|
48
|
+
# Emit GitHub Actions annotations
|
|
49
|
+
for f in result.verdict.findings:
|
|
50
|
+
level = "error" if f.severity in ("critical", "high") else "warning"
|
|
51
|
+
loc = f"file={f.file_path},line={f.line_start}"
|
|
52
|
+
msg = f.message.replace("\n", "%0A").replace(",", "%2C")
|
|
53
|
+
print(f"::{level} {loc}::[{f.severity.upper()}] {f.category}: {msg}")
|
|
54
|
+
# Summary to stderr so stdout stays clean for annotation parsing
|
|
55
|
+
print(
|
|
56
|
+
f"Lore Review complete — {len(result.verdict.findings)} findings "
|
|
57
|
+
f"(cost: ${result.total_cost_usd:.4f})",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
print(f"Lore Review — PR {result.pr_id}")
|
|
62
|
+
print(f"Findings: {len(result.verdict.findings)}")
|
|
63
|
+
print(f"Darwin rules learned: {result.darwin_rules_learned}")
|
|
64
|
+
print(f"Cost: ${result.total_cost_usd:.4f}")
|
|
65
|
+
for f in result.verdict.findings:
|
|
66
|
+
print(f" [{f.severity.upper()}] {f.category}: {f.message} ({f.file_path}:{f.line_start})")
|
|
67
|
+
|
|
68
|
+
# Exit code: 1 if any finding meets or exceeds --fail-on threshold
|
|
69
|
+
if args.fail_on != "never":
|
|
70
|
+
for f in result.verdict.findings:
|
|
71
|
+
if _severity_gte(f.severity, args.fail_on):
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .models import ImmunityRule, Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DarwinStore:
|
|
10
|
+
def __init__(self, db_path: Path = Path(".lore-review/darwin.db")):
|
|
11
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
self._db = str(db_path)
|
|
13
|
+
self._init_schema()
|
|
14
|
+
|
|
15
|
+
def _init_schema(self):
|
|
16
|
+
with sqlite3.connect(self._db) as conn:
|
|
17
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS immunity_rules (
|
|
18
|
+
rule_id TEXT PRIMARY KEY, pattern TEXT, category TEXT,
|
|
19
|
+
confidence REAL, times_applied INTEGER DEFAULT 0,
|
|
20
|
+
created_at TEXT)""")
|
|
21
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS review_misses (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, repo_id TEXT,
|
|
23
|
+
pattern TEXT, category TEXT, was_caught INTEGER,
|
|
24
|
+
recorded_at REAL)""")
|
|
25
|
+
|
|
26
|
+
def repo_id_from_path(self, repo_path: str) -> str:
|
|
27
|
+
return hashlib.sha256(repo_path.encode()).hexdigest()[:16]
|
|
28
|
+
|
|
29
|
+
def get_rules(self, repo_id: str) -> list[ImmunityRule]:
|
|
30
|
+
with sqlite3.connect(self._db) as conn:
|
|
31
|
+
rows = conn.execute(
|
|
32
|
+
"SELECT rule_id, pattern, category, confidence, times_applied, created_at FROM immunity_rules WHERE rule_id LIKE ?",
|
|
33
|
+
(f"{repo_id}%",)
|
|
34
|
+
).fetchall()
|
|
35
|
+
return [ImmunityRule(rule_id=r[0], pattern=r[1], category=r[2],
|
|
36
|
+
confidence=r[3], times_applied=r[4], created_at=r[5]) for r in rows]
|
|
37
|
+
|
|
38
|
+
def record_miss(self, repo_id: str, finding: Finding, was_caught: bool):
|
|
39
|
+
with sqlite3.connect(self._db) as conn:
|
|
40
|
+
conn.execute(
|
|
41
|
+
"INSERT INTO review_misses (repo_id, pattern, category, was_caught, recorded_at) VALUES (?,?,?,?,?)",
|
|
42
|
+
(repo_id, finding.message[:100], finding.category, int(was_caught), time.time())
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def compile_rules(self, repo_id: str) -> list[ImmunityRule]:
|
|
46
|
+
"""Cluster misses into immunity rules."""
|
|
47
|
+
with sqlite3.connect(self._db) as conn:
|
|
48
|
+
rows = conn.execute(
|
|
49
|
+
"SELECT pattern, category, COUNT(*) as cnt FROM review_misses WHERE repo_id=? GROUP BY pattern, category HAVING cnt >= 2",
|
|
50
|
+
(repo_id,)
|
|
51
|
+
).fetchall()
|
|
52
|
+
rules = []
|
|
53
|
+
for pattern, category, cnt in rows:
|
|
54
|
+
rule_id = f"{repo_id}_{hashlib.sha256(pattern.encode()).hexdigest()[:8]}"
|
|
55
|
+
rule = ImmunityRule(rule_id=rule_id, pattern=pattern, category=category,
|
|
56
|
+
confidence=min(0.5 + cnt * 0.1, 0.95),
|
|
57
|
+
times_applied=cnt, created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
58
|
+
with sqlite3.connect(self._db) as conn:
|
|
59
|
+
conn.execute(
|
|
60
|
+
"INSERT OR REPLACE INTO immunity_rules VALUES (?,?,?,?,?,?)",
|
|
61
|
+
(rule.rule_id, rule.pattern, rule.category, rule.confidence, rule.times_applied, rule.created_at)
|
|
62
|
+
)
|
|
63
|
+
rules.append(rule)
|
|
64
|
+
return rules
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Reads code-review-graph MCP if available, falls back to diff-only mode."""
|
|
2
|
+
import httpx
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GraphReader:
|
|
7
|
+
def __init__(self, mcp_url: str = "http://localhost:8000"):
|
|
8
|
+
self.mcp_url = mcp_url
|
|
9
|
+
self._available = None
|
|
10
|
+
|
|
11
|
+
def is_available(self) -> bool:
|
|
12
|
+
if self._available is None:
|
|
13
|
+
try:
|
|
14
|
+
httpx.get(f"{self.mcp_url}/health", timeout=2.0)
|
|
15
|
+
self._available = True
|
|
16
|
+
except Exception:
|
|
17
|
+
self._available = False
|
|
18
|
+
return self._available
|
|
19
|
+
|
|
20
|
+
def get_pr_context(self, diff: str, repo_path: str) -> dict:
|
|
21
|
+
if not self.is_available():
|
|
22
|
+
return {"graph_available": False, "changed_files": self._parse_diff_files(diff), "symbols": [], "risk_score": 0.5}
|
|
23
|
+
try:
|
|
24
|
+
resp = httpx.post(f"{self.mcp_url}/tools/get_review_context_tool",
|
|
25
|
+
json={"diff": diff, "repo_path": repo_path}, timeout=30.0)
|
|
26
|
+
data = resp.json()
|
|
27
|
+
data["graph_available"] = True
|
|
28
|
+
return data
|
|
29
|
+
except Exception:
|
|
30
|
+
return {"graph_available": False, "changed_files": self._parse_diff_files(diff), "symbols": [], "risk_score": 0.5}
|
|
31
|
+
|
|
32
|
+
def _parse_diff_files(self, diff: str) -> list[str]:
|
|
33
|
+
files = []
|
|
34
|
+
for line in diff.splitlines():
|
|
35
|
+
if line.startswith("+++ b/"):
|
|
36
|
+
files.append(line[6:])
|
|
37
|
+
return files
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReviewRequest(BaseModel):
|
|
7
|
+
repo_path: str
|
|
8
|
+
pr_diff: str
|
|
9
|
+
pr_id: str = "local"
|
|
10
|
+
base_branch: str = "main"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Finding(BaseModel):
|
|
14
|
+
severity: Literal["critical", "high", "medium", "low", "info"]
|
|
15
|
+
category: Literal["security", "performance", "style", "correctness"]
|
|
16
|
+
message: str
|
|
17
|
+
file_path: str
|
|
18
|
+
line_start: int = 0
|
|
19
|
+
line_end: int = 0
|
|
20
|
+
confidence: float = 1.0
|
|
21
|
+
graph_evidence: list[str] = Field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CouncilVerdict(BaseModel):
|
|
25
|
+
findings: list[Finding]
|
|
26
|
+
consensus_score: float = 0.0
|
|
27
|
+
cost_usd: float = 0.0
|
|
28
|
+
immunity_rules_applied: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReviewResult(BaseModel):
|
|
32
|
+
pr_id: str
|
|
33
|
+
verdict: CouncilVerdict
|
|
34
|
+
darwin_rules_learned: int = 0
|
|
35
|
+
total_cost_usd: float = 0.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ImmunityRule(BaseModel):
|
|
39
|
+
rule_id: str
|
|
40
|
+
pattern: str
|
|
41
|
+
category: str
|
|
42
|
+
confidence: float
|
|
43
|
+
times_applied: int = 0
|
|
44
|
+
created_at: str = ""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Main review pipeline: Scout → Council → Sentinel → Darwin."""
|
|
2
|
+
from .models import ReviewRequest, ReviewResult, Finding
|
|
3
|
+
from .darwin_store import DarwinStore
|
|
4
|
+
from .graph_reader import GraphReader
|
|
5
|
+
from .agents.scout import run_scout
|
|
6
|
+
from .agents.council import run_council
|
|
7
|
+
from .agents.sentinel import run_sentinel, _bug_type
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def review_pr(request: ReviewRequest, store: DarwinStore = None, graph_reader: GraphReader = None) -> ReviewResult:
|
|
11
|
+
if store is None:
|
|
12
|
+
store = DarwinStore()
|
|
13
|
+
if graph_reader is None:
|
|
14
|
+
graph_reader = GraphReader()
|
|
15
|
+
|
|
16
|
+
repo_id = store.repo_id_from_path(request.repo_path)
|
|
17
|
+
immunity_rules = store.get_rules(repo_id)
|
|
18
|
+
|
|
19
|
+
scout_ctx = run_scout(request.pr_diff, request.repo_path, graph_reader)
|
|
20
|
+
verdict = run_council(scout_ctx, immunity_rules)
|
|
21
|
+
verdict = run_sentinel(verdict, scout_ctx)
|
|
22
|
+
|
|
23
|
+
# Record normalized bug-type patterns so Darwin can cluster across runs
|
|
24
|
+
# (raw messages vary between AI runs; bug-type is stable)
|
|
25
|
+
for finding in verdict.findings:
|
|
26
|
+
normalized = Finding(
|
|
27
|
+
severity=finding.severity,
|
|
28
|
+
category=finding.category,
|
|
29
|
+
message=_bug_type(finding.message), # normalize to stable key
|
|
30
|
+
file_path=finding.file_path,
|
|
31
|
+
line_start=finding.line_start,
|
|
32
|
+
)
|
|
33
|
+
store.record_miss(repo_id, normalized, was_caught=True)
|
|
34
|
+
|
|
35
|
+
new_rules = store.compile_rules(repo_id)
|
|
36
|
+
|
|
37
|
+
return ReviewResult(
|
|
38
|
+
pr_id=request.pr_id,
|
|
39
|
+
verdict=verdict,
|
|
40
|
+
darwin_rules_learned=len(new_rules),
|
|
41
|
+
total_cost_usd=verdict.cost_usd,
|
|
42
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lore-review"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The code reviewer that knows your codebase — and gets smarter every time it's wrong"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"fastapi>=0.110.0",
|
|
8
|
+
"uvicorn>=0.27.0",
|
|
9
|
+
"pydantic>=2.0.0",
|
|
10
|
+
"httpx>=0.27.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
lore-review = "lore_review.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from lore_review.darwin_store import DarwinStore
|
|
4
|
+
from lore_review.models import Finding
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def store(tmp_path):
|
|
9
|
+
return DarwinStore(db_path=tmp_path / "darwin.db")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_repo_id_deterministic(store):
|
|
13
|
+
assert store.repo_id_from_path("/tmp/repo") == store.repo_id_from_path("/tmp/repo")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_get_rules_empty(store):
|
|
17
|
+
assert store.get_rules("abc123") == []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_record_miss(store):
|
|
21
|
+
f = Finding(severity="high", category="security", message="test vuln", file_path="x.py")
|
|
22
|
+
store.record_miss("repo1", f, was_caught=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_compile_rules_threshold(store):
|
|
26
|
+
f = Finding(severity="high", category="security", message="sql injection risk", file_path="db.py")
|
|
27
|
+
# Need 2+ occurrences to compile
|
|
28
|
+
store.record_miss("repo1", f, was_caught=False)
|
|
29
|
+
store.record_miss("repo1", f, was_caught=False)
|
|
30
|
+
rules = store.compile_rules("repo1")
|
|
31
|
+
assert len(rules) >= 1
|
|
32
|
+
assert rules[0].category == "security"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_compile_rules_below_threshold(store):
|
|
36
|
+
f = Finding(severity="low", category="style", message="single occurrence", file_path="x.py")
|
|
37
|
+
store.record_miss("repo1", f, was_caught=False)
|
|
38
|
+
rules = store.compile_rules("repo1")
|
|
39
|
+
assert len(rules) == 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from lore_review.models import ReviewRequest, Finding, CouncilVerdict, ReviewResult, ImmunityRule
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_review_request():
|
|
5
|
+
r = ReviewRequest(repo_path="/tmp/repo", pr_diff="--- a/foo.py\n+++ b/foo.py\n+x=1")
|
|
6
|
+
assert r.pr_id == "local"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_finding_defaults():
|
|
10
|
+
f = Finding(severity="high", category="security", message="SQL injection", file_path="db.py")
|
|
11
|
+
assert f.confidence == 1.0
|
|
12
|
+
assert f.graph_evidence == []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_council_verdict():
|
|
16
|
+
v = CouncilVerdict(findings=[], consensus_score=0.9, cost_usd=0.01)
|
|
17
|
+
assert v.immunity_rules_applied == 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_immunity_rule():
|
|
21
|
+
r = ImmunityRule(rule_id="abc", pattern="sql injection", category="security", confidence=0.8)
|
|
22
|
+
assert r.times_applied == 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from lore_review.models import ReviewRequest
|
|
2
|
+
from lore_review.review_pipeline import review_pr
|
|
3
|
+
from lore_review.darwin_store import DarwinStore
|
|
4
|
+
from lore_review.graph_reader import GraphReader
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_pipeline_no_graph(tmp_path):
|
|
8
|
+
store = DarwinStore(db_path=tmp_path / "darwin.db")
|
|
9
|
+
graph = GraphReader(mcp_url="http://localhost:9999") # won't connect
|
|
10
|
+
req = ReviewRequest(
|
|
11
|
+
repo_path=str(tmp_path),
|
|
12
|
+
pr_diff="--- a/foo.py\n+++ b/foo.py\n@@ -1 +1 @@\n-old\n+new",
|
|
13
|
+
pr_id="test-pr-1"
|
|
14
|
+
)
|
|
15
|
+
result = review_pr(req, store=store, graph_reader=graph)
|
|
16
|
+
assert result.pr_id == "test-pr-1"
|
|
17
|
+
assert result.total_cost_usd >= 0
|
|
18
|
+
assert result.darwin_rules_learned == 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_pipeline_result_structure(tmp_path):
|
|
22
|
+
store = DarwinStore(db_path=tmp_path / "darwin.db")
|
|
23
|
+
req = ReviewRequest(repo_path=str(tmp_path), pr_diff="", pr_id="pr-2")
|
|
24
|
+
result = review_pr(req, store=store)
|
|
25
|
+
assert hasattr(result, 'verdict')
|
|
26
|
+
assert hasattr(result.verdict, 'findings')
|