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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """crucible: Code review orchestration MCP server."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.4.0"
crucible/cli.py CHANGED
@@ -442,11 +442,13 @@ def cmd_review(args: argparse.Namespace) -> int:
442
442
  import json as json_mod
443
443
 
444
444
  from crucible.models import Domain, Severity, ToolFinding
445
- from crucible.tools.delegation import (
446
- delegate_bandit,
447
- delegate_ruff,
448
- delegate_semgrep,
449
- delegate_slither,
445
+ from crucible.review.core import (
446
+ compute_severity_counts,
447
+ deduplicate_findings,
448
+ detect_domain_for_file,
449
+ filter_findings_to_changes,
450
+ get_tools_for_domain,
451
+ run_static_analysis,
450
452
  )
451
453
  from crucible.tools.git import (
452
454
  get_branch_diff,
@@ -457,92 +459,6 @@ def cmd_review(args: argparse.Namespace) -> int:
457
459
  is_git_repo,
458
460
  )
459
461
 
460
- # Helper functions (inline to avoid circular imports)
461
- def get_semgrep_config(domain: Domain) -> str:
462
- if domain == Domain.SMART_CONTRACT:
463
- return "p/smart-contracts"
464
- elif domain == Domain.BACKEND:
465
- return "p/python"
466
- elif domain == Domain.FRONTEND:
467
- return "p/typescript"
468
- return "auto"
469
-
470
- def detect_domain(filename: str) -> tuple[Domain, list[str]]:
471
- """Detect domain from file extension."""
472
- ext = Path(filename).suffix.lower()
473
- if ext == ".sol":
474
- return Domain.SMART_CONTRACT, ["solidity", "smart_contract", "web3"]
475
- elif ext in (".py", ".pyw"):
476
- return Domain.BACKEND, ["python", "backend"]
477
- elif ext in (".ts", ".tsx", ".js", ".jsx"):
478
- return Domain.FRONTEND, ["typescript", "frontend", "javascript"]
479
- elif ext == ".rs":
480
- return Domain.BACKEND, ["rust", "backend"]
481
- elif ext == ".go":
482
- return Domain.BACKEND, ["go", "backend"]
483
- return Domain.UNKNOWN, []
484
-
485
- def get_changed_files(changes: list) -> list[str]:
486
- """Get list of changed files (excluding deleted)."""
487
- return [c.path for c in changes if c.status != "D"]
488
-
489
- def parse_location_line(location: str) -> int | None:
490
- """Extract line number from location string like 'file.py:10'."""
491
- if ":" in location:
492
- try:
493
- return int(location.split(":")[1].split(":")[0])
494
- except (ValueError, IndexError):
495
- return None
496
- return None
497
-
498
- def filter_findings_to_changes(
499
- findings: list[ToolFinding],
500
- changes: list,
501
- include_context: bool = False,
502
- ) -> list[ToolFinding]:
503
- """Filter findings to only those in changed lines."""
504
- # Build a lookup of file -> changed line ranges
505
- changed_ranges: dict[str, list[tuple[int, int]]] = {}
506
- for change in changes:
507
- if change.status == "D":
508
- continue # Skip deleted files
509
- ranges = [(r.start, r.end) for r in change.added_lines]
510
- changed_ranges[change.path] = ranges
511
-
512
- context_lines = 5 if include_context else 0
513
- filtered: list[ToolFinding] = []
514
-
515
- for finding in findings:
516
- # Parse location: "path:line" or "path:line:col"
517
- parts = finding.location.split(":")
518
- if len(parts) < 2:
519
- continue
520
-
521
- file_path = parts[0]
522
- try:
523
- line_num = int(parts[1])
524
- except ValueError:
525
- continue
526
-
527
- # Check if file is in changes
528
- # Handle both absolute and relative paths
529
- matching_file = None
530
- for changed_file in changed_ranges:
531
- if file_path.endswith(changed_file) or changed_file.endswith(file_path):
532
- matching_file = changed_file
533
- break
534
-
535
- if not matching_file:
536
- continue
537
-
538
- # Check if line is in changed ranges (with context)
539
- for start, end in changed_ranges[matching_file]:
540
- if start - context_lines <= line_num <= end + context_lines:
541
- filtered.append(finding)
542
- break
543
-
544
- return filtered
545
-
546
462
  path = args.path or "."
547
463
  mode = args.mode
548
464
 
@@ -638,7 +554,7 @@ def cmd_review(args: argparse.Namespace) -> int:
638
554
  print("No changes found.")
639
555
  return 0
640
556
 
641
- changed_files = get_changed_files(context.changes)
557
+ changed_files = [c.path for c in context.changes if c.status != "D"]
642
558
  if not changed_files:
643
559
  print("No files to analyze (only deletions).")
644
560
  return 0
@@ -656,77 +572,24 @@ def cmd_review(args: argparse.Namespace) -> int:
656
572
 
657
573
  for file_path in changed_files:
658
574
  full_path = f"{repo_path}/{file_path}"
659
- domain, domain_tags = detect_domain(file_path)
575
+ domain, domain_tags = detect_domain_for_file(file_path)
660
576
  domains_detected.add(domain)
661
577
  all_domain_tags.update(domain_tags)
662
578
 
663
- # Select tools based on domain
664
- if domain == Domain.SMART_CONTRACT:
665
- tools = ["slither", "semgrep"]
666
- elif domain == Domain.BACKEND and "python" in domain_tags:
667
- tools = ["ruff", "bandit", "semgrep"]
668
- elif domain == Domain.FRONTEND:
669
- tools = ["semgrep"]
670
- else:
671
- tools = ["semgrep"]
672
-
673
- # Apply skip_tools from config
579
+ # Get tools for this domain, applying skip_tools from config
580
+ tools = get_tools_for_domain(domain, domain_tags)
674
581
  tools = [t for t in tools if t not in skip_tools]
675
582
 
676
- # Run tools
677
- if "semgrep" in tools:
678
- semgrep_config = get_semgrep_config(domain)
679
- result = delegate_semgrep(full_path, semgrep_config)
680
- if result.is_ok:
681
- all_findings.extend(result.value)
682
- elif result.is_err:
683
- tool_errors.append(f"semgrep ({file_path}): {result.error}")
684
-
685
- if "ruff" in tools:
686
- result = delegate_ruff(full_path)
687
- if result.is_ok:
688
- all_findings.extend(result.value)
689
- elif result.is_err:
690
- tool_errors.append(f"ruff ({file_path}): {result.error}")
691
-
692
- if "slither" in tools:
693
- result = delegate_slither(full_path)
694
- if result.is_ok:
695
- all_findings.extend(result.value)
696
- elif result.is_err:
697
- tool_errors.append(f"slither ({file_path}): {result.error}")
698
-
699
- if "bandit" in tools:
700
- result = delegate_bandit(full_path)
701
- if result.is_ok:
702
- all_findings.extend(result.value)
703
- elif result.is_err:
704
- tool_errors.append(f"bandit ({file_path}): {result.error}")
705
-
706
- # Filter findings to changed lines
583
+ # Run static analysis
584
+ findings, errors = run_static_analysis(full_path, domain, domain_tags, tools)
585
+ all_findings.extend(findings)
586
+ for err in errors:
587
+ tool_errors.append(f"{err} ({file_path})")
588
+
589
+ # Filter findings to changed lines and deduplicate
707
590
  filtered_findings = filter_findings_to_changes(
708
- all_findings, context.changes, args.include_context
591
+ all_findings, context, args.include_context
709
592
  )
710
-
711
- # Deduplicate findings
712
- def deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
713
- """Deduplicate findings by location and message."""
714
- seen: dict[tuple[str, str], ToolFinding] = {}
715
- severity_order = [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]
716
-
717
- for f in findings:
718
- norm_msg = f.message.lower().strip()
719
- key = (f.location, norm_msg)
720
-
721
- if key not in seen:
722
- seen[key] = f
723
- else:
724
- existing = seen[key]
725
- if severity_order.index(f.severity) < severity_order.index(existing.severity):
726
- seen[key] = f
727
-
728
- return list(seen.values())
729
-
730
593
  filtered_findings = deduplicate_findings(filtered_findings)
731
594
 
732
595
  # Match skills and load knowledge based on detected domains
@@ -761,10 +624,7 @@ def cmd_review(args: argparse.Namespace) -> int:
761
624
  knowledge_content[filename] = result.value
762
625
 
763
626
  # Compute severity summary
764
- severity_counts: dict[str, int] = {}
765
- for f in filtered_findings:
766
- sev = f.severity.value
767
- severity_counts[sev] = severity_counts.get(sev, 0) + 1
627
+ severity_counts = compute_severity_counts(filtered_findings)
768
628
 
769
629
  # Determine pass/fail using per-domain thresholds
770
630
  # Use the strictest applicable threshold
@@ -964,6 +824,281 @@ def cmd_review(args: argparse.Namespace) -> int:
964
824
  return 0 if passed else 1
965
825
 
966
826
 
827
+ # --- Assertions commands ---
828
+
829
+ # Assertions directories
830
+ ASSERTIONS_BUNDLED = Path(__file__).parent / "enforcement" / "bundled"
831
+ ASSERTIONS_USER = Path.home() / ".claude" / "crucible" / "assertions"
832
+ ASSERTIONS_PROJECT = Path(".crucible") / "assertions"
833
+
834
+
835
+ def cmd_assertions_validate(args: argparse.Namespace) -> int:
836
+ """Validate assertion files."""
837
+ from crucible.enforcement.assertions import (
838
+ clear_assertion_cache,
839
+ get_all_assertion_files,
840
+ load_assertion_file,
841
+ )
842
+
843
+ clear_assertion_cache()
844
+
845
+ files = get_all_assertion_files()
846
+ if not files:
847
+ print("No assertion files found.")
848
+ print("\nCreate assertion files in:")
849
+ print(f" Project: {ASSERTIONS_PROJECT}/")
850
+ print(f" User: {ASSERTIONS_USER}/")
851
+ return 0
852
+
853
+ valid_count = 0
854
+ error_count = 0
855
+
856
+ for filename in sorted(files):
857
+ result = load_assertion_file(filename)
858
+ if result.is_ok:
859
+ assertion_count = len(result.value.assertions)
860
+ print(f" ✓ {filename}: {assertion_count} assertion(s) valid")
861
+ valid_count += 1
862
+ else:
863
+ print(f" ✗ {filename}: {result.error}")
864
+ error_count += 1
865
+
866
+ print()
867
+ if error_count == 0:
868
+ print(f"All {valid_count} file(s) valid.")
869
+ return 0
870
+ else:
871
+ print(f"{error_count} file(s) with errors, {valid_count} valid.")
872
+ return 1
873
+
874
+
875
+ def cmd_assertions_list(args: argparse.Namespace) -> int:
876
+ """List available assertion files."""
877
+ print("Bundled assertions:")
878
+ if ASSERTIONS_BUNDLED.exists():
879
+ found = False
880
+ for file_path in sorted(ASSERTIONS_BUNDLED.iterdir()):
881
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
882
+ print(f" - {file_path.name}")
883
+ found = True
884
+ if not found:
885
+ print(" (none)")
886
+ else:
887
+ print(" (none)")
888
+
889
+ print("\nUser assertions (~/.claude/crucible/assertions/):")
890
+ if ASSERTIONS_USER.exists():
891
+ found = False
892
+ for file_path in sorted(ASSERTIONS_USER.iterdir()):
893
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
894
+ print(f" - {file_path.name}")
895
+ found = True
896
+ if not found:
897
+ print(" (none)")
898
+ else:
899
+ print(" (none)")
900
+
901
+ print("\nProject assertions (.crucible/assertions/):")
902
+ if ASSERTIONS_PROJECT.exists():
903
+ found = False
904
+ for file_path in sorted(ASSERTIONS_PROJECT.iterdir()):
905
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
906
+ print(f" - {file_path.name}")
907
+ found = True
908
+ if not found:
909
+ print(" (none)")
910
+ else:
911
+ print(" (none)")
912
+
913
+ return 0
914
+
915
+
916
+ def cmd_assertions_test(args: argparse.Namespace) -> int:
917
+ """Test assertions against a file or directory."""
918
+ import os
919
+
920
+ from crucible.enforcement.assertions import load_assertions
921
+ from crucible.enforcement.patterns import run_pattern_assertions
922
+
923
+ target_path = Path(args.file)
924
+ if not target_path.exists():
925
+ print(f"Error: Path '{target_path}' not found")
926
+ return 1
927
+
928
+ assertions, errors = load_assertions()
929
+
930
+ if errors:
931
+ print("Assertion loading errors:")
932
+ for error in errors:
933
+ print(f" - {error}")
934
+ print()
935
+
936
+ if not assertions:
937
+ print("No assertions loaded.")
938
+ return 0
939
+
940
+ # Collect files to test
941
+ files_to_test: list[tuple[str, str]] = [] # (display_path, content)
942
+
943
+ if target_path.is_file():
944
+ try:
945
+ content = target_path.read_text()
946
+ files_to_test.append((str(target_path), content))
947
+ except UnicodeDecodeError:
948
+ print(f"Error: Cannot read '{target_path}' (binary file?)")
949
+ return 1
950
+ else:
951
+ # Directory mode
952
+ for root, _, files in os.walk(target_path):
953
+ for fname in files:
954
+ fpath = Path(root) / fname
955
+ rel_path = fpath.relative_to(target_path)
956
+ try:
957
+ content = fpath.read_text()
958
+ files_to_test.append((str(rel_path), content))
959
+ except (UnicodeDecodeError, OSError):
960
+ pass # Skip binary/unreadable files
961
+
962
+ # Run assertions on all files
963
+ all_findings = []
964
+ checked = 0
965
+ skipped = 0
966
+
967
+ for display_path, content in files_to_test:
968
+ findings, c, s = run_pattern_assertions(display_path, content, assertions)
969
+ all_findings.extend(findings)
970
+ checked = max(checked, c)
971
+ skipped = max(skipped, s)
972
+
973
+ # Separate suppressed and active findings
974
+ active = [f for f in all_findings if not f.suppressed]
975
+ suppressed = [f for f in all_findings if f.suppressed]
976
+
977
+ print(f"Testing {target_path}")
978
+ print(f" Files scanned: {len(files_to_test)}")
979
+ print(f" Assertions checked: {checked}")
980
+ print(f" Assertions skipped (LLM): {skipped}")
981
+ print()
982
+
983
+ if active:
984
+ print(f"Findings ({len(active)}):")
985
+ for f in active:
986
+ sev = f.severity.upper()
987
+ print(f" [{sev}] {f.assertion_id}: {f.location}")
988
+ print(f" {f.message}")
989
+ if f.match_text:
990
+ print(f" Matched: {f.match_text!r}")
991
+ print()
992
+
993
+ if suppressed:
994
+ print(f"Suppressed ({len(suppressed)}):")
995
+ for f in suppressed:
996
+ reason = f" -- {f.suppression_reason}" if f.suppression_reason else ""
997
+ print(f" {f.assertion_id}: {f.location}{reason}")
998
+
999
+ if not active and not suppressed:
1000
+ print("No matches found.")
1001
+
1002
+ return 0
1003
+
1004
+
1005
+ def cmd_assertions_explain(args: argparse.Namespace) -> int:
1006
+ """Explain what a rule does."""
1007
+ from crucible.enforcement.assertions import load_assertions
1008
+
1009
+ rule_id = args.rule.lower()
1010
+ assertions, errors = load_assertions()
1011
+
1012
+ for assertion in assertions:
1013
+ if assertion.id.lower() == rule_id:
1014
+ print(f"Rule: {assertion.id}")
1015
+ print(f"Type: {assertion.type.value}")
1016
+ if assertion.pattern:
1017
+ print(f"Pattern: {assertion.pattern}")
1018
+ print(f"Message: {assertion.message}")
1019
+ print(f"Severity: {assertion.severity}")
1020
+ print(f"Priority: {assertion.priority.value}")
1021
+ if assertion.languages:
1022
+ print(f"Languages: {', '.join(assertion.languages)}")
1023
+ if assertion.applicability:
1024
+ if assertion.applicability.glob:
1025
+ print(f"Applies to: {assertion.applicability.glob}")
1026
+ if assertion.applicability.exclude:
1027
+ print(f"Excludes: {', '.join(assertion.applicability.exclude)}")
1028
+ if assertion.compliance:
1029
+ print(f"Compliance: {assertion.compliance}")
1030
+ return 0
1031
+
1032
+ print(f"Rule '{rule_id}' not found.")
1033
+ print("\nAvailable rules:")
1034
+ for a in assertions[:10]:
1035
+ print(f" - {a.id}")
1036
+ if len(assertions) > 10:
1037
+ print(f" ... and {len(assertions) - 10} more")
1038
+ return 1
1039
+
1040
+
1041
+ def cmd_assertions_debug(args: argparse.Namespace) -> int:
1042
+ """Debug applicability for a rule and file."""
1043
+ from crucible.enforcement.assertions import load_assertions
1044
+ from crucible.enforcement.patterns import matches_glob, matches_language
1045
+
1046
+ rule_id = args.rule.lower()
1047
+ file_path = args.file
1048
+
1049
+ assertions, errors = load_assertions()
1050
+
1051
+ for assertion in assertions:
1052
+ if assertion.id.lower() == rule_id:
1053
+ print(f"Applicability check for '{assertion.id}':")
1054
+ print()
1055
+
1056
+ # Language check
1057
+ if assertion.languages:
1058
+ lang_match = matches_language(file_path, assertion.languages)
1059
+ lang_status = "MATCH" if lang_match else "NO MATCH"
1060
+ print(f" Languages: {', '.join(assertion.languages)}")
1061
+ print(f" File: {file_path} → {lang_status}")
1062
+ else:
1063
+ print(" Languages: (any)")
1064
+
1065
+ # Glob check
1066
+ if assertion.applicability:
1067
+ if assertion.applicability.glob:
1068
+ glob_match = matches_glob(
1069
+ file_path,
1070
+ assertion.applicability.glob,
1071
+ assertion.applicability.exclude,
1072
+ )
1073
+ glob_status = "MATCH" if glob_match else "NO MATCH"
1074
+ print(f" Glob: {assertion.applicability.glob}")
1075
+ if assertion.applicability.exclude:
1076
+ print(f" Exclude: {', '.join(assertion.applicability.exclude)}")
1077
+ print(f" File: {file_path} → {glob_status}")
1078
+ else:
1079
+ print(" Glob: (any)")
1080
+ else:
1081
+ print(" Applicability: (none)")
1082
+
1083
+ # Overall result
1084
+ print()
1085
+ lang_ok = matches_language(file_path, assertion.languages)
1086
+ glob_ok = True
1087
+ if assertion.applicability and assertion.applicability.glob:
1088
+ glob_ok = matches_glob(
1089
+ file_path,
1090
+ assertion.applicability.glob,
1091
+ assertion.applicability.exclude,
1092
+ )
1093
+ overall = lang_ok and glob_ok
1094
+ result = "APPLICABLE" if overall else "NOT APPLICABLE"
1095
+ print(f" Result: {result}")
1096
+ return 0
1097
+
1098
+ print(f"Rule '{rule_id}' not found.")
1099
+ return 1
1100
+
1101
+
967
1102
  # --- Hooks commands ---
968
1103
 
969
1104
  PRECOMMIT_HOOK_SCRIPT = """\
@@ -1526,6 +1661,38 @@ def main() -> int:
1526
1661
  help="Project path (default: current directory)"
1527
1662
  )
1528
1663
 
1664
+ # === assertions command ===
1665
+ assertions_parser = subparsers.add_parser("assertions", help="Manage pattern assertions")
1666
+ assertions_sub = assertions_parser.add_subparsers(dest="assertions_command")
1667
+
1668
+ # assertions validate
1669
+ assertions_sub.add_parser("validate", help="Validate assertion files")
1670
+
1671
+ # assertions list
1672
+ assertions_sub.add_parser("list", help="List assertion files from all sources")
1673
+
1674
+ # assertions test
1675
+ assertions_test_parser = assertions_sub.add_parser(
1676
+ "test",
1677
+ help="Test assertions against a file"
1678
+ )
1679
+ assertions_test_parser.add_argument("file", help="File to test")
1680
+
1681
+ # assertions explain
1682
+ assertions_explain_parser = assertions_sub.add_parser(
1683
+ "explain",
1684
+ help="Explain what a rule does"
1685
+ )
1686
+ assertions_explain_parser.add_argument("rule", help="Rule ID to explain")
1687
+
1688
+ # assertions debug
1689
+ assertions_debug_parser = assertions_sub.add_parser(
1690
+ "debug",
1691
+ help="Debug applicability for a rule and file"
1692
+ )
1693
+ assertions_debug_parser.add_argument("--rule", "-r", required=True, help="Rule ID")
1694
+ assertions_debug_parser.add_argument("--file", "-f", required=True, help="File to check")
1695
+
1529
1696
  # === ci command ===
1530
1697
  ci_parser = subparsers.add_parser(
1531
1698
  "ci",
@@ -1593,6 +1760,20 @@ def main() -> int:
1593
1760
  else:
1594
1761
  hooks_parser.print_help()
1595
1762
  return 0
1763
+ elif args.command == "assertions":
1764
+ if args.assertions_command == "validate":
1765
+ return cmd_assertions_validate(args)
1766
+ elif args.assertions_command == "list":
1767
+ return cmd_assertions_list(args)
1768
+ elif args.assertions_command == "test":
1769
+ return cmd_assertions_test(args)
1770
+ elif args.assertions_command == "explain":
1771
+ return cmd_assertions_explain(args)
1772
+ elif args.assertions_command == "debug":
1773
+ return cmd_assertions_debug(args)
1774
+ else:
1775
+ assertions_parser.print_help()
1776
+ return 0
1596
1777
  elif args.command == "review":
1597
1778
  return cmd_review(args)
1598
1779
  elif args.command == "pre-commit":
@@ -1619,6 +1800,12 @@ def main() -> int:
1619
1800
  print(" crucible hooks uninstall Remove pre-commit hook")
1620
1801
  print(" crucible hooks status Show hook installation status")
1621
1802
  print()
1803
+ print(" crucible assertions list List assertion files from all sources")
1804
+ print(" crucible assertions validate Validate assertion files")
1805
+ print(" crucible assertions test <file> Test assertions against a file")
1806
+ print(" crucible assertions explain <r> Explain what a rule does")
1807
+ print(" crucible assertions debug Debug applicability for a rule")
1808
+ print()
1622
1809
  print(" crucible review Review git changes")
1623
1810
  print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1624
1811
  print(" --base <ref> Base branch or commit count")
@@ -0,0 +1,40 @@
1
+ """Enforcement module for pattern assertions and applicability checking."""
2
+
3
+ from crucible.enforcement.assertions import (
4
+ get_all_assertion_files,
5
+ load_assertion_file,
6
+ load_assertions,
7
+ resolve_assertion_file,
8
+ )
9
+ from crucible.enforcement.models import (
10
+ Assertion,
11
+ AssertionFile,
12
+ AssertionType,
13
+ PatternMatch,
14
+ Priority,
15
+ Suppression,
16
+ )
17
+ from crucible.enforcement.patterns import (
18
+ find_pattern_matches,
19
+ parse_suppressions,
20
+ run_pattern_assertions,
21
+ )
22
+
23
+ __all__ = [
24
+ # Models
25
+ "Assertion",
26
+ "AssertionFile",
27
+ "AssertionType",
28
+ "PatternMatch",
29
+ "Priority",
30
+ "Suppression",
31
+ # Assertions
32
+ "get_all_assertion_files",
33
+ "load_assertion_file",
34
+ "load_assertions",
35
+ "resolve_assertion_file",
36
+ # Patterns
37
+ "find_pattern_matches",
38
+ "parse_suppressions",
39
+ "run_pattern_assertions",
40
+ ]