crucible-mcp 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
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/__init__.py +1 -1
- crucible/cli.py +347 -160
- crucible/enforcement/__init__.py +40 -0
- crucible/enforcement/assertions.py +276 -0
- crucible/enforcement/models.py +107 -0
- crucible/enforcement/patterns.py +337 -0
- crucible/review/__init__.py +23 -0
- crucible/review/core.py +383 -0
- crucible/server.py +104 -327
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/METADATA +10 -3
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/RECORD +14 -8
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/top_level.txt +0 -0
crucible/server.py
CHANGED
|
@@ -13,6 +13,15 @@ from crucible.knowledge.loader import (
|
|
|
13
13
|
load_principles,
|
|
14
14
|
)
|
|
15
15
|
from crucible.models import Domain, FullReviewResult, Severity, ToolFinding
|
|
16
|
+
from crucible.review.core import (
|
|
17
|
+
compute_severity_counts,
|
|
18
|
+
deduplicate_findings,
|
|
19
|
+
detect_domain,
|
|
20
|
+
filter_findings_to_changes,
|
|
21
|
+
load_skills_and_knowledge,
|
|
22
|
+
run_enforcement,
|
|
23
|
+
run_static_analysis,
|
|
24
|
+
)
|
|
16
25
|
from crucible.skills import get_knowledge_for_skills, load_skill, match_skills_for_domain
|
|
17
26
|
from crucible.tools.delegation import (
|
|
18
27
|
check_all_tools,
|
|
@@ -61,38 +70,6 @@ def _format_findings(findings: list[ToolFinding]) -> str:
|
|
|
61
70
|
return "\n".join(parts) if parts else "No findings."
|
|
62
71
|
|
|
63
72
|
|
|
64
|
-
def _deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
|
|
65
|
-
"""Deduplicate findings by location and message.
|
|
66
|
-
|
|
67
|
-
When multiple tools report the same issue at the same location,
|
|
68
|
-
keep only the highest severity finding.
|
|
69
|
-
"""
|
|
70
|
-
# Group by (location, normalized_message)
|
|
71
|
-
seen: dict[tuple[str, str], ToolFinding] = {}
|
|
72
|
-
|
|
73
|
-
for f in findings:
|
|
74
|
-
# Normalize the message for comparison (lowercase, strip whitespace)
|
|
75
|
-
norm_msg = f.message.lower().strip()
|
|
76
|
-
key = (f.location, norm_msg)
|
|
77
|
-
|
|
78
|
-
if key not in seen:
|
|
79
|
-
seen[key] = f
|
|
80
|
-
else:
|
|
81
|
-
# Keep the higher severity finding
|
|
82
|
-
existing = seen[key]
|
|
83
|
-
severity_order = [
|
|
84
|
-
Severity.CRITICAL,
|
|
85
|
-
Severity.HIGH,
|
|
86
|
-
Severity.MEDIUM,
|
|
87
|
-
Severity.LOW,
|
|
88
|
-
Severity.INFO,
|
|
89
|
-
]
|
|
90
|
-
if severity_order.index(f.severity) < severity_order.index(existing.severity):
|
|
91
|
-
seen[key] = f
|
|
92
|
-
|
|
93
|
-
return list(seen.values())
|
|
94
|
-
|
|
95
|
-
|
|
96
73
|
@server.list_tools() # type: ignore[misc]
|
|
97
74
|
async def list_tools() -> list[Tool]:
|
|
98
75
|
"""List available tools."""
|
|
@@ -135,6 +112,11 @@ async def list_tools() -> list[Tool]:
|
|
|
135
112
|
"description": "Load knowledge files (default: true). Set false for quick analysis only.",
|
|
136
113
|
"default": True,
|
|
137
114
|
},
|
|
115
|
+
"enforce": {
|
|
116
|
+
"type": "boolean",
|
|
117
|
+
"description": "Run pattern assertions from .crucible/assertions/ (default: true).",
|
|
118
|
+
"default": True,
|
|
119
|
+
},
|
|
138
120
|
},
|
|
139
121
|
},
|
|
140
122
|
),
|
|
@@ -321,105 +303,6 @@ async def list_tools() -> list[Tool]:
|
|
|
321
303
|
]
|
|
322
304
|
|
|
323
305
|
|
|
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
306
|
def _format_review_output(
|
|
424
307
|
path: str | None,
|
|
425
308
|
git_context: GitContext | None,
|
|
@@ -431,6 +314,10 @@ def _format_review_output(
|
|
|
431
314
|
skill_content: dict[str, str] | None,
|
|
432
315
|
knowledge_files: set[str] | None,
|
|
433
316
|
knowledge_content: dict[str, str] | None,
|
|
317
|
+
enforcement_findings: list | None = None,
|
|
318
|
+
enforcement_errors: list[str] | None = None,
|
|
319
|
+
assertions_checked: int = 0,
|
|
320
|
+
assertions_skipped: int = 0,
|
|
434
321
|
) -> str:
|
|
435
322
|
"""Format unified review output."""
|
|
436
323
|
parts: list[str] = ["# Code Review\n"]
|
|
@@ -500,6 +387,46 @@ def _format_review_output(
|
|
|
500
387
|
parts.append("No issues found.")
|
|
501
388
|
parts.append("")
|
|
502
389
|
|
|
390
|
+
# Enforcement assertions
|
|
391
|
+
if enforcement_findings is not None:
|
|
392
|
+
active = [f for f in enforcement_findings if not f.suppressed]
|
|
393
|
+
suppressed = [f for f in enforcement_findings if f.suppressed]
|
|
394
|
+
|
|
395
|
+
parts.append("## Pattern Assertions\n")
|
|
396
|
+
if assertions_checked > 0 or assertions_skipped > 0:
|
|
397
|
+
parts.append(f"*Checked: {assertions_checked}, Skipped (LLM): {assertions_skipped}*\n")
|
|
398
|
+
|
|
399
|
+
if enforcement_errors:
|
|
400
|
+
parts.append("**Errors:**")
|
|
401
|
+
for err in enforcement_errors:
|
|
402
|
+
parts.append(f"- {err}")
|
|
403
|
+
parts.append("")
|
|
404
|
+
|
|
405
|
+
if active:
|
|
406
|
+
# Group by severity
|
|
407
|
+
by_sev: dict[str, list] = {}
|
|
408
|
+
for f in active:
|
|
409
|
+
by_sev.setdefault(f.severity.upper(), []).append(f)
|
|
410
|
+
|
|
411
|
+
for sev in ["ERROR", "WARNING", "INFO"]:
|
|
412
|
+
if sev in by_sev:
|
|
413
|
+
parts.append(f"### {sev} ({len(by_sev[sev])})\n")
|
|
414
|
+
for f in by_sev[sev]:
|
|
415
|
+
parts.append(f"- **[{f.assertion_id}]** {f.message}")
|
|
416
|
+
parts.append(f" - Location: `{f.location}`")
|
|
417
|
+
if f.match_text:
|
|
418
|
+
parts.append(f" - Match: `{f.match_text}`")
|
|
419
|
+
else:
|
|
420
|
+
parts.append("No pattern violations found.")
|
|
421
|
+
|
|
422
|
+
if suppressed:
|
|
423
|
+
parts.append(f"\n*Suppressed: {len(suppressed)}*")
|
|
424
|
+
for f in suppressed:
|
|
425
|
+
reason = f" ({f.suppression_reason})" if f.suppression_reason else ""
|
|
426
|
+
parts.append(f"- {f.assertion_id}: {f.location}{reason}")
|
|
427
|
+
|
|
428
|
+
parts.append("")
|
|
429
|
+
|
|
503
430
|
# Review checklists from skills
|
|
504
431
|
if skill_content:
|
|
505
432
|
parts.append("---\n")
|
|
@@ -532,6 +459,7 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
532
459
|
skills_override = arguments.get("skills")
|
|
533
460
|
include_skills = arguments.get("include_skills", True)
|
|
534
461
|
include_knowledge = arguments.get("include_knowledge", True)
|
|
462
|
+
enforce = arguments.get("enforce", True)
|
|
535
463
|
|
|
536
464
|
# Determine if this is path-based or git-based review
|
|
537
465
|
git_context: GitContext | None = None
|
|
@@ -593,34 +521,48 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
593
521
|
repo_path = get_repo_root(path if path else os.getcwd()).value
|
|
594
522
|
for file_path in changed_files:
|
|
595
523
|
full_path = f"{repo_path}/{file_path}"
|
|
596
|
-
domain, domain_tags =
|
|
524
|
+
domain, domain_tags = detect_domain(file_path)
|
|
597
525
|
domains_detected.add(domain)
|
|
598
526
|
all_domain_tags.update(domain_tags)
|
|
599
527
|
|
|
600
|
-
findings, errors =
|
|
528
|
+
findings, errors = run_static_analysis(full_path, domain, domain_tags)
|
|
601
529
|
all_findings.extend(findings)
|
|
602
530
|
tool_errors.extend([f"{e} ({file_path})" for e in errors])
|
|
603
531
|
|
|
604
532
|
# Filter findings to changed lines
|
|
605
|
-
all_findings =
|
|
533
|
+
all_findings = filter_findings_to_changes(all_findings, git_context, include_context)
|
|
606
534
|
else:
|
|
607
535
|
# Path mode: analyze the path directly
|
|
608
|
-
domain, domain_tags =
|
|
536
|
+
domain, domain_tags = detect_domain(path)
|
|
609
537
|
domains_detected.add(domain)
|
|
610
538
|
all_domain_tags.update(domain_tags)
|
|
611
539
|
|
|
612
|
-
findings, errors =
|
|
540
|
+
findings, errors = run_static_analysis(path, domain, domain_tags)
|
|
613
541
|
all_findings.extend(findings)
|
|
614
542
|
tool_errors.extend(errors)
|
|
615
543
|
|
|
616
544
|
# Deduplicate findings
|
|
617
|
-
all_findings =
|
|
545
|
+
all_findings = deduplicate_findings(all_findings)
|
|
546
|
+
|
|
547
|
+
# Run pattern assertions
|
|
548
|
+
enforcement_findings = []
|
|
549
|
+
enforcement_errors: list[str] = []
|
|
550
|
+
assertions_checked = 0
|
|
551
|
+
assertions_skipped = 0
|
|
552
|
+
|
|
553
|
+
if enforce:
|
|
554
|
+
if git_context:
|
|
555
|
+
repo_path = get_repo_root(path if path else os.getcwd()).value
|
|
556
|
+
enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped = (
|
|
557
|
+
run_enforcement(path or "", changed_files=changed_files, repo_root=repo_path)
|
|
558
|
+
)
|
|
559
|
+
elif path:
|
|
560
|
+
enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped = (
|
|
561
|
+
run_enforcement(path)
|
|
562
|
+
)
|
|
618
563
|
|
|
619
564
|
# Compute severity summary
|
|
620
|
-
severity_counts
|
|
621
|
-
for f in all_findings:
|
|
622
|
-
sev = f.severity.value
|
|
623
|
-
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
565
|
+
severity_counts = compute_severity_counts(all_findings)
|
|
624
566
|
|
|
625
567
|
# Load skills and knowledge
|
|
626
568
|
matched_skills: list[tuple[str, list[str]]] | None = None
|
|
@@ -630,7 +572,7 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
630
572
|
|
|
631
573
|
if include_skills or include_knowledge:
|
|
632
574
|
primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
|
|
633
|
-
matched, s_content, k_files, k_content =
|
|
575
|
+
matched, s_content, k_files, k_content = load_skills_and_knowledge(
|
|
634
576
|
primary_domain, list(all_domain_tags), skills_override
|
|
635
577
|
)
|
|
636
578
|
if include_skills:
|
|
@@ -652,6 +594,10 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
652
594
|
skill_content,
|
|
653
595
|
knowledge_files,
|
|
654
596
|
knowledge_content,
|
|
597
|
+
enforcement_findings if enforce else None,
|
|
598
|
+
enforcement_errors if enforce else None,
|
|
599
|
+
assertions_checked,
|
|
600
|
+
assertions_skipped,
|
|
655
601
|
)
|
|
656
602
|
|
|
657
603
|
return [TextContent(type="text", text=output)]
|
|
@@ -773,89 +719,13 @@ def _handle_check_tools(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
773
719
|
return [TextContent(type="text", text="\n".join(parts))]
|
|
774
720
|
|
|
775
721
|
|
|
776
|
-
def _detect_domain_for_file(path: str) -> tuple[Domain, list[str]]:
|
|
777
|
-
"""Detect domain from a single file path.
|
|
778
|
-
|
|
779
|
-
Returns (domain, list of domain tags for skill matching).
|
|
780
|
-
"""
|
|
781
|
-
if path.endswith(".sol"):
|
|
782
|
-
return Domain.SMART_CONTRACT, ["solidity", "smart_contract", "web3"]
|
|
783
|
-
elif path.endswith(".vy"):
|
|
784
|
-
return Domain.SMART_CONTRACT, ["vyper", "smart_contract", "web3"]
|
|
785
|
-
elif path.endswith(".py"):
|
|
786
|
-
return Domain.BACKEND, ["python", "backend"]
|
|
787
|
-
elif path.endswith((".ts", ".tsx")):
|
|
788
|
-
return Domain.FRONTEND, ["typescript", "frontend"]
|
|
789
|
-
elif path.endswith((".js", ".jsx")):
|
|
790
|
-
return Domain.FRONTEND, ["javascript", "frontend"]
|
|
791
|
-
elif path.endswith(".go"):
|
|
792
|
-
return Domain.BACKEND, ["go", "backend"]
|
|
793
|
-
elif path.endswith(".rs"):
|
|
794
|
-
return Domain.BACKEND, ["rust", "backend"]
|
|
795
|
-
elif path.endswith((".tf", ".yaml", ".yml")):
|
|
796
|
-
return Domain.INFRASTRUCTURE, ["infrastructure", "devops"]
|
|
797
|
-
else:
|
|
798
|
-
return Domain.UNKNOWN, []
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
def _detect_domain(path: str) -> tuple[Domain, list[str]]:
|
|
802
|
-
"""Detect domain from file or directory path.
|
|
803
|
-
|
|
804
|
-
For directories, scans contained files and aggregates domains.
|
|
805
|
-
Returns (primary_domain, list of all domain tags).
|
|
806
|
-
"""
|
|
807
|
-
from collections import Counter
|
|
808
|
-
from pathlib import Path
|
|
809
|
-
|
|
810
|
-
p = Path(path)
|
|
811
|
-
|
|
812
|
-
# Single file - use direct detection
|
|
813
|
-
if p.is_file():
|
|
814
|
-
return _detect_domain_for_file(path)
|
|
815
|
-
|
|
816
|
-
# Directory - scan and aggregate
|
|
817
|
-
if not p.is_dir():
|
|
818
|
-
return Domain.UNKNOWN, ["unknown"]
|
|
819
|
-
|
|
820
|
-
domain_counts: Counter[Domain] = Counter()
|
|
821
|
-
all_tags: set[str] = set()
|
|
822
|
-
|
|
823
|
-
# Scan files in directory (up to 1000 to avoid huge repos)
|
|
824
|
-
file_count = 0
|
|
825
|
-
max_files = 1000
|
|
826
|
-
|
|
827
|
-
for file_path in p.rglob("*"):
|
|
828
|
-
if file_count >= max_files:
|
|
829
|
-
break
|
|
830
|
-
if not file_path.is_file():
|
|
831
|
-
continue
|
|
832
|
-
# Skip hidden files and common non-code directories
|
|
833
|
-
if any(part.startswith(".") for part in file_path.parts):
|
|
834
|
-
continue
|
|
835
|
-
if any(part in ("node_modules", "__pycache__", "venv", ".venv", "dist", "build") for part in file_path.parts):
|
|
836
|
-
continue
|
|
837
|
-
|
|
838
|
-
domain, tags = _detect_domain_for_file(str(file_path))
|
|
839
|
-
if domain != Domain.UNKNOWN:
|
|
840
|
-
domain_counts[domain] += 1
|
|
841
|
-
all_tags.update(tags)
|
|
842
|
-
file_count += 1
|
|
843
|
-
|
|
844
|
-
# Return most common domain, or UNKNOWN if none found
|
|
845
|
-
if not domain_counts:
|
|
846
|
-
return Domain.UNKNOWN, ["unknown"]
|
|
847
|
-
|
|
848
|
-
primary_domain = domain_counts.most_common(1)[0][0]
|
|
849
|
-
return primary_domain, sorted(all_tags) if all_tags else ["unknown"]
|
|
850
|
-
|
|
851
|
-
|
|
852
722
|
def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
853
723
|
"""Handle quick_review tool - returns findings with domain metadata."""
|
|
854
724
|
path = arguments.get("path", "")
|
|
855
725
|
tools = arguments.get("tools")
|
|
856
726
|
|
|
857
727
|
# Internal domain detection
|
|
858
|
-
domain, domain_tags =
|
|
728
|
+
domain, domain_tags = detect_domain(path)
|
|
859
729
|
|
|
860
730
|
# Select tools based on domain
|
|
861
731
|
if domain == Domain.SMART_CONTRACT:
|
|
@@ -908,7 +778,7 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
908
778
|
tool_results.append(f"## Bandit\nError: {result.error}")
|
|
909
779
|
|
|
910
780
|
# Deduplicate findings
|
|
911
|
-
all_findings =
|
|
781
|
+
all_findings = deduplicate_findings(all_findings)
|
|
912
782
|
|
|
913
783
|
# Compute severity summary
|
|
914
784
|
severity_counts: dict[str, int] = {}
|
|
@@ -927,60 +797,6 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
927
797
|
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
928
798
|
|
|
929
799
|
|
|
930
|
-
def _filter_findings_to_changes(
|
|
931
|
-
findings: list[ToolFinding],
|
|
932
|
-
context: GitContext,
|
|
933
|
-
include_context: bool = False,
|
|
934
|
-
) -> list[ToolFinding]:
|
|
935
|
-
"""Filter findings to only those in changed lines."""
|
|
936
|
-
# Build a lookup of file -> changed line ranges
|
|
937
|
-
changed_ranges: dict[str, list[tuple[int, int]]] = {}
|
|
938
|
-
for change in context.changes:
|
|
939
|
-
if change.status == "D":
|
|
940
|
-
continue # Skip deleted files
|
|
941
|
-
ranges = [(r.start, r.end) for r in change.added_lines]
|
|
942
|
-
changed_ranges[change.path] = ranges
|
|
943
|
-
|
|
944
|
-
context_lines = 5 if include_context else 0
|
|
945
|
-
filtered: list[ToolFinding] = []
|
|
946
|
-
|
|
947
|
-
for finding in findings:
|
|
948
|
-
# Parse location: "path:line" or "path:line:col"
|
|
949
|
-
parts = finding.location.split(":")
|
|
950
|
-
if len(parts) < 2:
|
|
951
|
-
continue
|
|
952
|
-
|
|
953
|
-
file_path = parts[0]
|
|
954
|
-
try:
|
|
955
|
-
line_num = int(parts[1])
|
|
956
|
-
except ValueError:
|
|
957
|
-
continue
|
|
958
|
-
|
|
959
|
-
# Check if file is in changes
|
|
960
|
-
# Handle both absolute and relative paths
|
|
961
|
-
matching_file = None
|
|
962
|
-
for changed_file in changed_ranges:
|
|
963
|
-
if file_path.endswith(changed_file) or changed_file.endswith(file_path):
|
|
964
|
-
matching_file = changed_file
|
|
965
|
-
break
|
|
966
|
-
|
|
967
|
-
if not matching_file:
|
|
968
|
-
continue
|
|
969
|
-
|
|
970
|
-
# Check if line is in changed ranges
|
|
971
|
-
ranges = changed_ranges[matching_file]
|
|
972
|
-
in_range = False
|
|
973
|
-
for start, end in ranges:
|
|
974
|
-
if start - context_lines <= line_num <= end + context_lines:
|
|
975
|
-
in_range = True
|
|
976
|
-
break
|
|
977
|
-
|
|
978
|
-
if in_range:
|
|
979
|
-
filtered.append(finding)
|
|
980
|
-
|
|
981
|
-
return filtered
|
|
982
|
-
|
|
983
|
-
|
|
984
800
|
def _format_change_review(
|
|
985
801
|
context: GitContext,
|
|
986
802
|
findings: list[ToolFinding],
|
|
@@ -1136,7 +952,7 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1136
952
|
full_path = f"{repo_path}/{file_path}"
|
|
1137
953
|
|
|
1138
954
|
# Detect domain for this file
|
|
1139
|
-
domain, domain_tags =
|
|
955
|
+
domain, domain_tags = detect_domain(file_path)
|
|
1140
956
|
domains_detected.add(domain)
|
|
1141
957
|
all_domain_tags.update(domain_tags)
|
|
1142
958
|
|
|
@@ -1181,10 +997,10 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1181
997
|
tool_errors.append(f"bandit ({file_path}): {result.error}")
|
|
1182
998
|
|
|
1183
999
|
# Filter findings to changed lines
|
|
1184
|
-
filtered_findings =
|
|
1000
|
+
filtered_findings = filter_findings_to_changes(all_findings, context, include_context)
|
|
1185
1001
|
|
|
1186
1002
|
# Deduplicate findings
|
|
1187
|
-
filtered_findings =
|
|
1003
|
+
filtered_findings = deduplicate_findings(filtered_findings)
|
|
1188
1004
|
|
|
1189
1005
|
# Compute severity summary
|
|
1190
1006
|
severity_counts: dict[str, int] = {}
|
|
@@ -1235,56 +1051,20 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1235
1051
|
|
|
1236
1052
|
|
|
1237
1053
|
def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
1238
|
-
"""Handle full_review tool - comprehensive code review.
|
|
1054
|
+
"""Handle full_review tool - comprehensive code review.
|
|
1055
|
+
|
|
1056
|
+
DEPRECATED: Use _handle_review with path parameter instead.
|
|
1057
|
+
"""
|
|
1058
|
+
from crucible.review.core import run_static_analysis
|
|
1059
|
+
|
|
1239
1060
|
path = arguments.get("path", "")
|
|
1240
1061
|
skills_override = arguments.get("skills")
|
|
1241
|
-
# include_sage is accepted but not yet implemented
|
|
1242
|
-
# _ = arguments.get("include_sage", True)
|
|
1243
1062
|
|
|
1244
1063
|
# 1. Detect domain
|
|
1245
|
-
domain, domain_tags =
|
|
1246
|
-
|
|
1247
|
-
# 2. Run static analysis (reuse quick_review logic)
|
|
1248
|
-
if domain == Domain.SMART_CONTRACT:
|
|
1249
|
-
default_tools = ["slither", "semgrep"]
|
|
1250
|
-
elif domain == Domain.BACKEND and "python" in domain_tags:
|
|
1251
|
-
default_tools = ["ruff", "bandit", "semgrep"]
|
|
1252
|
-
elif domain == Domain.FRONTEND:
|
|
1253
|
-
default_tools = ["semgrep"]
|
|
1254
|
-
else:
|
|
1255
|
-
default_tools = ["semgrep"]
|
|
1256
|
-
|
|
1257
|
-
all_findings: list[ToolFinding] = []
|
|
1258
|
-
tool_errors: list[str] = []
|
|
1064
|
+
domain, domain_tags = detect_domain(path)
|
|
1259
1065
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
result = delegate_semgrep(path, config)
|
|
1263
|
-
if result.is_ok:
|
|
1264
|
-
all_findings.extend(result.value)
|
|
1265
|
-
elif result.is_err:
|
|
1266
|
-
tool_errors.append(f"semgrep: {result.error}")
|
|
1267
|
-
|
|
1268
|
-
if "ruff" in default_tools:
|
|
1269
|
-
result = delegate_ruff(path)
|
|
1270
|
-
if result.is_ok:
|
|
1271
|
-
all_findings.extend(result.value)
|
|
1272
|
-
elif result.is_err:
|
|
1273
|
-
tool_errors.append(f"ruff: {result.error}")
|
|
1274
|
-
|
|
1275
|
-
if "slither" in default_tools:
|
|
1276
|
-
result = delegate_slither(path)
|
|
1277
|
-
if result.is_ok:
|
|
1278
|
-
all_findings.extend(result.value)
|
|
1279
|
-
elif result.is_err:
|
|
1280
|
-
tool_errors.append(f"slither: {result.error}")
|
|
1281
|
-
|
|
1282
|
-
if "bandit" in default_tools:
|
|
1283
|
-
result = delegate_bandit(path)
|
|
1284
|
-
if result.is_ok:
|
|
1285
|
-
all_findings.extend(result.value)
|
|
1286
|
-
elif result.is_err:
|
|
1287
|
-
tool_errors.append(f"bandit: {result.error}")
|
|
1066
|
+
# 2. Run static analysis using shared core function
|
|
1067
|
+
all_findings, tool_errors = run_static_analysis(path, domain, domain_tags)
|
|
1288
1068
|
|
|
1289
1069
|
# 3. Match applicable skills
|
|
1290
1070
|
matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
|
|
@@ -1318,13 +1098,10 @@ def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1318
1098
|
)
|
|
1319
1099
|
|
|
1320
1100
|
# 7. Deduplicate findings
|
|
1321
|
-
all_findings =
|
|
1101
|
+
all_findings = deduplicate_findings(all_findings)
|
|
1322
1102
|
|
|
1323
1103
|
# 8. Compute severity summary
|
|
1324
|
-
severity_counts
|
|
1325
|
-
for f in all_findings:
|
|
1326
|
-
sev = f.severity.value
|
|
1327
|
-
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
1104
|
+
severity_counts = compute_severity_counts(all_findings)
|
|
1328
1105
|
|
|
1329
1106
|
# 8. Build result
|
|
1330
1107
|
review_result = FullReviewResult(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crucible-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
|
|
5
5
|
Author: be.nvy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,7 +17,9 @@ Requires-Dist: ruff>=0.3; extra == "dev"
|
|
|
17
17
|
|
|
18
18
|
# Crucible
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
**Your team's standards, applied by Claude, every time.**
|
|
21
|
+
|
|
22
|
+
Claude without context applies generic best practices. Crucible loads *your* patterns—so Claude reviews code the way your team would, not the way the internet would.
|
|
21
23
|
|
|
22
24
|
```
|
|
23
25
|
├── Personas: Domain-specific thinking (how to approach problems)
|
|
@@ -26,7 +28,12 @@ Load your coding patterns into Claude Code.
|
|
|
26
28
|
└── Context-aware: Loads relevant skills based on what you're working on
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
**
|
|
31
|
+
**Why Crucible?**
|
|
32
|
+
- **Consistency** — Same checklist every time, for every engineer, every session
|
|
33
|
+
- **Automation** — Runs in CI and pre-commit, not just interactively
|
|
34
|
+
- **Institutional knowledge** — Your senior engineer's mental checklist, in the repo
|
|
35
|
+
- **Your context** — Security fundamentals plus *your* auth patterns, *your* conventions, *your* definition of "done"
|
|
36
|
+
- **Cost efficiency** — Filter with free tools first, LLM only on what needs judgment
|
|
30
37
|
|
|
31
38
|
> Not affiliated with Atlassian's Crucible.
|
|
32
39
|
|
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
crucible/__init__.py,sha256=
|
|
2
|
-
crucible/cli.py,sha256=
|
|
1
|
+
crucible/__init__.py,sha256=M4v_CsJVOdiAAPgmd54mxkkbnes8e5ifMznDuOJhzzY,77
|
|
2
|
+
crucible/cli.py,sha256=8rmzsh92h1kFSCFBGfd50pKWTdd7r2c3W_klk9c5JnY,60527
|
|
3
3
|
crucible/errors.py,sha256=HrX_yvJEhXJoKodXGo_iY9wqx2J3ONYy0a_LbrVC5As,819
|
|
4
4
|
crucible/models.py,sha256=jaxbiPc1E7bJxKPLadZe1dbSJdq-WINsxjveeSNNqeg,2066
|
|
5
|
-
crucible/server.py,sha256=
|
|
5
|
+
crucible/server.py,sha256=rzSi1sfuu1o5CpjMUgxFjJB6zjra9-M9WFmmelmORWA,43817
|
|
6
6
|
crucible/domain/__init__.py,sha256=2fsoB5wH2Pl3vtGRt4voYOSZ04-zLoW8pNq6nvzVMgU,118
|
|
7
7
|
crucible/domain/detection.py,sha256=TNeLB_VQgS1AsT5BKDf_tIpGa47THrFoRXwU4u54VB0,1797
|
|
8
|
+
crucible/enforcement/__init__.py,sha256=FOaGSrE1SWFPxBJ1L5VoDhQDmlJgRXXs_iiI20wHf2Q,867
|
|
9
|
+
crucible/enforcement/assertions.py,sha256=ay5QvJIr_YaqWYbrJNhbouafJOiy4ZhwiX7E9VAY3s4,8166
|
|
10
|
+
crucible/enforcement/models.py,sha256=aIuxjqZfACpabT2lB1J3LKVzkcc1RTdbr7mAuBEAreU,2435
|
|
11
|
+
crucible/enforcement/patterns.py,sha256=hE4Z-JJ9OBruSFPBDxw_aNaSJbyUPD2SWCEwA1KzDmI,9720
|
|
8
12
|
crucible/hooks/__init__.py,sha256=k5oEWhTJKEQi-QWBfTbp1p6HaKg55_wVCBVD5pZzdqw,271
|
|
9
13
|
crucible/hooks/precommit.py,sha256=OAwvjEACopcrTmWmZMO0S8TqZkvFY_392pJBFCHGSaQ,21561
|
|
10
14
|
crucible/knowledge/__init__.py,sha256=unb7kyO1MtB3Zt-TGx_O8LE79KyrGrNHoFFHgUWUvGU,40
|
|
11
15
|
crucible/knowledge/loader.py,sha256=DD4gqU6xkssaWvEkbymMOu6YtHab7YLEk-tU9cPTeaE,6666
|
|
16
|
+
crucible/review/__init__.py,sha256=Ssva6Yaqcc44AqL9OUMjxypu5R1PPkrmLGk6OKtP15w,547
|
|
17
|
+
crucible/review/core.py,sha256=Yl7NFFrUtzwjonovhv6sP2fDoplosAjxLvi5vASx08o,12671
|
|
12
18
|
crucible/skills/__init__.py,sha256=L3heXWF0T3aR9yYLFphs1LNlkxAFSPkPuRFMH-S1taI,495
|
|
13
19
|
crucible/skills/loader.py,sha256=iC0_V1s6CIse5NXyFGtpLbON8xDxYh8xXmHH7hAX5O0,8642
|
|
14
20
|
crucible/synthesis/__init__.py,sha256=CYrkZG4bdAjp8XdOh1smfKscd3YU5lZlaDLGwLE9c-0,46
|
|
15
21
|
crucible/tools/__init__.py,sha256=gFRThTk1E-fHzpe8bB5rtBG6Z6G-ysPzjVEHfKGbEYU,400
|
|
16
22
|
crucible/tools/delegation.py,sha256=_x1y76No3qkmGjjROVvMx1pSKKwU59aRu5R-r07lVFU,12871
|
|
17
23
|
crucible/tools/git.py,sha256=EmxRUt0jSFLa_mm_2Czt5rHdiFC0YK9IpaPDfRwlXVo,10051
|
|
18
|
-
crucible_mcp-0.
|
|
19
|
-
crucible_mcp-0.
|
|
20
|
-
crucible_mcp-0.
|
|
21
|
-
crucible_mcp-0.
|
|
22
|
-
crucible_mcp-0.
|
|
24
|
+
crucible_mcp-0.4.0.dist-info/METADATA,sha256=hmhKr9GR0M9x0qV-edCMibj8lFrgKb4Bgv-Kx-cJoSY,5168
|
|
25
|
+
crucible_mcp-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
26
|
+
crucible_mcp-0.4.0.dist-info/entry_points.txt,sha256=18BZaH1OlFSFYtKuHq0Z8yYX8Wmx7Ikfqay-P00ZX3Q,83
|
|
27
|
+
crucible_mcp-0.4.0.dist-info/top_level.txt,sha256=4hzuFgqbFPOO-WiU_DYxTm8VYIxTXh7Wlp0gRcWR0Cs,9
|
|
28
|
+
crucible_mcp-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|