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/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 = _detect_domain(file_path)
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 = _run_static_analysis(full_path, domain, domain_tags)
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 = _filter_findings_to_changes(all_findings, git_context, include_context)
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 = _detect_domain(path)
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 = _run_static_analysis(path, domain, domain_tags)
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 = _deduplicate_findings(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: dict[str, int] = {}
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 = _load_skills_and_knowledge(
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 = _detect_domain(path)
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 = _deduplicate_findings(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 = _detect_domain(file_path)
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 = _filter_findings_to_changes(all_findings, context, include_context)
1067
+ filtered_findings = filter_findings_to_changes(all_findings, context, include_context)
1185
1068
 
1186
1069
  # Deduplicate findings
1187
- filtered_findings = _deduplicate_findings(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 = _detect_domain(path)
1131
+ domain, domain_tags = detect_domain(path)
1246
1132
 
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] = []
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 = _deduplicate_findings(all_findings)
1168
+ all_findings = deduplicate_findings(all_findings)
1322
1169
 
1323
1170
  # 8. Compute severity summary
1324
- severity_counts: dict[str, int] = {}
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(path),
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(path),
80
+ cwd=str(check_dir),
68
81
  capture_output=True,
69
82
  text=True,
70
83
  timeout=5,