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 +1 -1
- crucible/cli.py +410 -158
- 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 +508 -273
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/METADATA +27 -7
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/RECORD +14 -8
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.2.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
|
|
@@ -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 =
|
|
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
|
-
#
|
|
662
|
-
|
|
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
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
591
|
+
all_findings, context, args.include_context
|
|
707
592
|
)
|
|
593
|
+
filtered_findings = deduplicate_findings(filtered_findings)
|
|
708
594
|
|
|
709
|
-
#
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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")
|