crucible-mcp 0.3.0__py3-none-any.whl → 0.5.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 +772 -172
- crucible/enforcement/__init__.py +40 -0
- crucible/enforcement/assertions.py +276 -0
- crucible/enforcement/budget.py +179 -0
- crucible/enforcement/compliance.py +486 -0
- crucible/enforcement/models.py +177 -0
- crucible/enforcement/patterns.py +337 -0
- crucible/review/__init__.py +23 -0
- crucible/review/core.py +454 -0
- crucible/server.py +171 -327
- crucible/tools/git.py +17 -4
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/METADATA +11 -3
- crucible_mcp-0.5.0.dist-info/RECORD +30 -0
- crucible_mcp-0.3.0.dist-info/RECORD +0 -22
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.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,25 @@ 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
|
+
},
|
|
120
|
+
"compliance_enabled": {
|
|
121
|
+
"type": "boolean",
|
|
122
|
+
"description": "Enable LLM compliance assertions (default: true).",
|
|
123
|
+
"default": True,
|
|
124
|
+
},
|
|
125
|
+
"compliance_model": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"enum": ["sonnet", "opus", "haiku"],
|
|
128
|
+
"description": "Model for LLM compliance assertions (default: sonnet).",
|
|
129
|
+
},
|
|
130
|
+
"token_budget": {
|
|
131
|
+
"type": "integer",
|
|
132
|
+
"description": "Token budget for LLM assertions (0 = unlimited, default: 10000).",
|
|
133
|
+
},
|
|
138
134
|
},
|
|
139
135
|
},
|
|
140
136
|
),
|
|
@@ -321,105 +317,6 @@ async def list_tools() -> list[Tool]:
|
|
|
321
317
|
]
|
|
322
318
|
|
|
323
319
|
|
|
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
320
|
def _format_review_output(
|
|
424
321
|
path: str | None,
|
|
425
322
|
git_context: GitContext | None,
|
|
@@ -431,6 +328,11 @@ def _format_review_output(
|
|
|
431
328
|
skill_content: dict[str, str] | None,
|
|
432
329
|
knowledge_files: set[str] | None,
|
|
433
330
|
knowledge_content: dict[str, str] | None,
|
|
331
|
+
enforcement_findings: list | None = None,
|
|
332
|
+
enforcement_errors: list[str] | None = None,
|
|
333
|
+
assertions_checked: int = 0,
|
|
334
|
+
assertions_skipped: int = 0,
|
|
335
|
+
budget_state: Any = None,
|
|
434
336
|
) -> str:
|
|
435
337
|
"""Format unified review output."""
|
|
436
338
|
parts: list[str] = ["# Code Review\n"]
|
|
@@ -500,6 +402,77 @@ def _format_review_output(
|
|
|
500
402
|
parts.append("No issues found.")
|
|
501
403
|
parts.append("")
|
|
502
404
|
|
|
405
|
+
# Enforcement assertions
|
|
406
|
+
if enforcement_findings is not None:
|
|
407
|
+
active = [f for f in enforcement_findings if not f.suppressed]
|
|
408
|
+
suppressed = [f for f in enforcement_findings if f.suppressed]
|
|
409
|
+
|
|
410
|
+
# Separate pattern vs LLM findings
|
|
411
|
+
pattern_findings = [f for f in active if getattr(f, "source", "pattern") == "pattern"]
|
|
412
|
+
llm_findings = [f for f in active if getattr(f, "source", "pattern") == "llm"]
|
|
413
|
+
|
|
414
|
+
parts.append("## Enforcement Assertions\n")
|
|
415
|
+
|
|
416
|
+
# Summary line
|
|
417
|
+
summary_parts = []
|
|
418
|
+
if assertions_checked > 0:
|
|
419
|
+
summary_parts.append(f"Checked: {assertions_checked}")
|
|
420
|
+
if assertions_skipped > 0:
|
|
421
|
+
summary_parts.append(f"Skipped: {assertions_skipped}")
|
|
422
|
+
if budget_state and budget_state.tokens_used > 0:
|
|
423
|
+
summary_parts.append(f"LLM tokens: {budget_state.tokens_used}")
|
|
424
|
+
if summary_parts:
|
|
425
|
+
parts.append(f"*{', '.join(summary_parts)}*\n")
|
|
426
|
+
|
|
427
|
+
if enforcement_errors:
|
|
428
|
+
parts.append("**Errors:**")
|
|
429
|
+
for err in enforcement_errors:
|
|
430
|
+
parts.append(f"- {err}")
|
|
431
|
+
parts.append("")
|
|
432
|
+
|
|
433
|
+
# Pattern assertions
|
|
434
|
+
if pattern_findings:
|
|
435
|
+
parts.append("### Pattern Assertions\n")
|
|
436
|
+
by_sev: dict[str, list] = {}
|
|
437
|
+
for f in pattern_findings:
|
|
438
|
+
by_sev.setdefault(f.severity.upper(), []).append(f)
|
|
439
|
+
|
|
440
|
+
for sev in ["ERROR", "WARNING", "INFO"]:
|
|
441
|
+
if sev in by_sev:
|
|
442
|
+
parts.append(f"#### {sev} ({len(by_sev[sev])})\n")
|
|
443
|
+
for f in by_sev[sev]:
|
|
444
|
+
parts.append(f"- **[{f.assertion_id}]** {f.message}")
|
|
445
|
+
parts.append(f" - Location: `{f.location}`")
|
|
446
|
+
if f.match_text:
|
|
447
|
+
parts.append(f" - Match: `{f.match_text}`")
|
|
448
|
+
|
|
449
|
+
# LLM compliance assertions
|
|
450
|
+
if llm_findings:
|
|
451
|
+
parts.append("### LLM Compliance Assertions\n")
|
|
452
|
+
by_sev_llm: dict[str, list] = {}
|
|
453
|
+
for f in llm_findings:
|
|
454
|
+
by_sev_llm.setdefault(f.severity.upper(), []).append(f)
|
|
455
|
+
|
|
456
|
+
for sev in ["ERROR", "WARNING", "INFO"]:
|
|
457
|
+
if sev in by_sev_llm:
|
|
458
|
+
parts.append(f"#### {sev} ({len(by_sev_llm[sev])})\n")
|
|
459
|
+
for f in by_sev_llm[sev]:
|
|
460
|
+
parts.append(f"- **[{f.assertion_id}]** {f.message}")
|
|
461
|
+
parts.append(f" - Location: `{f.location}`")
|
|
462
|
+
if getattr(f, "llm_reasoning", None):
|
|
463
|
+
parts.append(f" - Reasoning: {f.llm_reasoning}")
|
|
464
|
+
|
|
465
|
+
if not pattern_findings and not llm_findings:
|
|
466
|
+
parts.append("No assertion violations found.")
|
|
467
|
+
|
|
468
|
+
if suppressed:
|
|
469
|
+
parts.append(f"\n*Suppressed: {len(suppressed)}*")
|
|
470
|
+
for f in suppressed:
|
|
471
|
+
reason = f" ({f.suppression_reason})" if f.suppression_reason else ""
|
|
472
|
+
parts.append(f"- {f.assertion_id}: {f.location}{reason}")
|
|
473
|
+
|
|
474
|
+
parts.append("")
|
|
475
|
+
|
|
503
476
|
# Review checklists from skills
|
|
504
477
|
if skill_content:
|
|
505
478
|
parts.append("---\n")
|
|
@@ -525,6 +498,8 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
525
498
|
"""Handle unified review tool."""
|
|
526
499
|
import os
|
|
527
500
|
|
|
501
|
+
from crucible.enforcement.models import ComplianceConfig, OverflowBehavior
|
|
502
|
+
|
|
528
503
|
path = arguments.get("path")
|
|
529
504
|
mode = arguments.get("mode")
|
|
530
505
|
base = arguments.get("base")
|
|
@@ -532,6 +507,19 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
532
507
|
skills_override = arguments.get("skills")
|
|
533
508
|
include_skills = arguments.get("include_skills", True)
|
|
534
509
|
include_knowledge = arguments.get("include_knowledge", True)
|
|
510
|
+
enforce = arguments.get("enforce", True)
|
|
511
|
+
|
|
512
|
+
# Build compliance config
|
|
513
|
+
compliance_enabled = arguments.get("compliance_enabled", True)
|
|
514
|
+
compliance_model = arguments.get("compliance_model", "sonnet")
|
|
515
|
+
token_budget = arguments.get("token_budget", 10000)
|
|
516
|
+
|
|
517
|
+
compliance_config = ComplianceConfig(
|
|
518
|
+
enabled=compliance_enabled,
|
|
519
|
+
model=compliance_model,
|
|
520
|
+
token_budget=token_budget,
|
|
521
|
+
overflow_behavior=OverflowBehavior.WARN,
|
|
522
|
+
)
|
|
535
523
|
|
|
536
524
|
# Determine if this is path-based or git-based review
|
|
537
525
|
git_context: GitContext | None = None
|
|
@@ -593,34 +581,54 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
593
581
|
repo_path = get_repo_root(path if path else os.getcwd()).value
|
|
594
582
|
for file_path in changed_files:
|
|
595
583
|
full_path = f"{repo_path}/{file_path}"
|
|
596
|
-
domain, domain_tags =
|
|
584
|
+
domain, domain_tags = detect_domain(file_path)
|
|
597
585
|
domains_detected.add(domain)
|
|
598
586
|
all_domain_tags.update(domain_tags)
|
|
599
587
|
|
|
600
|
-
findings, errors =
|
|
588
|
+
findings, errors = run_static_analysis(full_path, domain, domain_tags)
|
|
601
589
|
all_findings.extend(findings)
|
|
602
590
|
tool_errors.extend([f"{e} ({file_path})" for e in errors])
|
|
603
591
|
|
|
604
592
|
# Filter findings to changed lines
|
|
605
|
-
all_findings =
|
|
593
|
+
all_findings = filter_findings_to_changes(all_findings, git_context, include_context)
|
|
606
594
|
else:
|
|
607
595
|
# Path mode: analyze the path directly
|
|
608
|
-
domain, domain_tags =
|
|
596
|
+
domain, domain_tags = detect_domain(path)
|
|
609
597
|
domains_detected.add(domain)
|
|
610
598
|
all_domain_tags.update(domain_tags)
|
|
611
599
|
|
|
612
|
-
findings, errors =
|
|
600
|
+
findings, errors = run_static_analysis(path, domain, domain_tags)
|
|
613
601
|
all_findings.extend(findings)
|
|
614
602
|
tool_errors.extend(errors)
|
|
615
603
|
|
|
616
604
|
# Deduplicate findings
|
|
617
|
-
all_findings =
|
|
605
|
+
all_findings = deduplicate_findings(all_findings)
|
|
606
|
+
|
|
607
|
+
# Run pattern and LLM assertions
|
|
608
|
+
enforcement_findings = []
|
|
609
|
+
enforcement_errors: list[str] = []
|
|
610
|
+
assertions_checked = 0
|
|
611
|
+
assertions_skipped = 0
|
|
612
|
+
budget_state = None
|
|
613
|
+
|
|
614
|
+
if enforce:
|
|
615
|
+
if git_context:
|
|
616
|
+
repo_path = get_repo_root(path if path else os.getcwd()).value
|
|
617
|
+
enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
|
|
618
|
+
run_enforcement(
|
|
619
|
+
path or "",
|
|
620
|
+
changed_files=changed_files,
|
|
621
|
+
repo_root=repo_path,
|
|
622
|
+
compliance_config=compliance_config,
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
elif path:
|
|
626
|
+
enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
|
|
627
|
+
run_enforcement(path, compliance_config=compliance_config)
|
|
628
|
+
)
|
|
618
629
|
|
|
619
630
|
# 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
|
|
631
|
+
severity_counts = compute_severity_counts(all_findings)
|
|
624
632
|
|
|
625
633
|
# Load skills and knowledge
|
|
626
634
|
matched_skills: list[tuple[str, list[str]]] | None = None
|
|
@@ -630,7 +638,7 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
630
638
|
|
|
631
639
|
if include_skills or include_knowledge:
|
|
632
640
|
primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
|
|
633
|
-
matched, s_content, k_files, k_content =
|
|
641
|
+
matched, s_content, k_files, k_content = load_skills_and_knowledge(
|
|
634
642
|
primary_domain, list(all_domain_tags), skills_override
|
|
635
643
|
)
|
|
636
644
|
if include_skills:
|
|
@@ -652,6 +660,11 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
652
660
|
skill_content,
|
|
653
661
|
knowledge_files,
|
|
654
662
|
knowledge_content,
|
|
663
|
+
enforcement_findings if enforce else None,
|
|
664
|
+
enforcement_errors if enforce else None,
|
|
665
|
+
assertions_checked,
|
|
666
|
+
assertions_skipped,
|
|
667
|
+
budget_state,
|
|
655
668
|
)
|
|
656
669
|
|
|
657
670
|
return [TextContent(type="text", text=output)]
|
|
@@ -773,89 +786,13 @@ def _handle_check_tools(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
773
786
|
return [TextContent(type="text", text="\n".join(parts))]
|
|
774
787
|
|
|
775
788
|
|
|
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
789
|
def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
853
790
|
"""Handle quick_review tool - returns findings with domain metadata."""
|
|
854
791
|
path = arguments.get("path", "")
|
|
855
792
|
tools = arguments.get("tools")
|
|
856
793
|
|
|
857
794
|
# Internal domain detection
|
|
858
|
-
domain, domain_tags =
|
|
795
|
+
domain, domain_tags = detect_domain(path)
|
|
859
796
|
|
|
860
797
|
# Select tools based on domain
|
|
861
798
|
if domain == Domain.SMART_CONTRACT:
|
|
@@ -908,7 +845,7 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
908
845
|
tool_results.append(f"## Bandit\nError: {result.error}")
|
|
909
846
|
|
|
910
847
|
# Deduplicate findings
|
|
911
|
-
all_findings =
|
|
848
|
+
all_findings = deduplicate_findings(all_findings)
|
|
912
849
|
|
|
913
850
|
# Compute severity summary
|
|
914
851
|
severity_counts: dict[str, int] = {}
|
|
@@ -927,60 +864,6 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
927
864
|
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
928
865
|
|
|
929
866
|
|
|
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
867
|
def _format_change_review(
|
|
985
868
|
context: GitContext,
|
|
986
869
|
findings: list[ToolFinding],
|
|
@@ -1136,7 +1019,7 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1136
1019
|
full_path = f"{repo_path}/{file_path}"
|
|
1137
1020
|
|
|
1138
1021
|
# Detect domain for this file
|
|
1139
|
-
domain, domain_tags =
|
|
1022
|
+
domain, domain_tags = detect_domain(file_path)
|
|
1140
1023
|
domains_detected.add(domain)
|
|
1141
1024
|
all_domain_tags.update(domain_tags)
|
|
1142
1025
|
|
|
@@ -1181,10 +1064,10 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1181
1064
|
tool_errors.append(f"bandit ({file_path}): {result.error}")
|
|
1182
1065
|
|
|
1183
1066
|
# Filter findings to changed lines
|
|
1184
|
-
filtered_findings =
|
|
1067
|
+
filtered_findings = filter_findings_to_changes(all_findings, context, include_context)
|
|
1185
1068
|
|
|
1186
1069
|
# Deduplicate findings
|
|
1187
|
-
filtered_findings =
|
|
1070
|
+
filtered_findings = deduplicate_findings(filtered_findings)
|
|
1188
1071
|
|
|
1189
1072
|
# Compute severity summary
|
|
1190
1073
|
severity_counts: dict[str, int] = {}
|
|
@@ -1235,56 +1118,20 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1235
1118
|
|
|
1236
1119
|
|
|
1237
1120
|
def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
1238
|
-
"""Handle full_review tool - comprehensive code review.
|
|
1121
|
+
"""Handle full_review tool - comprehensive code review.
|
|
1122
|
+
|
|
1123
|
+
DEPRECATED: Use _handle_review with path parameter instead.
|
|
1124
|
+
"""
|
|
1125
|
+
from crucible.review.core import run_static_analysis
|
|
1126
|
+
|
|
1239
1127
|
path = arguments.get("path", "")
|
|
1240
1128
|
skills_override = arguments.get("skills")
|
|
1241
|
-
# include_sage is accepted but not yet implemented
|
|
1242
|
-
# _ = arguments.get("include_sage", True)
|
|
1243
1129
|
|
|
1244
1130
|
# 1. Detect domain
|
|
1245
|
-
domain, domain_tags =
|
|
1131
|
+
domain, domain_tags = detect_domain(path)
|
|
1246
1132
|
|
|
1247
|
-
# 2. Run static analysis
|
|
1248
|
-
|
|
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] = []
|
|
1259
|
-
|
|
1260
|
-
if "semgrep" in default_tools:
|
|
1261
|
-
config = get_semgrep_config(domain)
|
|
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}")
|
|
1133
|
+
# 2. Run static analysis using shared core function
|
|
1134
|
+
all_findings, tool_errors = run_static_analysis(path, domain, domain_tags)
|
|
1288
1135
|
|
|
1289
1136
|
# 3. Match applicable skills
|
|
1290
1137
|
matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
|
|
@@ -1318,13 +1165,10 @@ def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
1318
1165
|
)
|
|
1319
1166
|
|
|
1320
1167
|
# 7. Deduplicate findings
|
|
1321
|
-
all_findings =
|
|
1168
|
+
all_findings = deduplicate_findings(all_findings)
|
|
1322
1169
|
|
|
1323
1170
|
# 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
|
|
1171
|
+
severity_counts = compute_severity_counts(all_findings)
|
|
1328
1172
|
|
|
1329
1173
|
# 8. Build result
|
|
1330
1174
|
review_result = FullReviewResult(
|
crucible/tools/git.py
CHANGED
|
@@ -42,11 +42,17 @@ class GitContext:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def is_git_repo(path: str | Path) -> bool:
|
|
45
|
-
"""Check if the path is inside a git repository.
|
|
45
|
+
"""Check if the path is inside a git repository.
|
|
46
|
+
|
|
47
|
+
Works with both files and directories.
|
|
48
|
+
"""
|
|
49
|
+
path = Path(path)
|
|
50
|
+
# Use parent directory if path is a file
|
|
51
|
+
check_dir = path.parent if path.is_file() else path
|
|
46
52
|
try:
|
|
47
53
|
result = subprocess.run(
|
|
48
54
|
["git", "rev-parse", "--git-dir"],
|
|
49
|
-
cwd=str(
|
|
55
|
+
cwd=str(check_dir),
|
|
50
56
|
capture_output=True,
|
|
51
57
|
text=True,
|
|
52
58
|
timeout=5,
|
|
@@ -57,14 +63,21 @@ def is_git_repo(path: str | Path) -> bool:
|
|
|
57
63
|
|
|
58
64
|
|
|
59
65
|
def get_repo_root(path: str | Path) -> Result[str, str]:
|
|
60
|
-
"""Get the root directory of the git repository.
|
|
66
|
+
"""Get the root directory of the git repository.
|
|
67
|
+
|
|
68
|
+
Works with both files and directories.
|
|
69
|
+
"""
|
|
61
70
|
if not shutil.which("git"):
|
|
62
71
|
return err("git not found")
|
|
63
72
|
|
|
73
|
+
path = Path(path)
|
|
74
|
+
# Use parent directory if path is a file
|
|
75
|
+
check_dir = path.parent if path.is_file() else path
|
|
76
|
+
|
|
64
77
|
try:
|
|
65
78
|
result = subprocess.run(
|
|
66
79
|
["git", "rev-parse", "--show-toplevel"],
|
|
67
|
-
cwd=str(
|
|
80
|
+
cwd=str(check_dir),
|
|
68
81
|
capture_output=True,
|
|
69
82
|
text=True,
|
|
70
83
|
timeout=5,
|