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.
@@ -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,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .lore-review/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
@@ -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
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue) ![MIT License](https://img.shields.io/badge/license-MIT-green) ![PyPI](https://img.shields.io/pypi/v/lore-review)
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
+ ```
@@ -0,0 +1,2 @@
1
+ """Lore Review — Self-improving AI code review powered by Darwin learning."""
2
+ __version__ = "0.1.0"
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')