crucible-mcp 0.4.0__tar.gz → 0.5.0__tar.gz
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_mcp-0.4.0 → crucible_mcp-0.5.0}/PKG-INFO +2 -1
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/pyproject.toml +2 -1
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/cli.py +425 -12
- crucible_mcp-0.5.0/src/crucible/enforcement/budget.py +179 -0
- crucible_mcp-0.5.0/src/crucible/enforcement/compliance.py +486 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/models.py +71 -1
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/review/core.py +78 -7
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/server.py +81 -14
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/tools/git.py +17 -4
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/PKG-INFO +2 -1
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/SOURCES.txt +3 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/requires.txt +1 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_cli.py +1 -1
- crucible_mcp-0.5.0/tests/test_compliance.py +617 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_git.py +20 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_hooks_cli.py +1 -1
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/README.md +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/setup.cfg +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/domain/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/domain/detection.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/assertions.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/patterns.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/errors.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/hooks/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/hooks/precommit.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/knowledge/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/knowledge/loader.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/models.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/review/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/skills/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/skills/loader.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/synthesis/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/tools/__init__.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/tools/delegation.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/dependency_links.txt +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/entry_points.txt +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/top_level.txt +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_detection.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_enforcement.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_full_review.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_integration.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_knowledge.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_precommit.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_server.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_skills.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_skills_loader.py +0 -0
- {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crucible-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
|
|
5
5
|
Author: be.nvy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -9,6 +9,7 @@ Requires-Python: >=3.11
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: mcp>=1.0.0
|
|
11
11
|
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: anthropic>=0.40.0
|
|
12
13
|
Provides-Extra: dev
|
|
13
14
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
14
15
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "crucible-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Code review MCP server for Claude. Not affiliated with Atlassian."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -11,6 +11,7 @@ keywords = ["mcp", "code-review", "static-analysis", "claude"]
|
|
|
11
11
|
dependencies = [
|
|
12
12
|
"mcp>=1.0.0",
|
|
13
13
|
"pyyaml>=6.0",
|
|
14
|
+
"anthropic>=0.40.0",
|
|
14
15
|
]
|
|
15
16
|
|
|
16
17
|
[project.optional-dependencies]
|
|
@@ -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,22 +433,217 @@ 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
|
|
@@ -450,6 +655,14 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
450
655
|
get_tools_for_domain,
|
|
451
656
|
run_static_analysis,
|
|
452
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
|
|
453
666
|
from crucible.tools.git import (
|
|
454
667
|
get_branch_diff,
|
|
455
668
|
get_recent_commits,
|
|
@@ -459,12 +672,12 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
459
672
|
is_git_repo,
|
|
460
673
|
)
|
|
461
674
|
|
|
462
|
-
path = args.path or "."
|
|
463
675
|
mode = args.mode
|
|
464
676
|
|
|
465
677
|
# Validate git repo
|
|
466
678
|
if not is_git_repo(path):
|
|
467
|
-
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")
|
|
468
681
|
return 1
|
|
469
682
|
|
|
470
683
|
root_result = get_repo_root(path)
|
|
@@ -623,6 +836,28 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
623
836
|
if result.is_ok:
|
|
624
837
|
knowledge_content[filename] = result.value
|
|
625
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
|
+
|
|
626
861
|
# Compute severity summary
|
|
627
862
|
severity_counts = compute_severity_counts(filtered_findings)
|
|
628
863
|
|
|
@@ -673,6 +908,22 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
673
908
|
}
|
|
674
909
|
for f in filtered_findings
|
|
675
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
|
+
},
|
|
676
927
|
"severity_counts": severity_counts,
|
|
677
928
|
"threshold": threshold_used,
|
|
678
929
|
"errors": tool_errors,
|
|
@@ -815,6 +1066,28 @@ def cmd_review(args: argparse.Namespace) -> int:
|
|
|
815
1066
|
else:
|
|
816
1067
|
print("\nNo issues found in changed code.")
|
|
817
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
|
+
|
|
818
1091
|
if effective_threshold:
|
|
819
1092
|
status = "PASSED" if passed else "FAILED"
|
|
820
1093
|
print(f"\n{'='*60}")
|
|
@@ -1124,7 +1397,7 @@ def cmd_hooks_install(args: argparse.Namespace) -> int:
|
|
|
1124
1397
|
|
|
1125
1398
|
path = args.path or "."
|
|
1126
1399
|
if not is_git_repo(path):
|
|
1127
|
-
print(f"Error: {path} is not a git repository")
|
|
1400
|
+
print(f"Error: {path} is not inside a git repository")
|
|
1128
1401
|
return 1
|
|
1129
1402
|
|
|
1130
1403
|
root_result = get_repo_root(path)
|
|
@@ -1170,7 +1443,7 @@ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
|
|
|
1170
1443
|
|
|
1171
1444
|
path = args.path or "."
|
|
1172
1445
|
if not is_git_repo(path):
|
|
1173
|
-
print(f"Error: {path} is not a git repository")
|
|
1446
|
+
print(f"Error: {path} is not inside a git repository")
|
|
1174
1447
|
return 1
|
|
1175
1448
|
|
|
1176
1449
|
root_result = get_repo_root(path)
|
|
@@ -1203,7 +1476,7 @@ def cmd_hooks_status(args: argparse.Namespace) -> int:
|
|
|
1203
1476
|
|
|
1204
1477
|
path = args.path or "."
|
|
1205
1478
|
if not is_git_repo(path):
|
|
1206
|
-
print(f"Error: {path} is not a git repository")
|
|
1479
|
+
print(f"Error: {path} is not inside a git repository")
|
|
1207
1480
|
return 1
|
|
1208
1481
|
|
|
1209
1482
|
root_result = get_repo_root(path)
|
|
@@ -1468,6 +1741,104 @@ def cmd_ci_generate(args: argparse.Namespace) -> int:
|
|
|
1468
1741
|
return 0
|
|
1469
1742
|
|
|
1470
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
|
+
|
|
1471
1842
|
# --- Main ---
|
|
1472
1843
|
|
|
1473
1844
|
|
|
@@ -1618,7 +1989,24 @@ def main() -> int:
|
|
|
1618
1989
|
help="Suppress progress messages"
|
|
1619
1990
|
)
|
|
1620
1991
|
review_parser.add_argument(
|
|
1621
|
-
"
|
|
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)"
|
|
1622
2010
|
)
|
|
1623
2011
|
|
|
1624
2012
|
# === pre-commit command (direct invocation) ===
|
|
@@ -1716,6 +2104,22 @@ def main() -> int:
|
|
|
1716
2104
|
help="Fail threshold for CI (default: high)"
|
|
1717
2105
|
)
|
|
1718
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
|
+
|
|
1719
2123
|
args = parser.parse_args()
|
|
1720
2124
|
|
|
1721
2125
|
if args.command == "init":
|
|
@@ -1778,6 +2182,14 @@ def main() -> int:
|
|
|
1778
2182
|
return cmd_review(args)
|
|
1779
2183
|
elif args.command == "pre-commit":
|
|
1780
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
|
|
1781
2193
|
else:
|
|
1782
2194
|
# Default help
|
|
1783
2195
|
print("crucible - Code review orchestration\n")
|
|
@@ -1806,9 +2218,10 @@ def main() -> int:
|
|
|
1806
2218
|
print(" crucible assertions explain <r> Explain what a rule does")
|
|
1807
2219
|
print(" crucible assertions debug Debug applicability for a rule")
|
|
1808
2220
|
print()
|
|
1809
|
-
print(" crucible review Review git changes")
|
|
2221
|
+
print(" crucible review Review git changes or files")
|
|
1810
2222
|
print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
|
|
1811
2223
|
print(" --base <ref> Base branch or commit count")
|
|
2224
|
+
print(" --no-git Review path without git (static analysis only)")
|
|
1812
2225
|
print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
|
|
1813
2226
|
print(" --format <format> Output format: text (default) or report (markdown)")
|
|
1814
2227
|
print()
|