crucible-mcp 0.2.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
@@ -652,86 +568,63 @@ def cmd_review(args: argparse.Namespace) -> int:
652
568
 
653
569
  # Track domains detected for per-domain threshold checking
654
570
  domains_detected: set[Domain] = set()
571
+ all_domain_tags: set[str] = set()
655
572
 
656
573
  for file_path in changed_files:
657
574
  full_path = f"{repo_path}/{file_path}"
658
- domain, domain_tags = detect_domain(file_path)
575
+ domain, domain_tags = detect_domain_for_file(file_path)
659
576
  domains_detected.add(domain)
577
+ all_domain_tags.update(domain_tags)
660
578
 
661
- # Select tools based on domain
662
- if domain == Domain.SMART_CONTRACT:
663
- tools = ["slither", "semgrep"]
664
- elif domain == Domain.BACKEND and "python" in domain_tags:
665
- tools = ["ruff", "bandit", "semgrep"]
666
- elif domain == Domain.FRONTEND:
667
- tools = ["semgrep"]
668
- else:
669
- tools = ["semgrep"]
670
-
671
- # 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)
672
581
  tools = [t for t in tools if t not in skip_tools]
673
582
 
674
- # Run tools
675
- if "semgrep" in tools:
676
- semgrep_config = get_semgrep_config(domain)
677
- result = delegate_semgrep(full_path, semgrep_config)
678
- if result.is_ok:
679
- all_findings.extend(result.value)
680
- elif result.is_err:
681
- tool_errors.append(f"semgrep ({file_path}): {result.error}")
682
-
683
- if "ruff" in tools:
684
- result = delegate_ruff(full_path)
685
- if result.is_ok:
686
- all_findings.extend(result.value)
687
- elif result.is_err:
688
- tool_errors.append(f"ruff ({file_path}): {result.error}")
689
-
690
- if "slither" in tools:
691
- result = delegate_slither(full_path)
692
- if result.is_ok:
693
- all_findings.extend(result.value)
694
- elif result.is_err:
695
- tool_errors.append(f"slither ({file_path}): {result.error}")
696
-
697
- if "bandit" in tools:
698
- result = delegate_bandit(full_path)
699
- if result.is_ok:
700
- all_findings.extend(result.value)
701
- elif result.is_err:
702
- tool_errors.append(f"bandit ({file_path}): {result.error}")
703
-
704
- # 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
705
590
  filtered_findings = filter_findings_to_changes(
706
- all_findings, context.changes, args.include_context
591
+ all_findings, context, args.include_context
707
592
  )
593
+ filtered_findings = deduplicate_findings(filtered_findings)
708
594
 
709
- # Deduplicate findings
710
- def deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
711
- """Deduplicate findings by location and message."""
712
- seen: dict[tuple[str, str], ToolFinding] = {}
713
- severity_order = [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]
714
-
715
- for f in findings:
716
- norm_msg = f.message.lower().strip()
717
- key = (f.location, norm_msg)
718
-
719
- if key not in seen:
720
- seen[key] = f
721
- else:
722
- existing = seen[key]
723
- if severity_order.index(f.severity) < severity_order.index(existing.severity):
724
- seen[key] = f
595
+ # Match skills and load knowledge based on detected domains
596
+ from crucible.knowledge.loader import load_knowledge_file
597
+ from crucible.skills.loader import (
598
+ get_knowledge_for_skills,
599
+ load_skill,
600
+ match_skills_for_domain,
601
+ )
725
602
 
726
- return list(seen.values())
603
+ # Use first domain for primary matching (most files determine this)
604
+ primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
605
+ matched_skills = match_skills_for_domain(
606
+ primary_domain, list(all_domain_tags), override=None
607
+ )
727
608
 
728
- filtered_findings = deduplicate_findings(filtered_findings)
609
+ # Load skill content
610
+ skill_names = [name for name, _ in matched_skills]
611
+ skill_content: dict[str, str] = {}
612
+ for skill_name, _triggers in matched_skills:
613
+ result = load_skill(skill_name)
614
+ if result.is_ok:
615
+ _, content = result.value
616
+ skill_content[skill_name] = content
617
+
618
+ # Load linked knowledge
619
+ knowledge_files = get_knowledge_for_skills(skill_names)
620
+ knowledge_content: dict[str, str] = {}
621
+ for filename in knowledge_files:
622
+ result = load_knowledge_file(filename)
623
+ if result.is_ok:
624
+ knowledge_content[filename] = result.value
729
625
 
730
626
  # Compute severity summary
731
- severity_counts: dict[str, int] = {}
732
- for f in filtered_findings:
733
- sev = f.severity.value
734
- severity_counts[sev] = severity_counts.get(sev, 0) + 1
627
+ severity_counts = compute_severity_counts(filtered_findings)
735
628
 
736
629
  # Determine pass/fail using per-domain thresholds
737
630
  # Use the strictest applicable threshold
@@ -767,6 +660,8 @@ def cmd_review(args: argparse.Namespace) -> int:
767
660
  "mode": mode,
768
661
  "files_changed": len(changed_files),
769
662
  "domains_detected": [d.value for d in domains_detected],
663
+ "skills_matched": dict(matched_skills),
664
+ "knowledge_loaded": list(knowledge_files),
770
665
  "findings": [
771
666
  {
772
667
  "tool": f.tool,
@@ -817,6 +712,19 @@ def cmd_review(args: argparse.Namespace) -> int:
817
712
  print(f"- `{c.path}` ({status_char})")
818
713
  print()
819
714
 
715
+ # Skills matched
716
+ if matched_skills:
717
+ print("## Applicable Skills\n")
718
+ for skill_name, triggers in matched_skills:
719
+ print(f"- **{skill_name}**: matched on {', '.join(triggers)}")
720
+ print()
721
+
722
+ # Knowledge loaded
723
+ if knowledge_files:
724
+ print("## Knowledge Loaded\n")
725
+ print(f"Files: {', '.join(sorted(knowledge_files))}")
726
+ print()
727
+
820
728
  # Findings by severity
821
729
  if filtered_findings:
822
730
  print("## Findings\n")
@@ -849,6 +757,23 @@ def cmd_review(args: argparse.Namespace) -> int:
849
757
  print(f"- {error}")
850
758
  print()
851
759
 
760
+ # Review checklists from skills
761
+ if skill_content:
762
+ print("## Review Checklists\n")
763
+ for skill_name, content in skill_content.items():
764
+ print(f"### {skill_name}\n")
765
+ # Print the skill content (already markdown)
766
+ print(content)
767
+ print()
768
+
769
+ # Knowledge reference
770
+ if knowledge_content:
771
+ print("## Principles Reference\n")
772
+ for filename, content in sorted(knowledge_content.items()):
773
+ print(f"### {filename}\n")
774
+ print(content)
775
+ print()
776
+
852
777
  # Result
853
778
  print("## Result\n")
854
779
  if effective_threshold:
@@ -899,6 +824,281 @@ def cmd_review(args: argparse.Namespace) -> int:
899
824
  return 0 if passed else 1
900
825
 
901
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
+
902
1102
  # --- Hooks commands ---
903
1103
 
904
1104
  PRECOMMIT_HOOK_SCRIPT = """\
@@ -1461,6 +1661,38 @@ def main() -> int:
1461
1661
  help="Project path (default: current directory)"
1462
1662
  )
1463
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
+
1464
1696
  # === ci command ===
1465
1697
  ci_parser = subparsers.add_parser(
1466
1698
  "ci",
@@ -1528,6 +1760,20 @@ def main() -> int:
1528
1760
  else:
1529
1761
  hooks_parser.print_help()
1530
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
1531
1777
  elif args.command == "review":
1532
1778
  return cmd_review(args)
1533
1779
  elif args.command == "pre-commit":
@@ -1554,6 +1800,12 @@ def main() -> int:
1554
1800
  print(" crucible hooks uninstall Remove pre-commit hook")
1555
1801
  print(" crucible hooks status Show hook installation status")
1556
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()
1557
1809
  print(" crucible review Review git changes")
1558
1810
  print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1559
1811
  print(" --base <ref> Base branch or commit count")