crucible-mcp 0.2.0__tar.gz → 0.3.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.
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/PKG-INFO +18 -5
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/README.md +17 -4
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/pyproject.toml +1 -1
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/cli.py +65 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/server.py +514 -56
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible_mcp.egg-info/PKG-INFO +18 -5
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_full_review.py +135 -2
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/setup.cfg +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/domain/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/domain/detection.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/errors.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/hooks/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/hooks/precommit.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/knowledge/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/knowledge/loader.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/models.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/skills/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/skills/loader.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/synthesis/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/tools/__init__.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/tools/delegation.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible/tools/git.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible_mcp.egg-info/SOURCES.txt +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible_mcp.egg-info/dependency_links.txt +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible_mcp.egg-info/entry_points.txt +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible_mcp.egg-info/requires.txt +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/src/crucible_mcp.egg-info/top_level.txt +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_cli.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_detection.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_git.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_hooks_cli.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_integration.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_knowledge.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_precommit.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_server.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_skills.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_skills_loader.py +0 -0
- {crucible_mcp-0.2.0 → crucible_mcp-0.3.0}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crucible-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
|
|
5
5
|
Author: be.nvy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -73,20 +73,33 @@ Code → Detect Domain → Load Personas + Knowledge → Claude with YOUR patter
|
|
|
73
73
|
|
|
74
74
|
| Tool | Purpose |
|
|
75
75
|
|------|---------|
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
76
|
+
| `review(path)` | Full review: analysis + skills + knowledge |
|
|
77
|
+
| `review(mode='staged')` | Review git changes with skills + knowledge |
|
|
78
|
+
| `load_knowledge(files)` | Load specific knowledge files |
|
|
79
|
+
| `get_principles(topic)` | Load engineering knowledge by topic |
|
|
78
80
|
| `delegate_*` | Direct tool access (semgrep, ruff, slither, bandit) |
|
|
79
81
|
| `check_tools()` | Show installed analysis tools |
|
|
80
82
|
|
|
83
|
+
The unified `review` tool supports:
|
|
84
|
+
- **Path-based**: `review(path="src/")` - analyze files/directories
|
|
85
|
+
- **Git-aware**: `review(mode="staged")` - analyze changes (staged, unstaged, branch, commits)
|
|
86
|
+
- **Quick mode**: `review(path, include_skills=false)` - analysis only, no skills/knowledge
|
|
87
|
+
|
|
81
88
|
## CLI
|
|
82
89
|
|
|
83
90
|
```bash
|
|
91
|
+
crucible init # Initialize .crucible/ for your project
|
|
92
|
+
crucible review # Review staged changes
|
|
93
|
+
crucible review --mode branch # Review current branch vs main
|
|
94
|
+
crucible ci generate # Generate GitHub Actions workflow
|
|
95
|
+
|
|
84
96
|
crucible skills list # List all skills
|
|
85
|
-
crucible skills show <skill> # Show which version is active
|
|
86
97
|
crucible skills init <skill> # Copy to .crucible/ for customization
|
|
87
98
|
|
|
88
99
|
crucible knowledge list # List all knowledge files
|
|
89
100
|
crucible knowledge init <file> # Copy for customization
|
|
101
|
+
|
|
102
|
+
crucible hooks install # Install pre-commit hook
|
|
90
103
|
```
|
|
91
104
|
|
|
92
105
|
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full flow.
|
|
@@ -135,6 +148,6 @@ See [KNOWLEDGE.md](docs/KNOWLEDGE.md) for topics covered.
|
|
|
135
148
|
|
|
136
149
|
```bash
|
|
137
150
|
pip install -e ".[dev]"
|
|
138
|
-
pytest # Run tests (
|
|
151
|
+
pytest # Run tests (509 tests)
|
|
139
152
|
ruff check src/ --fix # Lint
|
|
140
153
|
```
|
|
@@ -56,20 +56,33 @@ Code → Detect Domain → Load Personas + Knowledge → Claude with YOUR patter
|
|
|
56
56
|
|
|
57
57
|
| Tool | Purpose |
|
|
58
58
|
|------|---------|
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
59
|
+
| `review(path)` | Full review: analysis + skills + knowledge |
|
|
60
|
+
| `review(mode='staged')` | Review git changes with skills + knowledge |
|
|
61
|
+
| `load_knowledge(files)` | Load specific knowledge files |
|
|
62
|
+
| `get_principles(topic)` | Load engineering knowledge by topic |
|
|
61
63
|
| `delegate_*` | Direct tool access (semgrep, ruff, slither, bandit) |
|
|
62
64
|
| `check_tools()` | Show installed analysis tools |
|
|
63
65
|
|
|
66
|
+
The unified `review` tool supports:
|
|
67
|
+
- **Path-based**: `review(path="src/")` - analyze files/directories
|
|
68
|
+
- **Git-aware**: `review(mode="staged")` - analyze changes (staged, unstaged, branch, commits)
|
|
69
|
+
- **Quick mode**: `review(path, include_skills=false)` - analysis only, no skills/knowledge
|
|
70
|
+
|
|
64
71
|
## CLI
|
|
65
72
|
|
|
66
73
|
```bash
|
|
74
|
+
crucible init # Initialize .crucible/ for your project
|
|
75
|
+
crucible review # Review staged changes
|
|
76
|
+
crucible review --mode branch # Review current branch vs main
|
|
77
|
+
crucible ci generate # Generate GitHub Actions workflow
|
|
78
|
+
|
|
67
79
|
crucible skills list # List all skills
|
|
68
|
-
crucible skills show <skill> # Show which version is active
|
|
69
80
|
crucible skills init <skill> # Copy to .crucible/ for customization
|
|
70
81
|
|
|
71
82
|
crucible knowledge list # List all knowledge files
|
|
72
83
|
crucible knowledge init <file> # Copy for customization
|
|
84
|
+
|
|
85
|
+
crucible hooks install # Install pre-commit hook
|
|
73
86
|
```
|
|
74
87
|
|
|
75
88
|
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full flow.
|
|
@@ -118,6 +131,6 @@ See [KNOWLEDGE.md](docs/KNOWLEDGE.md) for topics covered.
|
|
|
118
131
|
|
|
119
132
|
```bash
|
|
120
133
|
pip install -e ".[dev]"
|
|
121
|
-
pytest # Run tests (
|
|
134
|
+
pytest # Run tests (509 tests)
|
|
122
135
|
ruff check src/ --fix # Lint
|
|
123
136
|
```
|
|
@@ -652,11 +652,13 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
652
652
|
|
|
653
653
|
# Track domains detected for per-domain threshold checking
|
|
654
654
|
domains_detected: set[Domain] = set()
|
|
655
|
+
all_domain_tags: set[str] = set()
|
|
655
656
|
|
|
656
657
|
for file_path in changed_files:
|
|
657
658
|
full_path = f"{repo_path}/{file_path}"
|
|
658
659
|
domain, domain_tags = detect_domain(file_path)
|
|
659
660
|
domains_detected.add(domain)
|
|
661
|
+
all_domain_tags.update(domain_tags)
|
|
660
662
|
|
|
661
663
|
# Select tools based on domain
|
|
662
664
|
if domain == Domain.SMART_CONTRACT:
|
|
@@ -727,6 +729,37 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
727
729
|
|
|
728
730
|
filtered_findings = deduplicate_findings(filtered_findings)
|
|
729
731
|
|
|
732
|
+
# Match skills and load knowledge based on detected domains
|
|
733
|
+
from crucible.knowledge.loader import load_knowledge_file
|
|
734
|
+
from crucible.skills.loader import (
|
|
735
|
+
get_knowledge_for_skills,
|
|
736
|
+
load_skill,
|
|
737
|
+
match_skills_for_domain,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
# Use first domain for primary matching (most files determine this)
|
|
741
|
+
primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
|
|
742
|
+
matched_skills = match_skills_for_domain(
|
|
743
|
+
primary_domain, list(all_domain_tags), override=None
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Load skill content
|
|
747
|
+
skill_names = [name for name, _ in matched_skills]
|
|
748
|
+
skill_content: dict[str, str] = {}
|
|
749
|
+
for skill_name, _triggers in matched_skills:
|
|
750
|
+
result = load_skill(skill_name)
|
|
751
|
+
if result.is_ok:
|
|
752
|
+
_, content = result.value
|
|
753
|
+
skill_content[skill_name] = content
|
|
754
|
+
|
|
755
|
+
# Load linked knowledge
|
|
756
|
+
knowledge_files = get_knowledge_for_skills(skill_names)
|
|
757
|
+
knowledge_content: dict[str, str] = {}
|
|
758
|
+
for filename in knowledge_files:
|
|
759
|
+
result = load_knowledge_file(filename)
|
|
760
|
+
if result.is_ok:
|
|
761
|
+
knowledge_content[filename] = result.value
|
|
762
|
+
|
|
730
763
|
# Compute severity summary
|
|
731
764
|
severity_counts: dict[str, int] = {}
|
|
732
765
|
for f in filtered_findings:
|
|
@@ -767,6 +800,8 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
767
800
|
"mode": mode,
|
|
768
801
|
"files_changed": len(changed_files),
|
|
769
802
|
"domains_detected": [d.value for d in domains_detected],
|
|
803
|
+
"skills_matched": dict(matched_skills),
|
|
804
|
+
"knowledge_loaded": list(knowledge_files),
|
|
770
805
|
"findings": [
|
|
771
806
|
{
|
|
772
807
|
"tool": f.tool,
|
|
@@ -817,6 +852,19 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
817
852
|
print(f"- `{c.path}` ({status_char})")
|
|
818
853
|
print()
|
|
819
854
|
|
|
855
|
+
# Skills matched
|
|
856
|
+
if matched_skills:
|
|
857
|
+
print("## Applicable Skills\n")
|
|
858
|
+
for skill_name, triggers in matched_skills:
|
|
859
|
+
print(f"- **{skill_name}**: matched on {', '.join(triggers)}")
|
|
860
|
+
print()
|
|
861
|
+
|
|
862
|
+
# Knowledge loaded
|
|
863
|
+
if knowledge_files:
|
|
864
|
+
print("## Knowledge Loaded\n")
|
|
865
|
+
print(f"Files: {', '.join(sorted(knowledge_files))}")
|
|
866
|
+
print()
|
|
867
|
+
|
|
820
868
|
# Findings by severity
|
|
821
869
|
if filtered_findings:
|
|
822
870
|
print("## Findings\n")
|
|
@@ -849,6 +897,23 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
849
897
|
print(f"- {error}")
|
|
850
898
|
print()
|
|
851
899
|
|
|
900
|
+
# Review checklists from skills
|
|
901
|
+
if skill_content:
|
|
902
|
+
print("## Review Checklists\n")
|
|
903
|
+
for skill_name, content in skill_content.items():
|
|
904
|
+
print(f"### {skill_name}\n")
|
|
905
|
+
# Print the skill content (already markdown)
|
|
906
|
+
print(content)
|
|
907
|
+
print()
|
|
908
|
+
|
|
909
|
+
# Knowledge reference
|
|
910
|
+
if knowledge_content:
|
|
911
|
+
print("## Principles Reference\n")
|
|
912
|
+
for filename, content in sorted(knowledge_content.items()):
|
|
913
|
+
print(f"### {filename}\n")
|
|
914
|
+
print(content)
|
|
915
|
+
print()
|
|
916
|
+
|
|
852
917
|
# Result
|
|
853
918
|
print("## Result\n")
|
|
854
919
|
if effective_threshold:
|
|
@@ -97,9 +97,50 @@ def _deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
|
|
|
97
97
|
async def list_tools() -> list[Tool]:
|
|
98
98
|
"""List available tools."""
|
|
99
99
|
return [
|
|
100
|
+
Tool(
|
|
101
|
+
name="review",
|
|
102
|
+
description="Unified code review tool. Supports path-based review OR git-aware review. Runs static analysis, matches skills, loads knowledge.",
|
|
103
|
+
inputSchema={
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"path": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "File or directory path to review. If not provided, uses git mode.",
|
|
109
|
+
},
|
|
110
|
+
"mode": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"enum": ["staged", "unstaged", "branch", "commits"],
|
|
113
|
+
"description": "Git mode: staged (about to commit), unstaged (working dir), branch (PR diff), commits (recent N)",
|
|
114
|
+
},
|
|
115
|
+
"base": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"description": "Base branch for 'branch' mode (default: main) or commit count for 'commits' mode (default: 1)",
|
|
118
|
+
},
|
|
119
|
+
"include_context": {
|
|
120
|
+
"type": "boolean",
|
|
121
|
+
"description": "For git modes: include findings near (within 5 lines of) changes (default: false)",
|
|
122
|
+
},
|
|
123
|
+
"skills": {
|
|
124
|
+
"type": "array",
|
|
125
|
+
"items": {"type": "string"},
|
|
126
|
+
"description": "Override skill selection (default: auto-detect based on domain)",
|
|
127
|
+
},
|
|
128
|
+
"include_skills": {
|
|
129
|
+
"type": "boolean",
|
|
130
|
+
"description": "Load skills and checklists (default: true). Set false for quick analysis only.",
|
|
131
|
+
"default": True,
|
|
132
|
+
},
|
|
133
|
+
"include_knowledge": {
|
|
134
|
+
"type": "boolean",
|
|
135
|
+
"description": "Load knowledge files (default: true). Set false for quick analysis only.",
|
|
136
|
+
"default": True,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
),
|
|
100
141
|
Tool(
|
|
101
142
|
name="quick_review",
|
|
102
|
-
description="
|
|
143
|
+
description="[DEPRECATED: use review(path, include_skills=false)] Run static analysis only.",
|
|
103
144
|
inputSchema={
|
|
104
145
|
"type": "object",
|
|
105
146
|
"properties": {
|
|
@@ -116,6 +157,57 @@ async def list_tools() -> list[Tool]:
|
|
|
116
157
|
"required": ["path"],
|
|
117
158
|
},
|
|
118
159
|
),
|
|
160
|
+
Tool(
|
|
161
|
+
name="full_review",
|
|
162
|
+
description="[DEPRECATED: use review(path)] Comprehensive code review with skills and knowledge.",
|
|
163
|
+
inputSchema={
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {
|
|
166
|
+
"path": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "File or directory path to review",
|
|
169
|
+
},
|
|
170
|
+
"skills": {
|
|
171
|
+
"type": "array",
|
|
172
|
+
"items": {"type": "string"},
|
|
173
|
+
"description": "Override skill selection (default: auto-detect based on domain)",
|
|
174
|
+
},
|
|
175
|
+
"include_sage": {
|
|
176
|
+
"type": "boolean",
|
|
177
|
+
"description": "Include Sage knowledge recall (not yet implemented)",
|
|
178
|
+
"default": True,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"required": ["path"],
|
|
182
|
+
},
|
|
183
|
+
),
|
|
184
|
+
Tool(
|
|
185
|
+
name="review_changes",
|
|
186
|
+
description="[DEPRECATED: use review(mode='staged')] Review git changes.",
|
|
187
|
+
inputSchema={
|
|
188
|
+
"type": "object",
|
|
189
|
+
"properties": {
|
|
190
|
+
"mode": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"enum": ["staged", "unstaged", "branch", "commits"],
|
|
193
|
+
"description": "What changes to review",
|
|
194
|
+
},
|
|
195
|
+
"base": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"description": "Base branch for 'branch' mode or commit count for 'commits' mode",
|
|
198
|
+
},
|
|
199
|
+
"path": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"description": "Repository path (default: current directory)",
|
|
202
|
+
},
|
|
203
|
+
"include_context": {
|
|
204
|
+
"type": "boolean",
|
|
205
|
+
"description": "Include findings near changes (default: false)",
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
"required": ["mode"],
|
|
209
|
+
},
|
|
210
|
+
),
|
|
119
211
|
Tool(
|
|
120
212
|
name="get_principles",
|
|
121
213
|
description="Load engineering principles by topic",
|
|
@@ -203,57 +295,6 @@ async def list_tools() -> list[Tool]:
|
|
|
203
295
|
"properties": {},
|
|
204
296
|
},
|
|
205
297
|
),
|
|
206
|
-
Tool(
|
|
207
|
-
name="review_changes",
|
|
208
|
-
description="Review git changes (staged, unstaged, branch diff, commits). Runs analysis on changed files and filters findings to changed lines only.",
|
|
209
|
-
inputSchema={
|
|
210
|
-
"type": "object",
|
|
211
|
-
"properties": {
|
|
212
|
-
"mode": {
|
|
213
|
-
"type": "string",
|
|
214
|
-
"enum": ["staged", "unstaged", "branch", "commits"],
|
|
215
|
-
"description": "What changes to review: staged (about to commit), unstaged (working dir), branch (PR diff vs base), commits (recent N commits)",
|
|
216
|
-
},
|
|
217
|
-
"base": {
|
|
218
|
-
"type": "string",
|
|
219
|
-
"description": "Base branch for 'branch' mode (default: main) or commit count for 'commits' mode (default: 1)",
|
|
220
|
-
},
|
|
221
|
-
"path": {
|
|
222
|
-
"type": "string",
|
|
223
|
-
"description": "Repository path (default: current directory)",
|
|
224
|
-
},
|
|
225
|
-
"include_context": {
|
|
226
|
-
"type": "boolean",
|
|
227
|
-
"description": "Include findings near (within 5 lines of) changes, not just in changed lines (default: false)",
|
|
228
|
-
},
|
|
229
|
-
},
|
|
230
|
-
"required": ["mode"],
|
|
231
|
-
},
|
|
232
|
-
),
|
|
233
|
-
Tool(
|
|
234
|
-
name="full_review",
|
|
235
|
-
description="Comprehensive code review: runs static analysis, matches applicable skills based on domain, loads linked knowledge. Returns unified report for synthesis.",
|
|
236
|
-
inputSchema={
|
|
237
|
-
"type": "object",
|
|
238
|
-
"properties": {
|
|
239
|
-
"path": {
|
|
240
|
-
"type": "string",
|
|
241
|
-
"description": "File or directory path to review",
|
|
242
|
-
},
|
|
243
|
-
"skills": {
|
|
244
|
-
"type": "array",
|
|
245
|
-
"items": {"type": "string"},
|
|
246
|
-
"description": "Override skill selection (default: auto-detect based on domain)",
|
|
247
|
-
},
|
|
248
|
-
"include_sage": {
|
|
249
|
-
"type": "boolean",
|
|
250
|
-
"description": "Include Sage knowledge recall (not yet implemented)",
|
|
251
|
-
"default": True,
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
"required": ["path"],
|
|
255
|
-
},
|
|
256
|
-
),
|
|
257
298
|
Tool(
|
|
258
299
|
name="load_knowledge",
|
|
259
300
|
description="Load knowledge/principles files without running static analysis. Useful for getting guidance on patterns, best practices, or domain-specific knowledge. Automatically includes project and user knowledge files.",
|
|
@@ -280,6 +321,342 @@ async def list_tools() -> list[Tool]:
|
|
|
280
321
|
]
|
|
281
322
|
|
|
282
323
|
|
|
324
|
+
def _run_static_analysis(
|
|
325
|
+
path: str,
|
|
326
|
+
domain: Domain,
|
|
327
|
+
domain_tags: list[str],
|
|
328
|
+
) -> tuple[list[ToolFinding], list[str]]:
|
|
329
|
+
"""Run static analysis tools based on domain.
|
|
330
|
+
|
|
331
|
+
Returns (findings, tool_errors).
|
|
332
|
+
"""
|
|
333
|
+
# Select tools based on domain
|
|
334
|
+
if domain == Domain.SMART_CONTRACT:
|
|
335
|
+
tools = ["slither", "semgrep"]
|
|
336
|
+
elif domain == Domain.BACKEND and "python" in domain_tags:
|
|
337
|
+
tools = ["ruff", "bandit", "semgrep"]
|
|
338
|
+
elif domain == Domain.FRONTEND:
|
|
339
|
+
tools = ["semgrep"]
|
|
340
|
+
else:
|
|
341
|
+
tools = ["semgrep"]
|
|
342
|
+
|
|
343
|
+
all_findings: list[ToolFinding] = []
|
|
344
|
+
tool_errors: list[str] = []
|
|
345
|
+
|
|
346
|
+
if "semgrep" in tools:
|
|
347
|
+
config = get_semgrep_config(domain)
|
|
348
|
+
result = delegate_semgrep(path, config)
|
|
349
|
+
if result.is_ok:
|
|
350
|
+
all_findings.extend(result.value)
|
|
351
|
+
elif result.is_err:
|
|
352
|
+
tool_errors.append(f"semgrep: {result.error}")
|
|
353
|
+
|
|
354
|
+
if "ruff" in tools:
|
|
355
|
+
result = delegate_ruff(path)
|
|
356
|
+
if result.is_ok:
|
|
357
|
+
all_findings.extend(result.value)
|
|
358
|
+
elif result.is_err:
|
|
359
|
+
tool_errors.append(f"ruff: {result.error}")
|
|
360
|
+
|
|
361
|
+
if "slither" in tools:
|
|
362
|
+
result = delegate_slither(path)
|
|
363
|
+
if result.is_ok:
|
|
364
|
+
all_findings.extend(result.value)
|
|
365
|
+
elif result.is_err:
|
|
366
|
+
tool_errors.append(f"slither: {result.error}")
|
|
367
|
+
|
|
368
|
+
if "bandit" in tools:
|
|
369
|
+
result = delegate_bandit(path)
|
|
370
|
+
if result.is_ok:
|
|
371
|
+
all_findings.extend(result.value)
|
|
372
|
+
elif result.is_err:
|
|
373
|
+
tool_errors.append(f"bandit: {result.error}")
|
|
374
|
+
|
|
375
|
+
return all_findings, tool_errors
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _load_skills_and_knowledge(
|
|
379
|
+
domain: Domain,
|
|
380
|
+
domain_tags: list[str],
|
|
381
|
+
skills_override: list[str] | None = None,
|
|
382
|
+
) -> tuple[list[tuple[str, list[str]]], dict[str, str], set[str], dict[str, str]]:
|
|
383
|
+
"""Load matched skills and linked knowledge.
|
|
384
|
+
|
|
385
|
+
Returns (matched_skills, skill_content, knowledge_files, knowledge_content).
|
|
386
|
+
"""
|
|
387
|
+
from crucible.knowledge.loader import load_knowledge_file
|
|
388
|
+
from crucible.skills.loader import (
|
|
389
|
+
get_knowledge_for_skills,
|
|
390
|
+
load_skill,
|
|
391
|
+
match_skills_for_domain,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
|
|
395
|
+
skill_names = [name for name, _ in matched_skills]
|
|
396
|
+
|
|
397
|
+
# Load skill content
|
|
398
|
+
skill_content: dict[str, str] = {}
|
|
399
|
+
for skill_name, _ in matched_skills:
|
|
400
|
+
result = load_skill(skill_name)
|
|
401
|
+
if result.is_ok:
|
|
402
|
+
_, content = result.value
|
|
403
|
+
# Extract content after frontmatter
|
|
404
|
+
if "\n---\n" in content:
|
|
405
|
+
skill_content[skill_name] = content.split("\n---\n", 1)[1].strip()
|
|
406
|
+
else:
|
|
407
|
+
skill_content[skill_name] = content
|
|
408
|
+
|
|
409
|
+
# Load knowledge from skills + custom project/user knowledge
|
|
410
|
+
knowledge_files = get_knowledge_for_skills(skill_names)
|
|
411
|
+
custom_knowledge = get_custom_knowledge_files()
|
|
412
|
+
knowledge_files = knowledge_files | custom_knowledge
|
|
413
|
+
|
|
414
|
+
knowledge_content: dict[str, str] = {}
|
|
415
|
+
for filename in knowledge_files:
|
|
416
|
+
result = load_knowledge_file(filename)
|
|
417
|
+
if result.is_ok:
|
|
418
|
+
knowledge_content[filename] = result.value
|
|
419
|
+
|
|
420
|
+
return matched_skills, skill_content, knowledge_files, knowledge_content
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _format_review_output(
|
|
424
|
+
path: str | None,
|
|
425
|
+
git_context: GitContext | None,
|
|
426
|
+
domains: list[str],
|
|
427
|
+
severity_counts: dict[str, int],
|
|
428
|
+
findings: list[ToolFinding],
|
|
429
|
+
tool_errors: list[str],
|
|
430
|
+
matched_skills: list[tuple[str, list[str]]] | None,
|
|
431
|
+
skill_content: dict[str, str] | None,
|
|
432
|
+
knowledge_files: set[str] | None,
|
|
433
|
+
knowledge_content: dict[str, str] | None,
|
|
434
|
+
) -> str:
|
|
435
|
+
"""Format unified review output."""
|
|
436
|
+
parts: list[str] = ["# Code Review\n"]
|
|
437
|
+
|
|
438
|
+
# Header based on mode
|
|
439
|
+
if git_context:
|
|
440
|
+
parts.append(f"**Mode:** {git_context.mode}")
|
|
441
|
+
if git_context.base_ref:
|
|
442
|
+
parts.append(f"**Base:** {git_context.base_ref}")
|
|
443
|
+
elif path:
|
|
444
|
+
parts.append(f"**Path:** `{path}`")
|
|
445
|
+
|
|
446
|
+
parts.append(f"**Domains:** {', '.join(domains)}")
|
|
447
|
+
parts.append(f"**Severity summary:** {severity_counts or 'No findings'}\n")
|
|
448
|
+
|
|
449
|
+
# Files changed (git mode)
|
|
450
|
+
if git_context and git_context.changes:
|
|
451
|
+
added = [c for c in git_context.changes if c.status == "A"]
|
|
452
|
+
modified = [c for c in git_context.changes if c.status == "M"]
|
|
453
|
+
deleted = [c for c in git_context.changes if c.status == "D"]
|
|
454
|
+
renamed = [c for c in git_context.changes if c.status == "R"]
|
|
455
|
+
|
|
456
|
+
total = len(git_context.changes)
|
|
457
|
+
parts.append(f"## Files Changed ({total})")
|
|
458
|
+
for c in added:
|
|
459
|
+
parts.append(f"- `+` {c.path}")
|
|
460
|
+
for c in modified:
|
|
461
|
+
parts.append(f"- `~` {c.path}")
|
|
462
|
+
for c in renamed:
|
|
463
|
+
parts.append(f"- `R` {c.old_path} -> {c.path}")
|
|
464
|
+
for c in deleted:
|
|
465
|
+
parts.append(f"- `-` {c.path}")
|
|
466
|
+
parts.append("")
|
|
467
|
+
|
|
468
|
+
# Commit messages
|
|
469
|
+
if git_context.commit_messages:
|
|
470
|
+
parts.append("## Commits")
|
|
471
|
+
for msg in git_context.commit_messages:
|
|
472
|
+
parts.append(f"- {msg}")
|
|
473
|
+
parts.append("")
|
|
474
|
+
|
|
475
|
+
# Tool errors
|
|
476
|
+
if tool_errors:
|
|
477
|
+
parts.append("## Tool Errors\n")
|
|
478
|
+
for error in tool_errors:
|
|
479
|
+
parts.append(f"- {error}")
|
|
480
|
+
parts.append("")
|
|
481
|
+
|
|
482
|
+
# Applicable skills
|
|
483
|
+
if matched_skills:
|
|
484
|
+
parts.append("## Applicable Skills\n")
|
|
485
|
+
for skill_name, triggers in matched_skills:
|
|
486
|
+
parts.append(f"- **{skill_name}**: matched on {', '.join(triggers)}")
|
|
487
|
+
parts.append("")
|
|
488
|
+
|
|
489
|
+
# Knowledge loaded
|
|
490
|
+
if knowledge_files:
|
|
491
|
+
parts.append("## Knowledge Loaded\n")
|
|
492
|
+
parts.append(f"Files: {', '.join(sorted(knowledge_files))}")
|
|
493
|
+
parts.append("")
|
|
494
|
+
|
|
495
|
+
# Findings
|
|
496
|
+
parts.append("## Static Analysis Findings\n")
|
|
497
|
+
if findings:
|
|
498
|
+
parts.append(_format_findings(findings))
|
|
499
|
+
else:
|
|
500
|
+
parts.append("No issues found.")
|
|
501
|
+
parts.append("")
|
|
502
|
+
|
|
503
|
+
# Review checklists from skills
|
|
504
|
+
if skill_content:
|
|
505
|
+
parts.append("---\n")
|
|
506
|
+
parts.append("## Review Checklists\n")
|
|
507
|
+
for skill_name, content in skill_content.items():
|
|
508
|
+
parts.append(f"### {skill_name}\n")
|
|
509
|
+
parts.append(content)
|
|
510
|
+
parts.append("")
|
|
511
|
+
|
|
512
|
+
# Knowledge reference
|
|
513
|
+
if knowledge_content:
|
|
514
|
+
parts.append("---\n")
|
|
515
|
+
parts.append("## Principles Reference\n")
|
|
516
|
+
for filename, content in sorted(knowledge_content.items()):
|
|
517
|
+
parts.append(f"### {filename}\n")
|
|
518
|
+
parts.append(content)
|
|
519
|
+
parts.append("")
|
|
520
|
+
|
|
521
|
+
return "\n".join(parts)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
525
|
+
"""Handle unified review tool."""
|
|
526
|
+
import os
|
|
527
|
+
|
|
528
|
+
path = arguments.get("path")
|
|
529
|
+
mode = arguments.get("mode")
|
|
530
|
+
base = arguments.get("base")
|
|
531
|
+
include_context = arguments.get("include_context", False)
|
|
532
|
+
skills_override = arguments.get("skills")
|
|
533
|
+
include_skills = arguments.get("include_skills", True)
|
|
534
|
+
include_knowledge = arguments.get("include_knowledge", True)
|
|
535
|
+
|
|
536
|
+
# Determine if this is path-based or git-based review
|
|
537
|
+
git_context: GitContext | None = None
|
|
538
|
+
changed_files: list[str] = []
|
|
539
|
+
|
|
540
|
+
if mode:
|
|
541
|
+
# Git-based review
|
|
542
|
+
repo_path = path if path else os.getcwd()
|
|
543
|
+
root_result = get_repo_root(repo_path)
|
|
544
|
+
if root_result.is_err:
|
|
545
|
+
return [TextContent(type="text", text=f"Error: {root_result.error}")]
|
|
546
|
+
repo_path = root_result.value
|
|
547
|
+
|
|
548
|
+
# Get git context based on mode
|
|
549
|
+
if mode == "staged":
|
|
550
|
+
context_result = get_staged_changes(repo_path)
|
|
551
|
+
elif mode == "unstaged":
|
|
552
|
+
context_result = get_unstaged_changes(repo_path)
|
|
553
|
+
elif mode == "branch":
|
|
554
|
+
base_branch = base if base else "main"
|
|
555
|
+
context_result = get_branch_diff(repo_path, base_branch)
|
|
556
|
+
elif mode == "commits":
|
|
557
|
+
try:
|
|
558
|
+
count = int(base) if base else 1
|
|
559
|
+
except ValueError:
|
|
560
|
+
return [TextContent(type="text", text=f"Error: Invalid commit count '{base}'")]
|
|
561
|
+
context_result = get_recent_commits(repo_path, count)
|
|
562
|
+
else:
|
|
563
|
+
return [TextContent(type="text", text=f"Error: Unknown mode '{mode}'")]
|
|
564
|
+
|
|
565
|
+
if context_result.is_err:
|
|
566
|
+
return [TextContent(type="text", text=f"Error: {context_result.error}")]
|
|
567
|
+
|
|
568
|
+
git_context = context_result.value
|
|
569
|
+
|
|
570
|
+
if not git_context.changes:
|
|
571
|
+
if mode == "staged":
|
|
572
|
+
return [TextContent(type="text", text="No changes to review. Stage files with `git add` first.")]
|
|
573
|
+
elif mode == "unstaged":
|
|
574
|
+
return [TextContent(type="text", text="No unstaged changes to review.")]
|
|
575
|
+
else:
|
|
576
|
+
return [TextContent(type="text", text="No changes found.")]
|
|
577
|
+
|
|
578
|
+
changed_files = get_changed_files(git_context)
|
|
579
|
+
if not changed_files:
|
|
580
|
+
return [TextContent(type="text", text="No files to analyze (only deletions).")]
|
|
581
|
+
|
|
582
|
+
elif not path:
|
|
583
|
+
return [TextContent(type="text", text="Error: Either 'path' or 'mode' is required.")]
|
|
584
|
+
|
|
585
|
+
# Detect domains and run analysis
|
|
586
|
+
all_findings: list[ToolFinding] = []
|
|
587
|
+
tool_errors: list[str] = []
|
|
588
|
+
domains_detected: set[Domain] = set()
|
|
589
|
+
all_domain_tags: set[str] = set()
|
|
590
|
+
|
|
591
|
+
if git_context:
|
|
592
|
+
# Git mode: analyze each changed file
|
|
593
|
+
repo_path = get_repo_root(path if path else os.getcwd()).value
|
|
594
|
+
for file_path in changed_files:
|
|
595
|
+
full_path = f"{repo_path}/{file_path}"
|
|
596
|
+
domain, domain_tags = _detect_domain(file_path)
|
|
597
|
+
domains_detected.add(domain)
|
|
598
|
+
all_domain_tags.update(domain_tags)
|
|
599
|
+
|
|
600
|
+
findings, errors = _run_static_analysis(full_path, domain, domain_tags)
|
|
601
|
+
all_findings.extend(findings)
|
|
602
|
+
tool_errors.extend([f"{e} ({file_path})" for e in errors])
|
|
603
|
+
|
|
604
|
+
# Filter findings to changed lines
|
|
605
|
+
all_findings = _filter_findings_to_changes(all_findings, git_context, include_context)
|
|
606
|
+
else:
|
|
607
|
+
# Path mode: analyze the path directly
|
|
608
|
+
domain, domain_tags = _detect_domain(path)
|
|
609
|
+
domains_detected.add(domain)
|
|
610
|
+
all_domain_tags.update(domain_tags)
|
|
611
|
+
|
|
612
|
+
findings, errors = _run_static_analysis(path, domain, domain_tags)
|
|
613
|
+
all_findings.extend(findings)
|
|
614
|
+
tool_errors.extend(errors)
|
|
615
|
+
|
|
616
|
+
# Deduplicate findings
|
|
617
|
+
all_findings = _deduplicate_findings(all_findings)
|
|
618
|
+
|
|
619
|
+
# Compute severity summary
|
|
620
|
+
severity_counts: dict[str, int] = {}
|
|
621
|
+
for f in all_findings:
|
|
622
|
+
sev = f.severity.value
|
|
623
|
+
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
624
|
+
|
|
625
|
+
# Load skills and knowledge
|
|
626
|
+
matched_skills: list[tuple[str, list[str]]] | None = None
|
|
627
|
+
skill_content: dict[str, str] | None = None
|
|
628
|
+
knowledge_files: set[str] | None = None
|
|
629
|
+
knowledge_content: dict[str, str] | None = None
|
|
630
|
+
|
|
631
|
+
if include_skills or include_knowledge:
|
|
632
|
+
primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
|
|
633
|
+
matched, s_content, k_files, k_content = _load_skills_and_knowledge(
|
|
634
|
+
primary_domain, list(all_domain_tags), skills_override
|
|
635
|
+
)
|
|
636
|
+
if include_skills:
|
|
637
|
+
matched_skills = matched
|
|
638
|
+
skill_content = s_content
|
|
639
|
+
if include_knowledge:
|
|
640
|
+
knowledge_files = k_files
|
|
641
|
+
knowledge_content = k_content
|
|
642
|
+
|
|
643
|
+
# Format output
|
|
644
|
+
output = _format_review_output(
|
|
645
|
+
path,
|
|
646
|
+
git_context,
|
|
647
|
+
list(all_domain_tags) if all_domain_tags else ["unknown"],
|
|
648
|
+
severity_counts,
|
|
649
|
+
all_findings,
|
|
650
|
+
tool_errors,
|
|
651
|
+
matched_skills,
|
|
652
|
+
skill_content,
|
|
653
|
+
knowledge_files,
|
|
654
|
+
knowledge_content,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
return [TextContent(type="text", text=output)]
|
|
658
|
+
|
|
659
|
+
|
|
283
660
|
def _handle_get_principles(arguments: dict[str, Any]) -> list[TextContent]:
|
|
284
661
|
"""Handle get_principles tool."""
|
|
285
662
|
topic = arguments.get("topic")
|
|
@@ -609,6 +986,10 @@ def _format_change_review(
|
|
|
609
986
|
findings: list[ToolFinding],
|
|
610
987
|
severity_counts: dict[str, int],
|
|
611
988
|
tool_errors: list[str] | None = None,
|
|
989
|
+
matched_skills: list[tuple[str, list[str]]] | None = None,
|
|
990
|
+
skill_content: dict[str, str] | None = None,
|
|
991
|
+
knowledge_files: set[str] | None = None,
|
|
992
|
+
knowledge_content: dict[str, str] | None = None,
|
|
612
993
|
) -> str:
|
|
613
994
|
"""Format change review output."""
|
|
614
995
|
parts: list[str] = ["# Change Review\n"]
|
|
@@ -642,6 +1023,19 @@ def _format_change_review(
|
|
|
642
1023
|
parts.append(f"- {msg}")
|
|
643
1024
|
parts.append("")
|
|
644
1025
|
|
|
1026
|
+
# Applicable skills
|
|
1027
|
+
if matched_skills:
|
|
1028
|
+
parts.append("## Applicable Skills\n")
|
|
1029
|
+
for skill_name, triggers in matched_skills:
|
|
1030
|
+
parts.append(f"- **{skill_name}**: matched on {', '.join(triggers)}")
|
|
1031
|
+
parts.append("")
|
|
1032
|
+
|
|
1033
|
+
# Knowledge loaded
|
|
1034
|
+
if knowledge_files:
|
|
1035
|
+
parts.append("## Knowledge Loaded\n")
|
|
1036
|
+
parts.append(f"Files: {', '.join(sorted(knowledge_files))}")
|
|
1037
|
+
parts.append("")
|
|
1038
|
+
|
|
645
1039
|
# Tool errors (if any)
|
|
646
1040
|
if tool_errors:
|
|
647
1041
|
parts.append("## Tool Errors\n")
|
|
@@ -657,6 +1051,25 @@ def _format_change_review(
|
|
|
657
1051
|
else:
|
|
658
1052
|
parts.append("## Findings in Changed Code\n")
|
|
659
1053
|
parts.append("No issues found in changed code.")
|
|
1054
|
+
parts.append("")
|
|
1055
|
+
|
|
1056
|
+
# Review checklists from skills
|
|
1057
|
+
if skill_content:
|
|
1058
|
+
parts.append("---\n")
|
|
1059
|
+
parts.append("## Review Checklists\n")
|
|
1060
|
+
for skill_name, content in skill_content.items():
|
|
1061
|
+
parts.append(f"### {skill_name}\n")
|
|
1062
|
+
parts.append(content)
|
|
1063
|
+
parts.append("")
|
|
1064
|
+
|
|
1065
|
+
# Knowledge reference
|
|
1066
|
+
if knowledge_content:
|
|
1067
|
+
parts.append("---\n")
|
|
1068
|
+
parts.append("## Principles Reference\n")
|
|
1069
|
+
for filename, content in sorted(knowledge_content.items()):
|
|
1070
|
+
parts.append(f"### {filename}\n")
|
|
1071
|
+
parts.append(content)
|
|
1072
|
+
parts.append("")
|
|
660
1073
|
|
|
661
1074
|
return "\n".join(parts)
|
|
662
1075
|
|
|
@@ -716,12 +1129,16 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
716
1129
|
# Run analysis on changed files
|
|
717
1130
|
all_findings: list[ToolFinding] = []
|
|
718
1131
|
tool_errors: list[str] = []
|
|
1132
|
+
domains_detected: set[Domain] = set()
|
|
1133
|
+
all_domain_tags: set[str] = set()
|
|
719
1134
|
|
|
720
1135
|
for file_path in changed_files:
|
|
721
1136
|
full_path = f"{repo_path}/{file_path}"
|
|
722
1137
|
|
|
723
1138
|
# Detect domain for this file
|
|
724
1139
|
domain, domain_tags = _detect_domain(file_path)
|
|
1140
|
+
domains_detected.add(domain)
|
|
1141
|
+
all_domain_tags.update(domain_tags)
|
|
725
1142
|
|
|
726
1143
|
# Select tools based on domain
|
|
727
1144
|
if domain == Domain.SMART_CONTRACT:
|
|
@@ -775,8 +1192,45 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
775
1192
|
sev = f.severity.value
|
|
776
1193
|
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
777
1194
|
|
|
1195
|
+
# Match skills and load knowledge based on detected domains
|
|
1196
|
+
from crucible.knowledge.loader import load_knowledge_file
|
|
1197
|
+
from crucible.skills.loader import (
|
|
1198
|
+
get_knowledge_for_skills,
|
|
1199
|
+
load_skill,
|
|
1200
|
+
match_skills_for_domain,
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
|
|
1204
|
+
matched_skills = match_skills_for_domain(
|
|
1205
|
+
primary_domain, list(all_domain_tags), override=None
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
skill_names = [name for name, _ in matched_skills]
|
|
1209
|
+
skill_content: dict[str, str] = {}
|
|
1210
|
+
for skill_name, _triggers in matched_skills:
|
|
1211
|
+
result = load_skill(skill_name)
|
|
1212
|
+
if result.is_ok:
|
|
1213
|
+
_, content = result.value
|
|
1214
|
+
skill_content[skill_name] = content
|
|
1215
|
+
|
|
1216
|
+
knowledge_files = get_knowledge_for_skills(skill_names)
|
|
1217
|
+
knowledge_content: dict[str, str] = {}
|
|
1218
|
+
for filename in knowledge_files:
|
|
1219
|
+
result = load_knowledge_file(filename)
|
|
1220
|
+
if result.is_ok:
|
|
1221
|
+
knowledge_content[filename] = result.value
|
|
1222
|
+
|
|
778
1223
|
# Format output
|
|
779
|
-
output = _format_change_review(
|
|
1224
|
+
output = _format_change_review(
|
|
1225
|
+
context,
|
|
1226
|
+
filtered_findings,
|
|
1227
|
+
severity_counts,
|
|
1228
|
+
tool_errors,
|
|
1229
|
+
matched_skills,
|
|
1230
|
+
skill_content,
|
|
1231
|
+
knowledge_files,
|
|
1232
|
+
knowledge_content,
|
|
1233
|
+
)
|
|
780
1234
|
return [TextContent(type="text", text=output)]
|
|
781
1235
|
|
|
782
1236
|
|
|
@@ -937,16 +1391,20 @@ def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
937
1391
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
938
1392
|
"""Handle tool calls."""
|
|
939
1393
|
handlers = {
|
|
1394
|
+
# Unified review tool
|
|
1395
|
+
"review": _handle_review,
|
|
1396
|
+
# Deprecated tools (kept for backwards compatibility)
|
|
1397
|
+
"quick_review": _handle_quick_review,
|
|
1398
|
+
"full_review": _handle_full_review,
|
|
1399
|
+
"review_changes": _handle_review_changes,
|
|
1400
|
+
# Other tools
|
|
940
1401
|
"get_principles": _handle_get_principles,
|
|
941
1402
|
"load_knowledge": _handle_load_knowledge,
|
|
942
1403
|
"delegate_semgrep": _handle_delegate_semgrep,
|
|
943
1404
|
"delegate_ruff": _handle_delegate_ruff,
|
|
944
1405
|
"delegate_slither": _handle_delegate_slither,
|
|
945
1406
|
"delegate_bandit": _handle_delegate_bandit,
|
|
946
|
-
"quick_review": _handle_quick_review,
|
|
947
1407
|
"check_tools": _handle_check_tools,
|
|
948
|
-
"review_changes": _handle_review_changes,
|
|
949
|
-
"full_review": _handle_full_review,
|
|
950
1408
|
}
|
|
951
1409
|
|
|
952
1410
|
handler = handlers.get(name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crucible-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
|
|
5
5
|
Author: be.nvy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -73,20 +73,33 @@ Code → Detect Domain → Load Personas + Knowledge → Claude with YOUR patter
|
|
|
73
73
|
|
|
74
74
|
| Tool | Purpose |
|
|
75
75
|
|------|---------|
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
76
|
+
| `review(path)` | Full review: analysis + skills + knowledge |
|
|
77
|
+
| `review(mode='staged')` | Review git changes with skills + knowledge |
|
|
78
|
+
| `load_knowledge(files)` | Load specific knowledge files |
|
|
79
|
+
| `get_principles(topic)` | Load engineering knowledge by topic |
|
|
78
80
|
| `delegate_*` | Direct tool access (semgrep, ruff, slither, bandit) |
|
|
79
81
|
| `check_tools()` | Show installed analysis tools |
|
|
80
82
|
|
|
83
|
+
The unified `review` tool supports:
|
|
84
|
+
- **Path-based**: `review(path="src/")` - analyze files/directories
|
|
85
|
+
- **Git-aware**: `review(mode="staged")` - analyze changes (staged, unstaged, branch, commits)
|
|
86
|
+
- **Quick mode**: `review(path, include_skills=false)` - analysis only, no skills/knowledge
|
|
87
|
+
|
|
81
88
|
## CLI
|
|
82
89
|
|
|
83
90
|
```bash
|
|
91
|
+
crucible init # Initialize .crucible/ for your project
|
|
92
|
+
crucible review # Review staged changes
|
|
93
|
+
crucible review --mode branch # Review current branch vs main
|
|
94
|
+
crucible ci generate # Generate GitHub Actions workflow
|
|
95
|
+
|
|
84
96
|
crucible skills list # List all skills
|
|
85
|
-
crucible skills show <skill> # Show which version is active
|
|
86
97
|
crucible skills init <skill> # Copy to .crucible/ for customization
|
|
87
98
|
|
|
88
99
|
crucible knowledge list # List all knowledge files
|
|
89
100
|
crucible knowledge init <file> # Copy for customization
|
|
101
|
+
|
|
102
|
+
crucible hooks install # Install pre-commit hook
|
|
90
103
|
```
|
|
91
104
|
|
|
92
105
|
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full flow.
|
|
@@ -135,6 +148,6 @@ See [KNOWLEDGE.md](docs/KNOWLEDGE.md) for topics covered.
|
|
|
135
148
|
|
|
136
149
|
```bash
|
|
137
150
|
pip install -e ".[dev]"
|
|
138
|
-
pytest # Run tests (
|
|
151
|
+
pytest # Run tests (509 tests)
|
|
139
152
|
ruff check src/ --fix # Lint
|
|
140
153
|
```
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for
|
|
1
|
+
"""Tests for unified review MCP tool and deprecated full_review."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from unittest.mock import patch
|
|
@@ -7,7 +7,12 @@ import pytest
|
|
|
7
7
|
|
|
8
8
|
from crucible.errors import ok
|
|
9
9
|
from crucible.models import Domain, FullReviewResult, Severity, ToolFinding
|
|
10
|
-
from crucible.server import
|
|
10
|
+
from crucible.server import (
|
|
11
|
+
_detect_domain_for_file,
|
|
12
|
+
_handle_full_review,
|
|
13
|
+
_handle_load_knowledge,
|
|
14
|
+
_handle_review,
|
|
15
|
+
)
|
|
11
16
|
|
|
12
17
|
|
|
13
18
|
class TestFullReviewResult:
|
|
@@ -349,3 +354,131 @@ class TestFullReviewIncludesCustomKnowledge:
|
|
|
349
354
|
result = _handle_full_review({"path": str(test_file)})
|
|
350
355
|
text = result[0].text
|
|
351
356
|
assert "PROJECT_PATTERNS.md" in text
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class TestUnifiedReview:
|
|
360
|
+
"""Test unified _handle_review handler."""
|
|
361
|
+
|
|
362
|
+
def test_path_based_review(self, tmp_path: Path) -> None:
|
|
363
|
+
"""Path-based review should work like full_review."""
|
|
364
|
+
test_file = tmp_path / "test.py"
|
|
365
|
+
test_file.write_text("x = 1\n")
|
|
366
|
+
|
|
367
|
+
with (
|
|
368
|
+
patch("crucible.skills.loader.SKILLS_PROJECT", tmp_path / "nonexistent-project"),
|
|
369
|
+
patch("crucible.skills.loader.SKILLS_USER", tmp_path / "nonexistent-user"),
|
|
370
|
+
patch("crucible.server.delegate_semgrep", return_value=ok([])),
|
|
371
|
+
patch("crucible.server.delegate_ruff", return_value=ok([])),
|
|
372
|
+
patch("crucible.server.delegate_bandit", return_value=ok([])),
|
|
373
|
+
):
|
|
374
|
+
result = _handle_review({"path": str(test_file)})
|
|
375
|
+
text = result[0].text
|
|
376
|
+
assert "Code Review" in text
|
|
377
|
+
assert "python" in text.lower()
|
|
378
|
+
assert "security-engineer" in text
|
|
379
|
+
|
|
380
|
+
def test_path_based_quick_review(self, tmp_path: Path) -> None:
|
|
381
|
+
"""Path-based review with include_skills=false should be quick."""
|
|
382
|
+
test_file = tmp_path / "test.py"
|
|
383
|
+
test_file.write_text("x = 1\n")
|
|
384
|
+
|
|
385
|
+
with (
|
|
386
|
+
patch("crucible.skills.loader.SKILLS_PROJECT", tmp_path / "nonexistent-project"),
|
|
387
|
+
patch("crucible.skills.loader.SKILLS_USER", tmp_path / "nonexistent-user"),
|
|
388
|
+
patch("crucible.server.delegate_semgrep", return_value=ok([])),
|
|
389
|
+
patch("crucible.server.delegate_ruff", return_value=ok([])),
|
|
390
|
+
patch("crucible.server.delegate_bandit", return_value=ok([])),
|
|
391
|
+
):
|
|
392
|
+
result = _handle_review({
|
|
393
|
+
"path": str(test_file),
|
|
394
|
+
"include_skills": False,
|
|
395
|
+
"include_knowledge": False,
|
|
396
|
+
})
|
|
397
|
+
text = result[0].text
|
|
398
|
+
assert "Code Review" in text
|
|
399
|
+
# Should NOT have skills section
|
|
400
|
+
assert "Applicable Skills" not in text
|
|
401
|
+
# Should NOT have knowledge section
|
|
402
|
+
assert "Knowledge Loaded" not in text
|
|
403
|
+
|
|
404
|
+
def test_requires_path_or_mode(self) -> None:
|
|
405
|
+
"""Should error if neither path nor mode provided."""
|
|
406
|
+
result = _handle_review({})
|
|
407
|
+
text = result[0].text
|
|
408
|
+
assert "Error" in text
|
|
409
|
+
assert "path" in text.lower() or "mode" in text.lower()
|
|
410
|
+
|
|
411
|
+
def test_skill_override(self, tmp_path: Path) -> None:
|
|
412
|
+
"""Should respect skill override."""
|
|
413
|
+
test_file = tmp_path / "test.py"
|
|
414
|
+
test_file.write_text("x = 1\n")
|
|
415
|
+
|
|
416
|
+
with (
|
|
417
|
+
patch("crucible.skills.loader.SKILLS_PROJECT", tmp_path / "nonexistent-project"),
|
|
418
|
+
patch("crucible.skills.loader.SKILLS_USER", tmp_path / "nonexistent-user"),
|
|
419
|
+
patch("crucible.server.delegate_semgrep", return_value=ok([])),
|
|
420
|
+
patch("crucible.server.delegate_ruff", return_value=ok([])),
|
|
421
|
+
patch("crucible.server.delegate_bandit", return_value=ok([])),
|
|
422
|
+
):
|
|
423
|
+
result = _handle_review({
|
|
424
|
+
"path": str(test_file),
|
|
425
|
+
"skills": ["web3-engineer"],
|
|
426
|
+
})
|
|
427
|
+
text = result[0].text
|
|
428
|
+
assert "web3-engineer" in text
|
|
429
|
+
|
|
430
|
+
def test_findings_included(self, tmp_path: Path) -> None:
|
|
431
|
+
"""Should include static analysis findings."""
|
|
432
|
+
test_file = tmp_path / "test.py"
|
|
433
|
+
test_file.write_text("x = 1\n")
|
|
434
|
+
|
|
435
|
+
mock_findings = [
|
|
436
|
+
ToolFinding(
|
|
437
|
+
tool="ruff",
|
|
438
|
+
rule="E501",
|
|
439
|
+
severity=Severity.LOW,
|
|
440
|
+
message="Line too long",
|
|
441
|
+
location="test.py:1",
|
|
442
|
+
),
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
with (
|
|
446
|
+
patch("crucible.skills.loader.SKILLS_PROJECT", tmp_path / "nonexistent-project"),
|
|
447
|
+
patch("crucible.skills.loader.SKILLS_USER", tmp_path / "nonexistent-user"),
|
|
448
|
+
patch("crucible.server.delegate_semgrep", return_value=ok([])),
|
|
449
|
+
patch("crucible.server.delegate_ruff", return_value=ok(mock_findings)),
|
|
450
|
+
patch("crucible.server.delegate_bandit", return_value=ok([])),
|
|
451
|
+
):
|
|
452
|
+
result = _handle_review({"path": str(test_file)})
|
|
453
|
+
text = result[0].text
|
|
454
|
+
assert "E501" in text
|
|
455
|
+
assert "Line too long" in text
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class TestUnifiedReviewGitMode:
|
|
459
|
+
"""Test unified review in git mode."""
|
|
460
|
+
|
|
461
|
+
def test_staged_mode_no_changes(self, tmp_path: Path) -> None:
|
|
462
|
+
"""Should handle no staged changes gracefully."""
|
|
463
|
+
from crucible.errors import ok as ok_result
|
|
464
|
+
from crucible.tools.git import GitContext
|
|
465
|
+
|
|
466
|
+
with (
|
|
467
|
+
patch("crucible.server.get_repo_root", return_value=ok_result(str(tmp_path))),
|
|
468
|
+
patch("crucible.server.get_staged_changes", return_value=ok_result(
|
|
469
|
+
GitContext(mode="staged", base_ref=None, changes=[], commit_messages=[])
|
|
470
|
+
)),
|
|
471
|
+
):
|
|
472
|
+
result = _handle_review({"mode": "staged"})
|
|
473
|
+
text = result[0].text
|
|
474
|
+
assert "No changes" in text or "Stage files" in text
|
|
475
|
+
|
|
476
|
+
def test_invalid_mode(self) -> None:
|
|
477
|
+
"""Should error on invalid mode."""
|
|
478
|
+
from crucible.errors import ok as ok_result
|
|
479
|
+
|
|
480
|
+
with patch("crucible.server.get_repo_root", return_value=ok_result("/tmp")):
|
|
481
|
+
result = _handle_review({"mode": "invalid"})
|
|
482
|
+
text = result[0].text
|
|
483
|
+
assert "Error" in text
|
|
484
|
+
assert "invalid" in text.lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|