crucible-mcp 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crucible/__init__.py +1 -1
- crucible/cli.py +772 -172
- crucible/enforcement/__init__.py +40 -0
- crucible/enforcement/assertions.py +276 -0
- crucible/enforcement/budget.py +179 -0
- crucible/enforcement/compliance.py +486 -0
- crucible/enforcement/models.py +177 -0
- crucible/enforcement/patterns.py +337 -0
- crucible/review/__init__.py +23 -0
- crucible/review/core.py +454 -0
- crucible/server.py +171 -327
- crucible/tools/git.py +17 -4
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/METADATA +11 -3
- crucible_mcp-0.5.0.dist-info/RECORD +30 -0
- crucible_mcp-0.3.0.dist-info/RECORD +0 -22
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.3.0.dist-info → crucible_mcp-0.5.0.dist-info}/top_level.txt +0 -0
crucible/cli.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""crucible CLI."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import argparse
|
|
4
6
|
import shutil
|
|
5
7
|
import sys
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
10
|
+
from crucible.enforcement.models import ComplianceConfig
|
|
11
|
+
|
|
8
12
|
# Skills directories
|
|
9
13
|
SKILLS_BUNDLED = Path(__file__).parent / "skills"
|
|
10
14
|
SKILLS_USER = Path.home() / ".claude" / "crucible" / "skills"
|
|
@@ -408,6 +412,12 @@ def _load_review_config(repo_path: str | None = None) -> dict:
|
|
|
408
412
|
backend: high
|
|
409
413
|
include_context: false
|
|
410
414
|
skip_tools: []
|
|
415
|
+
enforcement:
|
|
416
|
+
compliance:
|
|
417
|
+
enabled: true
|
|
418
|
+
model: sonnet
|
|
419
|
+
token_budget: 10000
|
|
420
|
+
overflow_behavior: warn
|
|
411
421
|
"""
|
|
412
422
|
import yaml
|
|
413
423
|
|
|
@@ -423,31 +433,236 @@ def _load_review_config(repo_path: str | None = None) -> dict:
|
|
|
423
433
|
try:
|
|
424
434
|
with open(config_project) as f:
|
|
425
435
|
config_data = yaml.safe_load(f) or {}
|
|
426
|
-
except Exception:
|
|
427
|
-
|
|
436
|
+
except Exception as e:
|
|
437
|
+
print(f"Warning: Could not load {config_project}: {e}", file=sys.stderr)
|
|
428
438
|
|
|
429
439
|
# Fall back to user-level
|
|
430
440
|
if not config_data and config_user.exists():
|
|
431
441
|
try:
|
|
432
442
|
with open(config_user) as f:
|
|
433
443
|
config_data = yaml.safe_load(f) or {}
|
|
434
|
-
except Exception:
|
|
435
|
-
|
|
444
|
+
except Exception as e:
|
|
445
|
+
print(f"Warning: Could not load {config_user}: {e}", file=sys.stderr)
|
|
436
446
|
|
|
437
447
|
return config_data
|
|
438
448
|
|
|
439
449
|
|
|
450
|
+
def _build_compliance_config(
|
|
451
|
+
config: dict,
|
|
452
|
+
cli_token_budget: int | None = None,
|
|
453
|
+
cli_model: str | None = None,
|
|
454
|
+
cli_no_compliance: bool = False,
|
|
455
|
+
) -> ComplianceConfig:
|
|
456
|
+
"""Build compliance config from config file and CLI overrides.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
config: Loaded config dict
|
|
460
|
+
cli_token_budget: CLI --token-budget override
|
|
461
|
+
cli_model: CLI --compliance-model override
|
|
462
|
+
cli_no_compliance: CLI --no-compliance flag
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
ComplianceConfig instance
|
|
466
|
+
"""
|
|
467
|
+
from crucible.enforcement.models import ComplianceConfig, OverflowBehavior
|
|
468
|
+
|
|
469
|
+
# Get enforcement.compliance section from config
|
|
470
|
+
enforcement_config = config.get("enforcement", {})
|
|
471
|
+
compliance_section = enforcement_config.get("compliance", {})
|
|
472
|
+
|
|
473
|
+
# Build config with defaults
|
|
474
|
+
enabled = not cli_no_compliance and compliance_section.get("enabled", True)
|
|
475
|
+
model = cli_model or compliance_section.get("model", "sonnet")
|
|
476
|
+
token_budget = cli_token_budget if cli_token_budget is not None else compliance_section.get("token_budget", 10000)
|
|
477
|
+
|
|
478
|
+
# Parse overflow behavior
|
|
479
|
+
overflow_str = compliance_section.get("overflow_behavior", "warn")
|
|
480
|
+
try:
|
|
481
|
+
overflow_behavior = OverflowBehavior(overflow_str.lower())
|
|
482
|
+
except ValueError:
|
|
483
|
+
overflow_behavior = OverflowBehavior.WARN
|
|
484
|
+
|
|
485
|
+
# Parse priority order
|
|
486
|
+
priority_order = compliance_section.get("priority_order", ["critical", "high", "medium", "low"])
|
|
487
|
+
if isinstance(priority_order, list):
|
|
488
|
+
priority_order = tuple(priority_order)
|
|
489
|
+
else:
|
|
490
|
+
priority_order = ("critical", "high", "medium", "low")
|
|
491
|
+
|
|
492
|
+
return ComplianceConfig(
|
|
493
|
+
enabled=enabled,
|
|
494
|
+
model=model,
|
|
495
|
+
token_budget=token_budget,
|
|
496
|
+
priority_order=priority_order,
|
|
497
|
+
overflow_behavior=overflow_behavior,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _cmd_review_no_git(args: argparse.Namespace, path: str) -> int:
|
|
502
|
+
"""Run static analysis on a path without git awareness."""
|
|
503
|
+
import json as json_mod
|
|
504
|
+
from pathlib import Path
|
|
505
|
+
|
|
506
|
+
from crucible.models import Domain, Severity, ToolFinding
|
|
507
|
+
from crucible.review.core import (
|
|
508
|
+
compute_severity_counts,
|
|
509
|
+
deduplicate_findings,
|
|
510
|
+
detect_domain_for_file,
|
|
511
|
+
get_tools_for_domain,
|
|
512
|
+
run_static_analysis,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
path_obj = Path(path)
|
|
516
|
+
if not path_obj.exists():
|
|
517
|
+
print(f"Error: {path} does not exist")
|
|
518
|
+
return 1
|
|
519
|
+
|
|
520
|
+
# Load config from current directory or user level
|
|
521
|
+
config = _load_review_config(".")
|
|
522
|
+
|
|
523
|
+
# Parse severity threshold
|
|
524
|
+
severity_order = ["critical", "high", "medium", "low", "info"]
|
|
525
|
+
severity_map = {
|
|
526
|
+
"critical": Severity.CRITICAL,
|
|
527
|
+
"high": Severity.HIGH,
|
|
528
|
+
"medium": Severity.MEDIUM,
|
|
529
|
+
"low": Severity.LOW,
|
|
530
|
+
"info": Severity.INFO,
|
|
531
|
+
}
|
|
532
|
+
default_threshold_str = args.fail_on or config.get("fail_on")
|
|
533
|
+
default_threshold: Severity | None = None
|
|
534
|
+
if default_threshold_str:
|
|
535
|
+
default_threshold = severity_map.get(default_threshold_str.lower())
|
|
536
|
+
|
|
537
|
+
skip_tools = set(config.get("skip_tools", []))
|
|
538
|
+
|
|
539
|
+
# Collect files to analyze
|
|
540
|
+
files_to_analyze: list[str] = []
|
|
541
|
+
if path_obj.is_file():
|
|
542
|
+
files_to_analyze = [str(path_obj)]
|
|
543
|
+
else:
|
|
544
|
+
# Recursively find files, respecting common ignores
|
|
545
|
+
ignore_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "build", "dist"}
|
|
546
|
+
for file_path in path_obj.rglob("*"):
|
|
547
|
+
if file_path.is_file():
|
|
548
|
+
# Skip ignored directories
|
|
549
|
+
if any(ignored in file_path.parts for ignored in ignore_dirs):
|
|
550
|
+
continue
|
|
551
|
+
files_to_analyze.append(str(file_path))
|
|
552
|
+
|
|
553
|
+
if not files_to_analyze:
|
|
554
|
+
print("No files to analyze.")
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
if not args.quiet and not args.json:
|
|
558
|
+
print(f"Reviewing {len(files_to_analyze)} file(s) (no git)...")
|
|
559
|
+
|
|
560
|
+
# Run analysis
|
|
561
|
+
all_findings: list[ToolFinding] = []
|
|
562
|
+
tool_errors: list[str] = []
|
|
563
|
+
domains_detected: set[Domain] = set()
|
|
564
|
+
all_domain_tags: set[str] = set()
|
|
565
|
+
|
|
566
|
+
for file_path in files_to_analyze:
|
|
567
|
+
domain, domain_tags = detect_domain_for_file(file_path)
|
|
568
|
+
domains_detected.add(domain)
|
|
569
|
+
all_domain_tags.update(domain_tags)
|
|
570
|
+
|
|
571
|
+
tools = get_tools_for_domain(domain, domain_tags)
|
|
572
|
+
tools = [t for t in tools if t not in skip_tools]
|
|
573
|
+
|
|
574
|
+
findings, errors = run_static_analysis(file_path, domain, domain_tags, tools)
|
|
575
|
+
all_findings.extend(findings)
|
|
576
|
+
tool_errors.extend(errors)
|
|
577
|
+
|
|
578
|
+
# Deduplicate
|
|
579
|
+
all_findings = deduplicate_findings(all_findings)
|
|
580
|
+
|
|
581
|
+
# Compute severity summary
|
|
582
|
+
severity_counts = compute_severity_counts(all_findings)
|
|
583
|
+
|
|
584
|
+
# Determine pass/fail
|
|
585
|
+
passed = True
|
|
586
|
+
if default_threshold:
|
|
587
|
+
threshold_idx = severity_order.index(default_threshold.value)
|
|
588
|
+
for sev in severity_order[: threshold_idx + 1]:
|
|
589
|
+
if severity_counts.get(sev, 0) > 0:
|
|
590
|
+
passed = False
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
# Output
|
|
594
|
+
if args.json:
|
|
595
|
+
output = {
|
|
596
|
+
"mode": "no-git",
|
|
597
|
+
"files_analyzed": len(files_to_analyze),
|
|
598
|
+
"domains_detected": [d.value for d in domains_detected],
|
|
599
|
+
"findings": [
|
|
600
|
+
{
|
|
601
|
+
"tool": f.tool,
|
|
602
|
+
"rule": f.rule,
|
|
603
|
+
"severity": f.severity.value,
|
|
604
|
+
"message": f.message,
|
|
605
|
+
"location": f.location,
|
|
606
|
+
"suggestion": f.suggestion,
|
|
607
|
+
}
|
|
608
|
+
for f in all_findings
|
|
609
|
+
],
|
|
610
|
+
"severity_counts": severity_counts,
|
|
611
|
+
"passed": passed,
|
|
612
|
+
"threshold": default_threshold.value if default_threshold else None,
|
|
613
|
+
"errors": tool_errors,
|
|
614
|
+
}
|
|
615
|
+
print(json_mod.dumps(output, indent=2))
|
|
616
|
+
else:
|
|
617
|
+
# Text output
|
|
618
|
+
if all_findings:
|
|
619
|
+
print(f"\nFound {len(all_findings)} issue(s):\n")
|
|
620
|
+
for f in all_findings:
|
|
621
|
+
sev_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "⚪"}.get(
|
|
622
|
+
f.severity.value, "⚪"
|
|
623
|
+
)
|
|
624
|
+
print(f"{sev_icon} [{f.severity.value.upper()}] {f.location}")
|
|
625
|
+
print(f" {f.tool}/{f.rule}: {f.message}")
|
|
626
|
+
if f.suggestion:
|
|
627
|
+
print(f" 💡 {f.suggestion}")
|
|
628
|
+
print()
|
|
629
|
+
else:
|
|
630
|
+
print("\n✅ No issues found.")
|
|
631
|
+
|
|
632
|
+
# Summary
|
|
633
|
+
if severity_counts:
|
|
634
|
+
counts_str = ", ".join(f"{k}: {v}" for k, v in severity_counts.items() if v > 0)
|
|
635
|
+
print(f"Summary: {counts_str}")
|
|
636
|
+
|
|
637
|
+
if tool_errors and not args.quiet:
|
|
638
|
+
print(f"\n⚠️ {len(tool_errors)} tool error(s)")
|
|
639
|
+
for err in tool_errors[:5]:
|
|
640
|
+
print(f" - {err}")
|
|
641
|
+
|
|
642
|
+
return 0 if passed else 1
|
|
643
|
+
|
|
644
|
+
|
|
440
645
|
def cmd_review(args: argparse.Namespace) -> int:
|
|
441
|
-
"""Run code review on git changes."""
|
|
646
|
+
"""Run code review on git changes or a path directly."""
|
|
442
647
|
import json as json_mod
|
|
443
648
|
|
|
444
649
|
from crucible.models import Domain, Severity, ToolFinding
|
|
445
|
-
from crucible.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
650
|
+
from crucible.review.core import (
|
|
651
|
+
compute_severity_counts,
|
|
652
|
+
deduplicate_findings,
|
|
653
|
+
detect_domain_for_file,
|
|
654
|
+
filter_findings_to_changes,
|
|
655
|
+
get_tools_for_domain,
|
|
656
|
+
run_static_analysis,
|
|
450
657
|
)
|
|
658
|
+
|
|
659
|
+
path = args.path or "."
|
|
660
|
+
|
|
661
|
+
# Handle --no-git mode: simple static analysis without git awareness
|
|
662
|
+
if getattr(args, "no_git", False):
|
|
663
|
+
return _cmd_review_no_git(args, path)
|
|
664
|
+
|
|
665
|
+
# Git-aware review mode
|
|
451
666
|
from crucible.tools.git import (
|
|
452
667
|
get_branch_diff,
|
|
453
668
|
get_recent_commits,
|
|
@@ -457,98 +672,12 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
457
672
|
is_git_repo,
|
|
458
673
|
)
|
|
459
674
|
|
|
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
|
-
path = args.path or "."
|
|
547
675
|
mode = args.mode
|
|
548
676
|
|
|
549
677
|
# Validate git repo
|
|
550
678
|
if not is_git_repo(path):
|
|
551
|
-
print(f"Error: {path} is not a git repository")
|
|
679
|
+
print(f"Error: {path} is not inside a git repository")
|
|
680
|
+
print("Hint: Use --no-git to review files without git awareness")
|
|
552
681
|
return 1
|
|
553
682
|
|
|
554
683
|
root_result = get_repo_root(path)
|
|
@@ -638,7 +767,7 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
638
767
|
print("No changes found.")
|
|
639
768
|
return 0
|
|
640
769
|
|
|
641
|
-
changed_files =
|
|
770
|
+
changed_files = [c.path for c in context.changes if c.status != "D"]
|
|
642
771
|
if not changed_files:
|
|
643
772
|
print("No files to analyze (only deletions).")
|
|
644
773
|
return 0
|
|
@@ -656,77 +785,24 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
656
785
|
|
|
657
786
|
for file_path in changed_files:
|
|
658
787
|
full_path = f"{repo_path}/{file_path}"
|
|
659
|
-
domain, domain_tags =
|
|
788
|
+
domain, domain_tags = detect_domain_for_file(file_path)
|
|
660
789
|
domains_detected.add(domain)
|
|
661
790
|
all_domain_tags.update(domain_tags)
|
|
662
791
|
|
|
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
|
|
792
|
+
# Get tools for this domain, applying skip_tools from config
|
|
793
|
+
tools = get_tools_for_domain(domain, domain_tags)
|
|
674
794
|
tools = [t for t in tools if t not in skip_tools]
|
|
675
795
|
|
|
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
|
|
796
|
+
# Run static analysis
|
|
797
|
+
findings, errors = run_static_analysis(full_path, domain, domain_tags, tools)
|
|
798
|
+
all_findings.extend(findings)
|
|
799
|
+
for err in errors:
|
|
800
|
+
tool_errors.append(f"{err} ({file_path})")
|
|
801
|
+
|
|
802
|
+
# Filter findings to changed lines and deduplicate
|
|
707
803
|
filtered_findings = filter_findings_to_changes(
|
|
708
|
-
all_findings, context
|
|
804
|
+
all_findings, context, args.include_context
|
|
709
805
|
)
|
|
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
806
|
filtered_findings = deduplicate_findings(filtered_findings)
|
|
731
807
|
|
|
732
808
|
# Match skills and load knowledge based on detected domains
|
|
@@ -760,11 +836,30 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
760
836
|
if result.is_ok:
|
|
761
837
|
knowledge_content[filename] = result.value
|
|
762
838
|
|
|
839
|
+
# Run enforcement assertions (pattern + LLM)
|
|
840
|
+
from crucible.review.core import run_enforcement
|
|
841
|
+
|
|
842
|
+
compliance_config = _build_compliance_config(
|
|
843
|
+
config,
|
|
844
|
+
cli_token_budget=getattr(args, "token_budget", None),
|
|
845
|
+
cli_model=getattr(args, "compliance_model", None),
|
|
846
|
+
cli_no_compliance=getattr(args, "no_compliance", False),
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
|
|
850
|
+
run_enforcement(
|
|
851
|
+
repo_path,
|
|
852
|
+
changed_files=changed_files,
|
|
853
|
+
repo_root=repo_path,
|
|
854
|
+
compliance_config=compliance_config,
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Add enforcement errors to tool errors
|
|
859
|
+
tool_errors.extend(enforcement_errors)
|
|
860
|
+
|
|
763
861
|
# 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
|
|
862
|
+
severity_counts = compute_severity_counts(filtered_findings)
|
|
768
863
|
|
|
769
864
|
# Determine pass/fail using per-domain thresholds
|
|
770
865
|
# Use the strictest applicable threshold
|
|
@@ -813,6 +908,22 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
813
908
|
}
|
|
814
909
|
for f in filtered_findings
|
|
815
910
|
],
|
|
911
|
+
"enforcement": {
|
|
912
|
+
"findings": [
|
|
913
|
+
{
|
|
914
|
+
"assertion_id": f.assertion_id,
|
|
915
|
+
"severity": f.severity,
|
|
916
|
+
"message": f.message,
|
|
917
|
+
"location": f.location,
|
|
918
|
+
"source": f.source,
|
|
919
|
+
"suppressed": f.suppressed,
|
|
920
|
+
}
|
|
921
|
+
for f in enforcement_findings
|
|
922
|
+
],
|
|
923
|
+
"assertions_checked": assertions_checked,
|
|
924
|
+
"assertions_skipped": assertions_skipped,
|
|
925
|
+
"llm_tokens_used": budget_state.tokens_used if budget_state else 0,
|
|
926
|
+
},
|
|
816
927
|
"severity_counts": severity_counts,
|
|
817
928
|
"threshold": threshold_used,
|
|
818
929
|
"errors": tool_errors,
|
|
@@ -955,6 +1066,28 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
955
1066
|
else:
|
|
956
1067
|
print("\nNo issues found in changed code.")
|
|
957
1068
|
|
|
1069
|
+
# Enforcement assertions
|
|
1070
|
+
if enforcement_findings:
|
|
1071
|
+
active_enforcement = [f for f in enforcement_findings if not f.suppressed]
|
|
1072
|
+
suppressed_enforcement = [f for f in enforcement_findings if f.suppressed]
|
|
1073
|
+
|
|
1074
|
+
if active_enforcement:
|
|
1075
|
+
print(f"\nEnforcement Assertions ({len(active_enforcement)}):")
|
|
1076
|
+
for f in active_enforcement:
|
|
1077
|
+
sev_upper = f.severity.upper()
|
|
1078
|
+
source_label = "[LLM]" if f.source == "llm" else "[PATTERN]"
|
|
1079
|
+
print(f" [{sev_upper}] {source_label} {f.assertion_id}: {f.location}")
|
|
1080
|
+
print(f" {f.message}")
|
|
1081
|
+
print()
|
|
1082
|
+
|
|
1083
|
+
if suppressed_enforcement:
|
|
1084
|
+
print(f" Suppressed: {len(suppressed_enforcement)}")
|
|
1085
|
+
|
|
1086
|
+
if assertions_checked > 0:
|
|
1087
|
+
print(f"\nAssertions: {assertions_checked} checked, {assertions_skipped} skipped")
|
|
1088
|
+
if budget_state and budget_state.tokens_used > 0:
|
|
1089
|
+
print(f" LLM tokens used: {budget_state.tokens_used}")
|
|
1090
|
+
|
|
958
1091
|
if effective_threshold:
|
|
959
1092
|
status = "PASSED" if passed else "FAILED"
|
|
960
1093
|
print(f"\n{'='*60}")
|
|
@@ -964,6 +1097,281 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
964
1097
|
return 0 if passed else 1
|
|
965
1098
|
|
|
966
1099
|
|
|
1100
|
+
# --- Assertions commands ---
|
|
1101
|
+
|
|
1102
|
+
# Assertions directories
|
|
1103
|
+
ASSERTIONS_BUNDLED = Path(__file__).parent / "enforcement" / "bundled"
|
|
1104
|
+
ASSERTIONS_USER = Path.home() / ".claude" / "crucible" / "assertions"
|
|
1105
|
+
ASSERTIONS_PROJECT = Path(".crucible") / "assertions"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def cmd_assertions_validate(args: argparse.Namespace) -> int:
|
|
1109
|
+
"""Validate assertion files."""
|
|
1110
|
+
from crucible.enforcement.assertions import (
|
|
1111
|
+
clear_assertion_cache,
|
|
1112
|
+
get_all_assertion_files,
|
|
1113
|
+
load_assertion_file,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
clear_assertion_cache()
|
|
1117
|
+
|
|
1118
|
+
files = get_all_assertion_files()
|
|
1119
|
+
if not files:
|
|
1120
|
+
print("No assertion files found.")
|
|
1121
|
+
print("\nCreate assertion files in:")
|
|
1122
|
+
print(f" Project: {ASSERTIONS_PROJECT}/")
|
|
1123
|
+
print(f" User: {ASSERTIONS_USER}/")
|
|
1124
|
+
return 0
|
|
1125
|
+
|
|
1126
|
+
valid_count = 0
|
|
1127
|
+
error_count = 0
|
|
1128
|
+
|
|
1129
|
+
for filename in sorted(files):
|
|
1130
|
+
result = load_assertion_file(filename)
|
|
1131
|
+
if result.is_ok:
|
|
1132
|
+
assertion_count = len(result.value.assertions)
|
|
1133
|
+
print(f" ✓ {filename}: {assertion_count} assertion(s) valid")
|
|
1134
|
+
valid_count += 1
|
|
1135
|
+
else:
|
|
1136
|
+
print(f" ✗ {filename}: {result.error}")
|
|
1137
|
+
error_count += 1
|
|
1138
|
+
|
|
1139
|
+
print()
|
|
1140
|
+
if error_count == 0:
|
|
1141
|
+
print(f"All {valid_count} file(s) valid.")
|
|
1142
|
+
return 0
|
|
1143
|
+
else:
|
|
1144
|
+
print(f"{error_count} file(s) with errors, {valid_count} valid.")
|
|
1145
|
+
return 1
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def cmd_assertions_list(args: argparse.Namespace) -> int:
|
|
1149
|
+
"""List available assertion files."""
|
|
1150
|
+
print("Bundled assertions:")
|
|
1151
|
+
if ASSERTIONS_BUNDLED.exists():
|
|
1152
|
+
found = False
|
|
1153
|
+
for file_path in sorted(ASSERTIONS_BUNDLED.iterdir()):
|
|
1154
|
+
if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
|
|
1155
|
+
print(f" - {file_path.name}")
|
|
1156
|
+
found = True
|
|
1157
|
+
if not found:
|
|
1158
|
+
print(" (none)")
|
|
1159
|
+
else:
|
|
1160
|
+
print(" (none)")
|
|
1161
|
+
|
|
1162
|
+
print("\nUser assertions (~/.claude/crucible/assertions/):")
|
|
1163
|
+
if ASSERTIONS_USER.exists():
|
|
1164
|
+
found = False
|
|
1165
|
+
for file_path in sorted(ASSERTIONS_USER.iterdir()):
|
|
1166
|
+
if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
|
|
1167
|
+
print(f" - {file_path.name}")
|
|
1168
|
+
found = True
|
|
1169
|
+
if not found:
|
|
1170
|
+
print(" (none)")
|
|
1171
|
+
else:
|
|
1172
|
+
print(" (none)")
|
|
1173
|
+
|
|
1174
|
+
print("\nProject assertions (.crucible/assertions/):")
|
|
1175
|
+
if ASSERTIONS_PROJECT.exists():
|
|
1176
|
+
found = False
|
|
1177
|
+
for file_path in sorted(ASSERTIONS_PROJECT.iterdir()):
|
|
1178
|
+
if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
|
|
1179
|
+
print(f" - {file_path.name}")
|
|
1180
|
+
found = True
|
|
1181
|
+
if not found:
|
|
1182
|
+
print(" (none)")
|
|
1183
|
+
else:
|
|
1184
|
+
print(" (none)")
|
|
1185
|
+
|
|
1186
|
+
return 0
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def cmd_assertions_test(args: argparse.Namespace) -> int:
|
|
1190
|
+
"""Test assertions against a file or directory."""
|
|
1191
|
+
import os
|
|
1192
|
+
|
|
1193
|
+
from crucible.enforcement.assertions import load_assertions
|
|
1194
|
+
from crucible.enforcement.patterns import run_pattern_assertions
|
|
1195
|
+
|
|
1196
|
+
target_path = Path(args.file)
|
|
1197
|
+
if not target_path.exists():
|
|
1198
|
+
print(f"Error: Path '{target_path}' not found")
|
|
1199
|
+
return 1
|
|
1200
|
+
|
|
1201
|
+
assertions, errors = load_assertions()
|
|
1202
|
+
|
|
1203
|
+
if errors:
|
|
1204
|
+
print("Assertion loading errors:")
|
|
1205
|
+
for error in errors:
|
|
1206
|
+
print(f" - {error}")
|
|
1207
|
+
print()
|
|
1208
|
+
|
|
1209
|
+
if not assertions:
|
|
1210
|
+
print("No assertions loaded.")
|
|
1211
|
+
return 0
|
|
1212
|
+
|
|
1213
|
+
# Collect files to test
|
|
1214
|
+
files_to_test: list[tuple[str, str]] = [] # (display_path, content)
|
|
1215
|
+
|
|
1216
|
+
if target_path.is_file():
|
|
1217
|
+
try:
|
|
1218
|
+
content = target_path.read_text()
|
|
1219
|
+
files_to_test.append((str(target_path), content))
|
|
1220
|
+
except UnicodeDecodeError:
|
|
1221
|
+
print(f"Error: Cannot read '{target_path}' (binary file?)")
|
|
1222
|
+
return 1
|
|
1223
|
+
else:
|
|
1224
|
+
# Directory mode
|
|
1225
|
+
for root, _, files in os.walk(target_path):
|
|
1226
|
+
for fname in files:
|
|
1227
|
+
fpath = Path(root) / fname
|
|
1228
|
+
rel_path = fpath.relative_to(target_path)
|
|
1229
|
+
try:
|
|
1230
|
+
content = fpath.read_text()
|
|
1231
|
+
files_to_test.append((str(rel_path), content))
|
|
1232
|
+
except (UnicodeDecodeError, OSError):
|
|
1233
|
+
pass # Skip binary/unreadable files
|
|
1234
|
+
|
|
1235
|
+
# Run assertions on all files
|
|
1236
|
+
all_findings = []
|
|
1237
|
+
checked = 0
|
|
1238
|
+
skipped = 0
|
|
1239
|
+
|
|
1240
|
+
for display_path, content in files_to_test:
|
|
1241
|
+
findings, c, s = run_pattern_assertions(display_path, content, assertions)
|
|
1242
|
+
all_findings.extend(findings)
|
|
1243
|
+
checked = max(checked, c)
|
|
1244
|
+
skipped = max(skipped, s)
|
|
1245
|
+
|
|
1246
|
+
# Separate suppressed and active findings
|
|
1247
|
+
active = [f for f in all_findings if not f.suppressed]
|
|
1248
|
+
suppressed = [f for f in all_findings if f.suppressed]
|
|
1249
|
+
|
|
1250
|
+
print(f"Testing {target_path}")
|
|
1251
|
+
print(f" Files scanned: {len(files_to_test)}")
|
|
1252
|
+
print(f" Assertions checked: {checked}")
|
|
1253
|
+
print(f" Assertions skipped (LLM): {skipped}")
|
|
1254
|
+
print()
|
|
1255
|
+
|
|
1256
|
+
if active:
|
|
1257
|
+
print(f"Findings ({len(active)}):")
|
|
1258
|
+
for f in active:
|
|
1259
|
+
sev = f.severity.upper()
|
|
1260
|
+
print(f" [{sev}] {f.assertion_id}: {f.location}")
|
|
1261
|
+
print(f" {f.message}")
|
|
1262
|
+
if f.match_text:
|
|
1263
|
+
print(f" Matched: {f.match_text!r}")
|
|
1264
|
+
print()
|
|
1265
|
+
|
|
1266
|
+
if suppressed:
|
|
1267
|
+
print(f"Suppressed ({len(suppressed)}):")
|
|
1268
|
+
for f in suppressed:
|
|
1269
|
+
reason = f" -- {f.suppression_reason}" if f.suppression_reason else ""
|
|
1270
|
+
print(f" {f.assertion_id}: {f.location}{reason}")
|
|
1271
|
+
|
|
1272
|
+
if not active and not suppressed:
|
|
1273
|
+
print("No matches found.")
|
|
1274
|
+
|
|
1275
|
+
return 0
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def cmd_assertions_explain(args: argparse.Namespace) -> int:
|
|
1279
|
+
"""Explain what a rule does."""
|
|
1280
|
+
from crucible.enforcement.assertions import load_assertions
|
|
1281
|
+
|
|
1282
|
+
rule_id = args.rule.lower()
|
|
1283
|
+
assertions, errors = load_assertions()
|
|
1284
|
+
|
|
1285
|
+
for assertion in assertions:
|
|
1286
|
+
if assertion.id.lower() == rule_id:
|
|
1287
|
+
print(f"Rule: {assertion.id}")
|
|
1288
|
+
print(f"Type: {assertion.type.value}")
|
|
1289
|
+
if assertion.pattern:
|
|
1290
|
+
print(f"Pattern: {assertion.pattern}")
|
|
1291
|
+
print(f"Message: {assertion.message}")
|
|
1292
|
+
print(f"Severity: {assertion.severity}")
|
|
1293
|
+
print(f"Priority: {assertion.priority.value}")
|
|
1294
|
+
if assertion.languages:
|
|
1295
|
+
print(f"Languages: {', '.join(assertion.languages)}")
|
|
1296
|
+
if assertion.applicability:
|
|
1297
|
+
if assertion.applicability.glob:
|
|
1298
|
+
print(f"Applies to: {assertion.applicability.glob}")
|
|
1299
|
+
if assertion.applicability.exclude:
|
|
1300
|
+
print(f"Excludes: {', '.join(assertion.applicability.exclude)}")
|
|
1301
|
+
if assertion.compliance:
|
|
1302
|
+
print(f"Compliance: {assertion.compliance}")
|
|
1303
|
+
return 0
|
|
1304
|
+
|
|
1305
|
+
print(f"Rule '{rule_id}' not found.")
|
|
1306
|
+
print("\nAvailable rules:")
|
|
1307
|
+
for a in assertions[:10]:
|
|
1308
|
+
print(f" - {a.id}")
|
|
1309
|
+
if len(assertions) > 10:
|
|
1310
|
+
print(f" ... and {len(assertions) - 10} more")
|
|
1311
|
+
return 1
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def cmd_assertions_debug(args: argparse.Namespace) -> int:
|
|
1315
|
+
"""Debug applicability for a rule and file."""
|
|
1316
|
+
from crucible.enforcement.assertions import load_assertions
|
|
1317
|
+
from crucible.enforcement.patterns import matches_glob, matches_language
|
|
1318
|
+
|
|
1319
|
+
rule_id = args.rule.lower()
|
|
1320
|
+
file_path = args.file
|
|
1321
|
+
|
|
1322
|
+
assertions, errors = load_assertions()
|
|
1323
|
+
|
|
1324
|
+
for assertion in assertions:
|
|
1325
|
+
if assertion.id.lower() == rule_id:
|
|
1326
|
+
print(f"Applicability check for '{assertion.id}':")
|
|
1327
|
+
print()
|
|
1328
|
+
|
|
1329
|
+
# Language check
|
|
1330
|
+
if assertion.languages:
|
|
1331
|
+
lang_match = matches_language(file_path, assertion.languages)
|
|
1332
|
+
lang_status = "MATCH" if lang_match else "NO MATCH"
|
|
1333
|
+
print(f" Languages: {', '.join(assertion.languages)}")
|
|
1334
|
+
print(f" File: {file_path} → {lang_status}")
|
|
1335
|
+
else:
|
|
1336
|
+
print(" Languages: (any)")
|
|
1337
|
+
|
|
1338
|
+
# Glob check
|
|
1339
|
+
if assertion.applicability:
|
|
1340
|
+
if assertion.applicability.glob:
|
|
1341
|
+
glob_match = matches_glob(
|
|
1342
|
+
file_path,
|
|
1343
|
+
assertion.applicability.glob,
|
|
1344
|
+
assertion.applicability.exclude,
|
|
1345
|
+
)
|
|
1346
|
+
glob_status = "MATCH" if glob_match else "NO MATCH"
|
|
1347
|
+
print(f" Glob: {assertion.applicability.glob}")
|
|
1348
|
+
if assertion.applicability.exclude:
|
|
1349
|
+
print(f" Exclude: {', '.join(assertion.applicability.exclude)}")
|
|
1350
|
+
print(f" File: {file_path} → {glob_status}")
|
|
1351
|
+
else:
|
|
1352
|
+
print(" Glob: (any)")
|
|
1353
|
+
else:
|
|
1354
|
+
print(" Applicability: (none)")
|
|
1355
|
+
|
|
1356
|
+
# Overall result
|
|
1357
|
+
print()
|
|
1358
|
+
lang_ok = matches_language(file_path, assertion.languages)
|
|
1359
|
+
glob_ok = True
|
|
1360
|
+
if assertion.applicability and assertion.applicability.glob:
|
|
1361
|
+
glob_ok = matches_glob(
|
|
1362
|
+
file_path,
|
|
1363
|
+
assertion.applicability.glob,
|
|
1364
|
+
assertion.applicability.exclude,
|
|
1365
|
+
)
|
|
1366
|
+
overall = lang_ok and glob_ok
|
|
1367
|
+
result = "APPLICABLE" if overall else "NOT APPLICABLE"
|
|
1368
|
+
print(f" Result: {result}")
|
|
1369
|
+
return 0
|
|
1370
|
+
|
|
1371
|
+
print(f"Rule '{rule_id}' not found.")
|
|
1372
|
+
return 1
|
|
1373
|
+
|
|
1374
|
+
|
|
967
1375
|
# --- Hooks commands ---
|
|
968
1376
|
|
|
969
1377
|
PRECOMMIT_HOOK_SCRIPT = """\
|
|
@@ -989,7 +1397,7 @@ def cmd_hooks_install(args: argparse.Namespace) -> int:
|
|
|
989
1397
|
|
|
990
1398
|
path = args.path or "."
|
|
991
1399
|
if not is_git_repo(path):
|
|
992
|
-
print(f"Error: {path} is not a git repository")
|
|
1400
|
+
print(f"Error: {path} is not inside a git repository")
|
|
993
1401
|
return 1
|
|
994
1402
|
|
|
995
1403
|
root_result = get_repo_root(path)
|
|
@@ -1035,7 +1443,7 @@ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
|
|
|
1035
1443
|
|
|
1036
1444
|
path = args.path or "."
|
|
1037
1445
|
if not is_git_repo(path):
|
|
1038
|
-
print(f"Error: {path} is not a git repository")
|
|
1446
|
+
print(f"Error: {path} is not inside a git repository")
|
|
1039
1447
|
return 1
|
|
1040
1448
|
|
|
1041
1449
|
root_result = get_repo_root(path)
|
|
@@ -1068,7 +1476,7 @@ def cmd_hooks_status(args: argparse.Namespace) -> int:
|
|
|
1068
1476
|
|
|
1069
1477
|
path = args.path or "."
|
|
1070
1478
|
if not is_git_repo(path):
|
|
1071
|
-
print(f"Error: {path} is not a git repository")
|
|
1479
|
+
print(f"Error: {path} is not inside a git repository")
|
|
1072
1480
|
return 1
|
|
1073
1481
|
|
|
1074
1482
|
root_result = get_repo_root(path)
|
|
@@ -1333,6 +1741,104 @@ def cmd_ci_generate(args: argparse.Namespace) -> int:
|
|
|
1333
1741
|
return 0
|
|
1334
1742
|
|
|
1335
1743
|
|
|
1744
|
+
# --- Config commands ---
|
|
1745
|
+
|
|
1746
|
+
CONFIG_DIR = Path.home() / ".config" / "crucible"
|
|
1747
|
+
SECRETS_FILE = CONFIG_DIR / "secrets.yaml"
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def cmd_config_set_api_key(args: argparse.Namespace) -> int:
|
|
1751
|
+
"""Set Anthropic API key for LLM compliance assertions."""
|
|
1752
|
+
import getpass
|
|
1753
|
+
|
|
1754
|
+
import yaml
|
|
1755
|
+
|
|
1756
|
+
print("Set Anthropic API key for LLM compliance assertions.")
|
|
1757
|
+
print("This will be stored in ~/.config/crucible/secrets.yaml")
|
|
1758
|
+
print()
|
|
1759
|
+
|
|
1760
|
+
# Prompt for key (hidden input)
|
|
1761
|
+
api_key = getpass.getpass("Enter API key (input hidden): ")
|
|
1762
|
+
|
|
1763
|
+
if not api_key:
|
|
1764
|
+
print("No key provided, aborting.")
|
|
1765
|
+
return 1
|
|
1766
|
+
|
|
1767
|
+
if not api_key.startswith("sk-ant-"):
|
|
1768
|
+
print("Warning: Key doesn't start with 'sk-ant-', are you sure this is correct?")
|
|
1769
|
+
confirm = input("Continue? [y/N]: ")
|
|
1770
|
+
if confirm.lower() != "y":
|
|
1771
|
+
print("Aborted.")
|
|
1772
|
+
return 1
|
|
1773
|
+
|
|
1774
|
+
# Create config directory
|
|
1775
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
1776
|
+
|
|
1777
|
+
# Load existing config or create new
|
|
1778
|
+
existing_config: dict = {}
|
|
1779
|
+
if SECRETS_FILE.exists():
|
|
1780
|
+
try:
|
|
1781
|
+
with open(SECRETS_FILE) as f:
|
|
1782
|
+
existing_config = yaml.safe_load(f) or {}
|
|
1783
|
+
except Exception as e:
|
|
1784
|
+
print(f"Warning: Could not read existing {SECRETS_FILE}: {e}", file=sys.stderr)
|
|
1785
|
+
|
|
1786
|
+
# Update config
|
|
1787
|
+
existing_config["anthropic_api_key"] = api_key
|
|
1788
|
+
|
|
1789
|
+
# Write with restrictive permissions
|
|
1790
|
+
SECRETS_FILE.write_text(yaml.dump(existing_config, default_flow_style=False))
|
|
1791
|
+
SECRETS_FILE.chmod(0o600)
|
|
1792
|
+
|
|
1793
|
+
print(f"API key saved to {SECRETS_FILE}")
|
|
1794
|
+
print("Permissions set to 600 (owner read/write only)")
|
|
1795
|
+
return 0
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def cmd_config_show(args: argparse.Namespace) -> int:
|
|
1799
|
+
"""Show current configuration."""
|
|
1800
|
+
import os
|
|
1801
|
+
|
|
1802
|
+
import yaml
|
|
1803
|
+
|
|
1804
|
+
print("Crucible Configuration")
|
|
1805
|
+
print("=" * 40)
|
|
1806
|
+
|
|
1807
|
+
# Check env var
|
|
1808
|
+
env_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
1809
|
+
if env_key:
|
|
1810
|
+
print(f"ANTHROPIC_API_KEY (env): {env_key[:10]}...{env_key[-4:]}")
|
|
1811
|
+
else:
|
|
1812
|
+
print("ANTHROPIC_API_KEY (env): not set")
|
|
1813
|
+
|
|
1814
|
+
# Check config file
|
|
1815
|
+
if SECRETS_FILE.exists():
|
|
1816
|
+
try:
|
|
1817
|
+
with open(SECRETS_FILE) as f:
|
|
1818
|
+
data = yaml.safe_load(f) or {}
|
|
1819
|
+
file_key = data.get("anthropic_api_key")
|
|
1820
|
+
if file_key:
|
|
1821
|
+
print(f"anthropic_api_key (file): {file_key[:10]}...{file_key[-4:]}")
|
|
1822
|
+
else:
|
|
1823
|
+
print("anthropic_api_key (file): not set")
|
|
1824
|
+
print(f"Config file: {SECRETS_FILE}")
|
|
1825
|
+
except Exception as e:
|
|
1826
|
+
print(f"Config file error: {e}")
|
|
1827
|
+
else:
|
|
1828
|
+
print(f"Config file: {SECRETS_FILE} (not found)")
|
|
1829
|
+
|
|
1830
|
+
# Show which would be used
|
|
1831
|
+
print()
|
|
1832
|
+
if env_key:
|
|
1833
|
+
print("Active: environment variable (takes precedence)")
|
|
1834
|
+
elif SECRETS_FILE.exists():
|
|
1835
|
+
print("Active: config file")
|
|
1836
|
+
else:
|
|
1837
|
+
print("Active: none (LLM assertions will fail)")
|
|
1838
|
+
|
|
1839
|
+
return 0
|
|
1840
|
+
|
|
1841
|
+
|
|
1336
1842
|
# --- Main ---
|
|
1337
1843
|
|
|
1338
1844
|
|
|
@@ -1483,7 +1989,24 @@ def main() -> int:
|
|
|
1483
1989
|
help="Suppress progress messages"
|
|
1484
1990
|
)
|
|
1485
1991
|
review_parser.add_argument(
|
|
1486
|
-
"
|
|
1992
|
+
"--token-budget", type=int,
|
|
1993
|
+
help="Token budget for LLM compliance assertions (0 = unlimited)"
|
|
1994
|
+
)
|
|
1995
|
+
review_parser.add_argument(
|
|
1996
|
+
"--compliance-model",
|
|
1997
|
+
choices=["sonnet", "opus", "haiku"],
|
|
1998
|
+
help="Model for LLM compliance assertions (default: sonnet)"
|
|
1999
|
+
)
|
|
2000
|
+
review_parser.add_argument(
|
|
2001
|
+
"--no-compliance", action="store_true",
|
|
2002
|
+
help="Disable LLM compliance assertions"
|
|
2003
|
+
)
|
|
2004
|
+
review_parser.add_argument(
|
|
2005
|
+
"--no-git", action="store_true",
|
|
2006
|
+
help="Review path directly without git awareness (static analysis only)"
|
|
2007
|
+
)
|
|
2008
|
+
review_parser.add_argument(
|
|
2009
|
+
"path", nargs="?", default=".", help="Path to review (file or directory)"
|
|
1487
2010
|
)
|
|
1488
2011
|
|
|
1489
2012
|
# === pre-commit command (direct invocation) ===
|
|
@@ -1526,6 +2049,38 @@ def main() -> int:
|
|
|
1526
2049
|
help="Project path (default: current directory)"
|
|
1527
2050
|
)
|
|
1528
2051
|
|
|
2052
|
+
# === assertions command ===
|
|
2053
|
+
assertions_parser = subparsers.add_parser("assertions", help="Manage pattern assertions")
|
|
2054
|
+
assertions_sub = assertions_parser.add_subparsers(dest="assertions_command")
|
|
2055
|
+
|
|
2056
|
+
# assertions validate
|
|
2057
|
+
assertions_sub.add_parser("validate", help="Validate assertion files")
|
|
2058
|
+
|
|
2059
|
+
# assertions list
|
|
2060
|
+
assertions_sub.add_parser("list", help="List assertion files from all sources")
|
|
2061
|
+
|
|
2062
|
+
# assertions test
|
|
2063
|
+
assertions_test_parser = assertions_sub.add_parser(
|
|
2064
|
+
"test",
|
|
2065
|
+
help="Test assertions against a file"
|
|
2066
|
+
)
|
|
2067
|
+
assertions_test_parser.add_argument("file", help="File to test")
|
|
2068
|
+
|
|
2069
|
+
# assertions explain
|
|
2070
|
+
assertions_explain_parser = assertions_sub.add_parser(
|
|
2071
|
+
"explain",
|
|
2072
|
+
help="Explain what a rule does"
|
|
2073
|
+
)
|
|
2074
|
+
assertions_explain_parser.add_argument("rule", help="Rule ID to explain")
|
|
2075
|
+
|
|
2076
|
+
# assertions debug
|
|
2077
|
+
assertions_debug_parser = assertions_sub.add_parser(
|
|
2078
|
+
"debug",
|
|
2079
|
+
help="Debug applicability for a rule and file"
|
|
2080
|
+
)
|
|
2081
|
+
assertions_debug_parser.add_argument("--rule", "-r", required=True, help="Rule ID")
|
|
2082
|
+
assertions_debug_parser.add_argument("--file", "-f", required=True, help="File to check")
|
|
2083
|
+
|
|
1529
2084
|
# === ci command ===
|
|
1530
2085
|
ci_parser = subparsers.add_parser(
|
|
1531
2086
|
"ci",
|
|
@@ -1549,6 +2104,22 @@ def main() -> int:
|
|
|
1549
2104
|
help="Fail threshold for CI (default: high)"
|
|
1550
2105
|
)
|
|
1551
2106
|
|
|
2107
|
+
# === config command ===
|
|
2108
|
+
config_parser = subparsers.add_parser("config", help="Manage crucible configuration")
|
|
2109
|
+
config_sub = config_parser.add_subparsers(dest="config_command")
|
|
2110
|
+
|
|
2111
|
+
# config set-api-key
|
|
2112
|
+
config_sub.add_parser(
|
|
2113
|
+
"set-api-key",
|
|
2114
|
+
help="Set Anthropic API key for LLM compliance assertions"
|
|
2115
|
+
)
|
|
2116
|
+
|
|
2117
|
+
# config show
|
|
2118
|
+
config_sub.add_parser(
|
|
2119
|
+
"show",
|
|
2120
|
+
help="Show current configuration"
|
|
2121
|
+
)
|
|
2122
|
+
|
|
1552
2123
|
args = parser.parse_args()
|
|
1553
2124
|
|
|
1554
2125
|
if args.command == "init":
|
|
@@ -1593,10 +2164,32 @@ def main() -> int:
|
|
|
1593
2164
|
else:
|
|
1594
2165
|
hooks_parser.print_help()
|
|
1595
2166
|
return 0
|
|
2167
|
+
elif args.command == "assertions":
|
|
2168
|
+
if args.assertions_command == "validate":
|
|
2169
|
+
return cmd_assertions_validate(args)
|
|
2170
|
+
elif args.assertions_command == "list":
|
|
2171
|
+
return cmd_assertions_list(args)
|
|
2172
|
+
elif args.assertions_command == "test":
|
|
2173
|
+
return cmd_assertions_test(args)
|
|
2174
|
+
elif args.assertions_command == "explain":
|
|
2175
|
+
return cmd_assertions_explain(args)
|
|
2176
|
+
elif args.assertions_command == "debug":
|
|
2177
|
+
return cmd_assertions_debug(args)
|
|
2178
|
+
else:
|
|
2179
|
+
assertions_parser.print_help()
|
|
2180
|
+
return 0
|
|
1596
2181
|
elif args.command == "review":
|
|
1597
2182
|
return cmd_review(args)
|
|
1598
2183
|
elif args.command == "pre-commit":
|
|
1599
2184
|
return cmd_precommit(args)
|
|
2185
|
+
elif args.command == "config":
|
|
2186
|
+
if args.config_command == "set-api-key":
|
|
2187
|
+
return cmd_config_set_api_key(args)
|
|
2188
|
+
elif args.config_command == "show":
|
|
2189
|
+
return cmd_config_show(args)
|
|
2190
|
+
else:
|
|
2191
|
+
config_parser.print_help()
|
|
2192
|
+
return 0
|
|
1600
2193
|
else:
|
|
1601
2194
|
# Default help
|
|
1602
2195
|
print("crucible - Code review orchestration\n")
|
|
@@ -1619,9 +2212,16 @@ def main() -> int:
|
|
|
1619
2212
|
print(" crucible hooks uninstall Remove pre-commit hook")
|
|
1620
2213
|
print(" crucible hooks status Show hook installation status")
|
|
1621
2214
|
print()
|
|
1622
|
-
print(" crucible
|
|
2215
|
+
print(" crucible assertions list List assertion files from all sources")
|
|
2216
|
+
print(" crucible assertions validate Validate assertion files")
|
|
2217
|
+
print(" crucible assertions test <file> Test assertions against a file")
|
|
2218
|
+
print(" crucible assertions explain <r> Explain what a rule does")
|
|
2219
|
+
print(" crucible assertions debug Debug applicability for a rule")
|
|
2220
|
+
print()
|
|
2221
|
+
print(" crucible review Review git changes or files")
|
|
1623
2222
|
print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
|
|
1624
2223
|
print(" --base <ref> Base branch or commit count")
|
|
2224
|
+
print(" --no-git Review path without git (static analysis only)")
|
|
1625
2225
|
print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
|
|
1626
2226
|
print(" --format <format> Output format: text (default) or report (markdown)")
|
|
1627
2227
|
print()
|