contrarian 0.2.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.
- contrarian-0.2.0/LICENSE +21 -0
- contrarian-0.2.0/PKG-INFO +143 -0
- contrarian-0.2.0/README.md +115 -0
- contrarian-0.2.0/pyproject.toml +42 -0
- contrarian-0.2.0/setup.cfg +4 -0
- contrarian-0.2.0/src/contrarian/__init__.py +0 -0
- contrarian-0.2.0/src/contrarian/graph_context.py +240 -0
- contrarian-0.2.0/src/contrarian/judge.py +152 -0
- contrarian-0.2.0/src/contrarian/pipeline.py +225 -0
- contrarian-0.2.0/src/contrarian/report.py +66 -0
- contrarian-0.2.0/src/contrarian/router.py +6 -0
- contrarian-0.2.0/src/contrarian/rubric.py +42 -0
- contrarian-0.2.0/src/contrarian/server.py +108 -0
- contrarian-0.2.0/src/contrarian/setup_cmd.py +62 -0
- contrarian-0.2.0/src/contrarian.egg-info/PKG-INFO +143 -0
- contrarian-0.2.0/src/contrarian.egg-info/SOURCES.txt +18 -0
- contrarian-0.2.0/src/contrarian.egg-info/dependency_links.txt +1 -0
- contrarian-0.2.0/src/contrarian.egg-info/entry_points.txt +2 -0
- contrarian-0.2.0/src/contrarian.egg-info/requires.txt +9 -0
- contrarian-0.2.0/src/contrarian.egg-info/top_level.txt +1 -0
contrarian-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anthony Micho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contrarian
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server adding an independent second LLM perspective (Judge B) with full project graph context
|
|
5
|
+
Author-email: Anthony Micho <anthony.micho76@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/anthonymicho/contrarian
|
|
8
|
+
Keywords: mcp,llm,code-review,claude,judge
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: mcp
|
|
20
|
+
Requires-Dist: openai>=1.0
|
|
21
|
+
Requires-Dist: python-dotenv
|
|
22
|
+
Requires-Dist: networkx>=3.4
|
|
23
|
+
Requires-Dist: graphifyy[leiden]
|
|
24
|
+
Requires-Dist: json-repair>=0.6
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: graphifyy[all]; extra == "all"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Contrarian
|
|
30
|
+
|
|
31
|
+
MCP server that adds a second LLM perspective (Judge B) directly inside Claude Code.
|
|
32
|
+
|
|
33
|
+
Claude Code is Judge A — it already has full project context. Contrarian adds Judge B, an independent model from a different lab, via a single tool call. Same rubric, different priors, no context switch.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
1. Claude Code invokes `contrarian_review()` — no args for auto-detect, or pass specific files.
|
|
40
|
+
2. Contrarian resolves the input: git diff, last commit, or full audit (in that order).
|
|
41
|
+
3. Judge B (external model) reviews against `JUDGE.md` in the project root.
|
|
42
|
+
4. Findings are returned inline and appended to `REPORT.md` for the dialogue log.
|
|
43
|
+
|
|
44
|
+
Auto-detect order:
|
|
45
|
+
- **diff** — any staged/unstaged changes or untracked files
|
|
46
|
+
- **last-commit** — clean tree → reviews the last commit
|
|
47
|
+
- **audit** — forced with `audit=true`, or nothing else found
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Requires Python 3.10+. You'll need a free API key from [DeepSeek](https://platform.deepseek.com) (no credit card required).
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install contrarian
|
|
57
|
+
contrarian setup
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`contrarian setup` prompts for your API key, then writes the MCP server config to `~/.claude/.claude.json` automatically. Restart Claude Code after running it.
|
|
61
|
+
|
|
62
|
+
That's it.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### Manual config (alternative)
|
|
67
|
+
|
|
68
|
+
If you prefer to configure manually, add to `~/.claude/.claude.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"contrarian": {
|
|
74
|
+
"type": "stdio",
|
|
75
|
+
"command": "contrarian",
|
|
76
|
+
"args": [],
|
|
77
|
+
"env": {
|
|
78
|
+
"JUDGE_B_API_KEY": "sk-or-...",
|
|
79
|
+
"JUDGE_B_BASE_URL": "https://openrouter.ai/api/v1"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Default provider is DeepSeek (`api.deepseek.com`, model `deepseek-chat`). Change `JUDGE_B_BASE_URL` and `JUDGE_B_MODEL` to use any OpenAI-compatible provider (Gemini, Groq, Ollama, OpenRouter, etc.).
|
|
87
|
+
|
|
88
|
+
Anthropic models are blocked at runtime — Judge B must come from a different lab than Judge A.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
Inside any Claude Code session, the tool is available as `contrarian_review`.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
contrarian_review() # auto-detect mode
|
|
98
|
+
contrarian_review({ path: "src/auth.py" }) # single file, diff
|
|
99
|
+
contrarian_review({ path: "src/auth.py", full: true }) # full file
|
|
100
|
+
contrarian_review({ paths: ["src/a.py", "src/b.py"] }) # multi-file, reviewed as a unit
|
|
101
|
+
contrarian_review({ audit: true }) # force full repo walk
|
|
102
|
+
contrarian_review({ model: "google/gemini-3.1-pro-preview" }) # one-off model override
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Rubric
|
|
108
|
+
|
|
109
|
+
Place a `JUDGE.md` at the project root to customize what Judge B looks for. Without it, a built-in fallback rubric applies.
|
|
110
|
+
|
|
111
|
+
The built-in rubric covers four dimensions:
|
|
112
|
+
|
|
113
|
+
| Dimension | What to find |
|
|
114
|
+
|---|---|
|
|
115
|
+
| **Exactitude** | Logic errors, wrong assumptions, incorrect implementation |
|
|
116
|
+
| **Missing** | Unhandled cases, absent validation, unconsidered implications |
|
|
117
|
+
| **Premises** | Assumptions that could be wrong |
|
|
118
|
+
| **Alternatives** | Fundamentally different approaches |
|
|
119
|
+
|
|
120
|
+
Output is always JSON:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"exactitude": { "verdict": "ok|warn|fail", "findings": [] },
|
|
125
|
+
"missing": [],
|
|
126
|
+
"premises": [],
|
|
127
|
+
"alternatives": []
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Report log
|
|
134
|
+
|
|
135
|
+
Findings are appended to `REPORT.md` at the project root under `[Judge B]` blocks. Claude Code annotates findings under `[Judge A]` blocks. Judge B reads previous exchanges before each review — closed findings (`addressed`, `won't fix`, `disagree`) are not re-raised.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Phase roadmap
|
|
140
|
+
|
|
141
|
+
- **Phase 2 (current):** MCP server + project graph context. Auto-detect mode. Python rewrite.
|
|
142
|
+
- **Phase 3:** Operational hardening — token budget guard, graph latency, detached HEAD handling.
|
|
143
|
+
- **Phase 4:** Agent watchdog — autonomous background review on commit.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Contrarian
|
|
2
|
+
|
|
3
|
+
MCP server that adds a second LLM perspective (Judge B) directly inside Claude Code.
|
|
4
|
+
|
|
5
|
+
Claude Code is Judge A — it already has full project context. Contrarian adds Judge B, an independent model from a different lab, via a single tool call. Same rubric, different priors, no context switch.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
1. Claude Code invokes `contrarian_review()` — no args for auto-detect, or pass specific files.
|
|
12
|
+
2. Contrarian resolves the input: git diff, last commit, or full audit (in that order).
|
|
13
|
+
3. Judge B (external model) reviews against `JUDGE.md` in the project root.
|
|
14
|
+
4. Findings are returned inline and appended to `REPORT.md` for the dialogue log.
|
|
15
|
+
|
|
16
|
+
Auto-detect order:
|
|
17
|
+
- **diff** — any staged/unstaged changes or untracked files
|
|
18
|
+
- **last-commit** — clean tree → reviews the last commit
|
|
19
|
+
- **audit** — forced with `audit=true`, or nothing else found
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Requires Python 3.10+. You'll need a free API key from [DeepSeek](https://platform.deepseek.com) (no credit card required).
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install contrarian
|
|
29
|
+
contrarian setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`contrarian setup` prompts for your API key, then writes the MCP server config to `~/.claude/.claude.json` automatically. Restart Claude Code after running it.
|
|
33
|
+
|
|
34
|
+
That's it.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### Manual config (alternative)
|
|
39
|
+
|
|
40
|
+
If you prefer to configure manually, add to `~/.claude/.claude.json`:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"contrarian": {
|
|
46
|
+
"type": "stdio",
|
|
47
|
+
"command": "contrarian",
|
|
48
|
+
"args": [],
|
|
49
|
+
"env": {
|
|
50
|
+
"JUDGE_B_API_KEY": "sk-or-...",
|
|
51
|
+
"JUDGE_B_BASE_URL": "https://openrouter.ai/api/v1"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Default provider is DeepSeek (`api.deepseek.com`, model `deepseek-chat`). Change `JUDGE_B_BASE_URL` and `JUDGE_B_MODEL` to use any OpenAI-compatible provider (Gemini, Groq, Ollama, OpenRouter, etc.).
|
|
59
|
+
|
|
60
|
+
Anthropic models are blocked at runtime — Judge B must come from a different lab than Judge A.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
Inside any Claude Code session, the tool is available as `contrarian_review`.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
contrarian_review() # auto-detect mode
|
|
70
|
+
contrarian_review({ path: "src/auth.py" }) # single file, diff
|
|
71
|
+
contrarian_review({ path: "src/auth.py", full: true }) # full file
|
|
72
|
+
contrarian_review({ paths: ["src/a.py", "src/b.py"] }) # multi-file, reviewed as a unit
|
|
73
|
+
contrarian_review({ audit: true }) # force full repo walk
|
|
74
|
+
contrarian_review({ model: "google/gemini-3.1-pro-preview" }) # one-off model override
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Rubric
|
|
80
|
+
|
|
81
|
+
Place a `JUDGE.md` at the project root to customize what Judge B looks for. Without it, a built-in fallback rubric applies.
|
|
82
|
+
|
|
83
|
+
The built-in rubric covers four dimensions:
|
|
84
|
+
|
|
85
|
+
| Dimension | What to find |
|
|
86
|
+
|---|---|
|
|
87
|
+
| **Exactitude** | Logic errors, wrong assumptions, incorrect implementation |
|
|
88
|
+
| **Missing** | Unhandled cases, absent validation, unconsidered implications |
|
|
89
|
+
| **Premises** | Assumptions that could be wrong |
|
|
90
|
+
| **Alternatives** | Fundamentally different approaches |
|
|
91
|
+
|
|
92
|
+
Output is always JSON:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"exactitude": { "verdict": "ok|warn|fail", "findings": [] },
|
|
97
|
+
"missing": [],
|
|
98
|
+
"premises": [],
|
|
99
|
+
"alternatives": []
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Report log
|
|
106
|
+
|
|
107
|
+
Findings are appended to `REPORT.md` at the project root under `[Judge B]` blocks. Claude Code annotates findings under `[Judge A]` blocks. Judge B reads previous exchanges before each review — closed findings (`addressed`, `won't fix`, `disagree`) are not re-raised.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Phase roadmap
|
|
112
|
+
|
|
113
|
+
- **Phase 2 (current):** MCP server + project graph context. Auto-detect mode. Python rewrite.
|
|
114
|
+
- **Phase 3:** Operational hardening — token budget guard, graph latency, detached HEAD handling.
|
|
115
|
+
- **Phase 4:** Agent watchdog — autonomous background review on commit.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contrarian"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "MCP server adding an independent second LLM perspective (Judge B) with full project graph context"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Anthony Micho", email = "anthony.micho76@gmail.com" }]
|
|
13
|
+
keywords = ["mcp", "llm", "code-review", "claude", "judge"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"mcp",
|
|
25
|
+
"openai>=1.0",
|
|
26
|
+
"python-dotenv",
|
|
27
|
+
"networkx>=3.4",
|
|
28
|
+
"graphifyy[leiden]",
|
|
29
|
+
"json-repair>=0.6",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/anthonymicho/contrarian"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
all = ["graphifyy[all]"]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
contrarian = "contrarian.server:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Build and query contrarian-out/graph.json for context about reviewed files.
|
|
2
|
+
|
|
3
|
+
Graph is built automatically on first call, cached until any project file changes.
|
|
4
|
+
Stale rebuilds run in a background thread — the existing graph is used immediately
|
|
5
|
+
and the fresh one is ready for the next call. Only the very first build blocks.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_rebuild_lock = threading.Lock()
|
|
13
|
+
_rebuild_thread: threading.Thread | None = None
|
|
14
|
+
_rebuild_failed = False # set by background thread on error; cleared on next successful build
|
|
15
|
+
|
|
16
|
+
_GRAPH_SUBDIR = "contrarian-out"
|
|
17
|
+
_GRAPH_FILE = "graph.json"
|
|
18
|
+
_HEAD_FILE = "HEAD.sha"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _graph_path(project_root: str) -> Path:
|
|
22
|
+
return Path(project_root) / _GRAPH_SUBDIR / _GRAPH_FILE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _head_path(project_root: str) -> Path:
|
|
26
|
+
return Path(project_root) / _GRAPH_SUBDIR / _HEAD_FILE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _current_head(project_root: str) -> str | None:
|
|
30
|
+
import subprocess
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["git", "rev-parse", "HEAD"],
|
|
33
|
+
cwd=project_root, capture_output=True, text=True,
|
|
34
|
+
)
|
|
35
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _saved_head(project_root: str) -> str | None:
|
|
39
|
+
hp = _head_path(project_root)
|
|
40
|
+
return hp.read_text(encoding="utf-8").strip() if hp.exists() else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_stale(project_root: str) -> bool:
|
|
44
|
+
gp = _graph_path(project_root)
|
|
45
|
+
if not gp.exists():
|
|
46
|
+
return True
|
|
47
|
+
import subprocess
|
|
48
|
+
# Check HEAD SHA first — catches branch switches and resets that leave a clean tree
|
|
49
|
+
current = _current_head(project_root)
|
|
50
|
+
if current and current != _saved_head(project_root):
|
|
51
|
+
return True
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["git", "status", "--porcelain"],
|
|
54
|
+
cwd=project_root, capture_output=True, text=True,
|
|
55
|
+
)
|
|
56
|
+
# Any uncommitted change means potentially stale
|
|
57
|
+
if result.returncode == 0:
|
|
58
|
+
return bool(result.stdout.strip())
|
|
59
|
+
# Not a git repo — check mtimes of files the graph already tracks.
|
|
60
|
+
# Covers nested paths that a shallow iterdir() would miss.
|
|
61
|
+
graph_mtime = gp.stat().st_mtime
|
|
62
|
+
try:
|
|
63
|
+
graph_data = json.loads(gp.read_text(encoding="utf-8"))
|
|
64
|
+
except Exception:
|
|
65
|
+
return True
|
|
66
|
+
for node in graph_data.get("nodes", []):
|
|
67
|
+
src = node.get("source_file", "")
|
|
68
|
+
if not src:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
if (Path(project_root) / src).stat().st_mtime > graph_mtime:
|
|
72
|
+
return True
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _log(msg: str) -> None:
|
|
79
|
+
import sys
|
|
80
|
+
print(f"[contrarian] {msg}", file=sys.stderr)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_graph(project_root: str) -> bool:
|
|
84
|
+
try:
|
|
85
|
+
from graphify.detect import detect
|
|
86
|
+
from graphify.extract import extract
|
|
87
|
+
from graphify.build import build
|
|
88
|
+
from graphify.cluster import cluster
|
|
89
|
+
from graphify.export import to_json
|
|
90
|
+
except ImportError as e:
|
|
91
|
+
_log(f"Graph build skipped — graphifyy not installed: {e}")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
root = Path(project_root)
|
|
95
|
+
out_dir = root / _GRAPH_SUBDIR
|
|
96
|
+
out_dir.mkdir(exist_ok=True)
|
|
97
|
+
|
|
98
|
+
gitignore = root / ".gitignore"
|
|
99
|
+
entry = f"{_GRAPH_SUBDIR}/"
|
|
100
|
+
if not gitignore.exists() or entry not in gitignore.read_text(encoding="utf-8"):
|
|
101
|
+
with gitignore.open("a", encoding="utf-8") as f:
|
|
102
|
+
f.write(f"\n{entry}\n")
|
|
103
|
+
|
|
104
|
+
manifest = detect(root)
|
|
105
|
+
files = manifest.get("files", [])
|
|
106
|
+
if not files:
|
|
107
|
+
_log("Graph build skipped — no source files detected by graphifyy")
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
extractions_dict = extract(files, cache_root=out_dir)
|
|
111
|
+
raw = list(extractions_dict.values()) if isinstance(extractions_dict, dict) else extractions_dict
|
|
112
|
+
extractions = []
|
|
113
|
+
for e in raw:
|
|
114
|
+
try:
|
|
115
|
+
if e:
|
|
116
|
+
extractions.append(e)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
if not extractions:
|
|
120
|
+
_log("Graph build skipped — all extractions were empty")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
G = build(extractions, directed=True)
|
|
124
|
+
communities = cluster(G)
|
|
125
|
+
# Write to a temp file then rename atomically so concurrent reads never see a partial write
|
|
126
|
+
tmp = out_dir / (_GRAPH_FILE + ".tmp")
|
|
127
|
+
to_json(G, communities, str(tmp), force=True)
|
|
128
|
+
tmp.rename(out_dir / _GRAPH_FILE)
|
|
129
|
+
# Persist HEAD SHA so branch switches are detected next time
|
|
130
|
+
head = _current_head(project_root)
|
|
131
|
+
if head:
|
|
132
|
+
_head_path(project_root).write_text(head + "\n", encoding="utf-8")
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _load_graph(project_root: str) -> dict | None:
|
|
137
|
+
gp = _graph_path(project_root)
|
|
138
|
+
if not gp.exists():
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(gp.read_text(encoding="utf-8"))
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _nodes_for_files(graph: dict, paths: list[str]) -> list[str]:
|
|
147
|
+
path_set = set(paths)
|
|
148
|
+
basenames = {Path(p).name for p in paths}
|
|
149
|
+
result = []
|
|
150
|
+
for n in graph.get("nodes", []):
|
|
151
|
+
src = n.get("source_file", "")
|
|
152
|
+
if not src:
|
|
153
|
+
continue
|
|
154
|
+
src_path = Path(src)
|
|
155
|
+
# Prefer exact relative-path match; fall back to basename only when the
|
|
156
|
+
# graph node stores a bare filename (no directory component), to avoid
|
|
157
|
+
# false matches between files with identical names in different directories.
|
|
158
|
+
if src in path_set or (src_path.parent == Path(".") and src_path.name in basenames):
|
|
159
|
+
result.append(n["id"])
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _one_hop_summary(graph: dict, node_ids: list[str]) -> str:
|
|
164
|
+
id_set = set(node_ids)
|
|
165
|
+
node_map = {n["id"]: n for n in graph.get("nodes", [])}
|
|
166
|
+
|
|
167
|
+
node_lines = [
|
|
168
|
+
f"- {node_map[nid].get('label', nid)} ({node_map[nid].get('source_file', '')})"
|
|
169
|
+
for nid in node_ids if nid in node_map
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
rel_lines: list[str] = []
|
|
173
|
+
neighbour_ids: set[str] = set()
|
|
174
|
+
for edge in graph.get("links", graph.get("edges", [])):
|
|
175
|
+
src, tgt = edge.get("source", ""), edge.get("target", "")
|
|
176
|
+
if src in id_set or tgt in id_set:
|
|
177
|
+
other = tgt if src in id_set else src
|
|
178
|
+
neighbour_ids.add(other)
|
|
179
|
+
rel = edge.get("relation", "relates_to")
|
|
180
|
+
src_label = node_map.get(src, {}).get("label", src)
|
|
181
|
+
tgt_label = node_map.get(tgt, {}).get("label", tgt)
|
|
182
|
+
rel_lines.append(f" {src_label} --{rel}--> {tgt_label}")
|
|
183
|
+
|
|
184
|
+
parts: list[str] = []
|
|
185
|
+
if node_lines:
|
|
186
|
+
parts.append("Reviewed nodes:\n" + "\n".join(node_lines))
|
|
187
|
+
if rel_lines:
|
|
188
|
+
parts.append("Relationships:\n" + "\n".join(rel_lines[:40]))
|
|
189
|
+
if neighbour_ids:
|
|
190
|
+
labels = [node_map.get(n, {}).get("label", n) for n in list(neighbour_ids)[:20]]
|
|
191
|
+
parts.append("Neighbours: " + ", ".join(labels))
|
|
192
|
+
|
|
193
|
+
return "\n\n".join(parts)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _rebuild_background(project_root: str) -> None:
|
|
197
|
+
global _rebuild_thread
|
|
198
|
+
|
|
199
|
+
if _rebuild_thread and _rebuild_thread.is_alive():
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
def _worker():
|
|
203
|
+
global _rebuild_failed
|
|
204
|
+
try:
|
|
205
|
+
with _rebuild_lock:
|
|
206
|
+
_build_graph(project_root)
|
|
207
|
+
_rebuild_failed = False
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
_rebuild_failed = True
|
|
210
|
+
_log(f"Background graph rebuild failed: {exc}")
|
|
211
|
+
|
|
212
|
+
_rebuild_thread = threading.Thread(target=_worker, daemon=True, name="contrarian-graph-rebuild")
|
|
213
|
+
_rebuild_thread.start()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_graph_context(project_root: str, paths: list[str]) -> str:
|
|
217
|
+
global _rebuild_failed
|
|
218
|
+
graph_exists = _graph_path(project_root).exists()
|
|
219
|
+
stale = _is_stale(project_root)
|
|
220
|
+
|
|
221
|
+
if stale or _rebuild_failed:
|
|
222
|
+
if graph_exists and not _rebuild_failed:
|
|
223
|
+
# Serve existing graph; rebuild in background for next call
|
|
224
|
+
_rebuild_background(project_root)
|
|
225
|
+
else:
|
|
226
|
+
# First build, or last background rebuild failed — block and retry.
|
|
227
|
+
# Propagates on failure so the caller sees the error instead of silent stale data.
|
|
228
|
+
_rebuild_failed = False
|
|
229
|
+
with _rebuild_lock:
|
|
230
|
+
_build_graph(project_root)
|
|
231
|
+
|
|
232
|
+
graph = _load_graph(project_root)
|
|
233
|
+
if not graph:
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
node_ids = _nodes_for_files(graph, paths)
|
|
237
|
+
if not node_ids:
|
|
238
|
+
return ""
|
|
239
|
+
|
|
240
|
+
return _one_hop_summary(graph, node_ids)
|