crucible-mcp 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crucible/__init__.py +1 -1
- crucible/cli.py +347 -160
- crucible/enforcement/__init__.py +40 -0
- crucible/enforcement/assertions.py +276 -0
- crucible/enforcement/models.py +107 -0
- crucible/enforcement/patterns.py +337 -0
- crucible/review/__init__.py +23 -0
- crucible/review/core.py +383 -0
- crucible/server.py +104 -327
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/METADATA +10 -3
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/RECORD +14 -8
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.4.0.dist-info}/top_level.txt +0 -0
crucible/__init__.py
CHANGED
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.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
664
|
-
|
|
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
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
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
|
|
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
|
+
]
|