crucible-mcp 1.1.0__py3-none-any.whl → 1.3.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/cli.py CHANGED
@@ -8,6 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
 
10
10
  from crucible.enforcement.models import ComplianceConfig
11
+ from crucible.ignore import load_ignore_spec
11
12
 
12
13
  # Skills directories
13
14
  SKILLS_BUNDLED = Path(__file__).parent / "skills"
@@ -541,12 +542,12 @@ def _cmd_review_no_git(args: argparse.Namespace, path: str) -> int:
541
542
  if path_obj.is_file():
542
543
  files_to_analyze = [str(path_obj)]
543
544
  else:
544
- # Recursively find files, respecting common ignores
545
- ignore_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "build", "dist"}
545
+ # Load ignore patterns (.crucibleignore + defaults)
546
+ ignore_spec = load_ignore_spec(path_obj)
546
547
  for file_path in path_obj.rglob("*"):
547
548
  if file_path.is_file():
548
- # Skip ignored directories
549
- if any(ignored in file_path.parts for ignored in ignore_dirs):
549
+ rel_path = file_path.relative_to(path_obj)
550
+ if ignore_spec.is_ignored(str(rel_path), is_dir=False):
550
551
  continue
551
552
  files_to_analyze.append(str(file_path))
552
553
 
@@ -1910,6 +1911,113 @@ def cmd_config_show(args: argparse.Namespace) -> int:
1910
1911
  return 0
1911
1912
 
1912
1913
 
1914
+ # --- Ignore commands ---
1915
+
1916
+
1917
+ def cmd_ignore_show(args: argparse.Namespace) -> int:
1918
+ """Show active ignore patterns and their sources."""
1919
+ from crucible.ignore import (
1920
+ DEFAULT_PATTERNS,
1921
+ IGNORE_PROJECT,
1922
+ IGNORE_USER,
1923
+ clear_ignore_cache,
1924
+ load_ignore_spec,
1925
+ )
1926
+
1927
+ clear_ignore_cache()
1928
+ spec = load_ignore_spec()
1929
+
1930
+ print("Ignore patterns (files/directories excluded from review)")
1931
+ print()
1932
+
1933
+ # Show default patterns
1934
+ print(f"Defaults ({len(DEFAULT_PATTERNS)} patterns):")
1935
+ print(" node_modules/, .git/, __pycache__/, .venv/, dist/, build/,")
1936
+ print(" .next/, .nuxt/, package-lock.json, yarn.lock, *.log, ...")
1937
+ print()
1938
+
1939
+ # Show user patterns
1940
+ print("User (~/.claude/crucible/.crucibleignore):")
1941
+ if IGNORE_USER.exists():
1942
+ user_content = IGNORE_USER.read_text().strip()
1943
+ for line in user_content.splitlines()[:5]:
1944
+ print(f" {line}")
1945
+ lines = user_content.splitlines()
1946
+ if len(lines) > 5:
1947
+ print(f" ... and {len(lines) - 5} more")
1948
+ else:
1949
+ print(" (not set)")
1950
+ print()
1951
+
1952
+ # Show project patterns
1953
+ print("Project (.crucible/.crucibleignore):")
1954
+ if IGNORE_PROJECT.exists():
1955
+ proj_content = IGNORE_PROJECT.read_text().strip()
1956
+ for line in proj_content.splitlines()[:5]:
1957
+ print(f" {line}")
1958
+ lines = proj_content.splitlines()
1959
+ if len(lines) > 5:
1960
+ print(f" ... and {len(lines) - 5} more")
1961
+ else:
1962
+ print(" (not set)")
1963
+ print()
1964
+
1965
+ print(f"Total: {len(spec.patterns)} patterns active (source: {spec.source})")
1966
+ return 0
1967
+
1968
+
1969
+ def cmd_ignore_init(args: argparse.Namespace) -> int:
1970
+ """Initialize a project .crucibleignore file."""
1971
+ from crucible.ignore import IGNORE_PROJECT
1972
+
1973
+ # Create .crucible directory if needed
1974
+ crucible_dir = Path(".crucible")
1975
+ crucible_dir.mkdir(exist_ok=True)
1976
+
1977
+ if IGNORE_PROJECT.exists() and not args.force:
1978
+ print(f"✗ {IGNORE_PROJECT} already exists (use --force to overwrite)")
1979
+ return 1
1980
+
1981
+ template = """# Project-specific ignore patterns for Crucible
1982
+ # These are in addition to the built-in defaults (node_modules, .git, etc.)
1983
+ # Syntax: gitignore-style patterns
1984
+
1985
+ # Large generated files
1986
+ # generated/
1987
+
1988
+ # Vendor directories not in defaults
1989
+ # third_party/
1990
+
1991
+ # Project-specific build outputs
1992
+ # .output/
1993
+
1994
+ # Test fixtures that shouldn't be reviewed
1995
+ # fixtures/large-*.json
1996
+ """
1997
+ IGNORE_PROJECT.write_text(template)
1998
+ print(f"✓ Created {IGNORE_PROJECT}")
1999
+ print("\nEdit this file to add project-specific ignore patterns.")
2000
+ return 0
2001
+
2002
+
2003
+ def cmd_ignore_test(args: argparse.Namespace) -> int:
2004
+ """Test if a path would be ignored."""
2005
+ from crucible.ignore import clear_ignore_cache, load_ignore_spec
2006
+
2007
+ clear_ignore_cache()
2008
+ spec = load_ignore_spec()
2009
+
2010
+ path = args.path
2011
+ is_dir = Path(path).is_dir() if Path(path).exists() else path.endswith("/")
2012
+
2013
+ if spec.is_ignored(path, is_dir=is_dir):
2014
+ print(f"✓ {path} would be IGNORED")
2015
+ return 0
2016
+ else:
2017
+ print(f"✗ {path} would be INCLUDED in review")
2018
+ return 1
2019
+
2020
+
1913
2021
  # --- Main ---
1914
2022
 
1915
2023
 
@@ -2217,6 +2325,37 @@ def main() -> int:
2217
2325
  help="Show current configuration"
2218
2326
  )
2219
2327
 
2328
+ # === ignore command ===
2329
+ ignore_parser = subparsers.add_parser("ignore", help="Manage file ignore patterns")
2330
+ ignore_sub = ignore_parser.add_subparsers(dest="ignore_command")
2331
+
2332
+ # ignore show
2333
+ ignore_sub.add_parser(
2334
+ "show",
2335
+ help="Show active ignore patterns"
2336
+ )
2337
+
2338
+ # ignore init
2339
+ ignore_init_parser = ignore_sub.add_parser(
2340
+ "init",
2341
+ help="Create a project .crucibleignore file"
2342
+ )
2343
+ ignore_init_parser.add_argument(
2344
+ "--force", "-f",
2345
+ action="store_true",
2346
+ help="Overwrite existing file"
2347
+ )
2348
+
2349
+ # ignore test
2350
+ ignore_test_parser = ignore_sub.add_parser(
2351
+ "test",
2352
+ help="Test if a path would be ignored"
2353
+ )
2354
+ ignore_test_parser.add_argument(
2355
+ "path",
2356
+ help="Path to test"
2357
+ )
2358
+
2220
2359
  args = parser.parse_args()
2221
2360
 
2222
2361
  if args.command == "init":
@@ -2296,6 +2435,16 @@ def main() -> int:
2296
2435
  else:
2297
2436
  config_parser.print_help()
2298
2437
  return 0
2438
+ elif args.command == "ignore":
2439
+ if args.ignore_command == "show":
2440
+ return cmd_ignore_show(args)
2441
+ elif args.ignore_command == "init":
2442
+ return cmd_ignore_init(args)
2443
+ elif args.ignore_command == "test":
2444
+ return cmd_ignore_test(args)
2445
+ else:
2446
+ ignore_parser.print_help()
2447
+ return 0
2299
2448
  else:
2300
2449
  # Default help
2301
2450
  print("crucible - Code review orchestration\n")
crucible/ignore.py ADDED
@@ -0,0 +1,294 @@
1
+ """File ignore patterns for Crucible.
2
+
3
+ Supports .crucibleignore files with gitignore-style syntax.
4
+
5
+ Cascade priority (first found wins):
6
+ 1. .crucible/.crucibleignore (project)
7
+ 2. ~/.claude/crucible/.crucibleignore (user)
8
+ 3. Built-in defaults
9
+
10
+ Pattern syntax (subset of gitignore):
11
+ - Standard glob patterns: *.log, build/
12
+ - Directory markers: node_modules/ matches directory anywhere
13
+ - Negation: !important.log (include despite earlier exclusion)
14
+ - Comments: # this is a comment
15
+ - Blank lines are ignored
16
+ """
17
+
18
+ from dataclasses import dataclass, field
19
+ from fnmatch import fnmatch
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+
23
+ # Default patterns - always applied as base
24
+ DEFAULT_PATTERNS = [
25
+ # Version control
26
+ ".git/",
27
+ ".hg/",
28
+ ".svn/",
29
+ # Dependencies
30
+ "node_modules/",
31
+ "vendor/",
32
+ "bower_components/",
33
+ # Python
34
+ "__pycache__/",
35
+ "*.pyc",
36
+ "*.pyo",
37
+ ".venv/",
38
+ "venv/",
39
+ ".env/",
40
+ "env/",
41
+ ".tox/",
42
+ ".nox/",
43
+ ".pytest_cache/",
44
+ ".mypy_cache/",
45
+ ".ruff_cache/",
46
+ "*.egg-info/",
47
+ # Build outputs
48
+ "build/",
49
+ "dist/",
50
+ "out/",
51
+ "target/",
52
+ "_build/",
53
+ # IDE/Editor
54
+ ".idea/",
55
+ ".vscode/",
56
+ "*.swp",
57
+ "*.swo",
58
+ "*~",
59
+ ".DS_Store",
60
+ # Coverage/test artifacts
61
+ "coverage/",
62
+ ".coverage",
63
+ "htmlcov/",
64
+ ".nyc_output/",
65
+ # Logs
66
+ "*.log",
67
+ "logs/",
68
+ # Lock files (large, not useful to review)
69
+ "package-lock.json",
70
+ "yarn.lock",
71
+ "pnpm-lock.yaml",
72
+ "poetry.lock",
73
+ "Cargo.lock",
74
+ "composer.lock",
75
+ "Gemfile.lock",
76
+ # Other common excludes
77
+ ".next/",
78
+ ".nuxt/",
79
+ ".output/",
80
+ ".cache/",
81
+ ".parcel-cache/",
82
+ ".turbo/",
83
+ ]
84
+
85
+ # Ignore file locations (cascade priority)
86
+ IGNORE_PROJECT = Path(".crucible") / ".crucibleignore"
87
+ IGNORE_USER = Path.home() / ".claude" / "crucible" / ".crucibleignore"
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class IgnorePattern:
92
+ """A parsed ignore pattern."""
93
+
94
+ pattern: str
95
+ negated: bool = False
96
+ directory_only: bool = False
97
+
98
+ def matches(self, path: str, is_dir: bool = False) -> bool:
99
+ """Check if this pattern matches the given path.
100
+
101
+ Args:
102
+ path: Relative path to check (forward slashes)
103
+ is_dir: Whether the path is a directory
104
+
105
+ Returns:
106
+ True if pattern matches
107
+ """
108
+ # Normalize pattern for matching
109
+ pattern = self.pattern.rstrip("/")
110
+
111
+ # If pattern contains /, match against full path
112
+ if "/" in pattern:
113
+ if fnmatch(path, pattern) or fnmatch(path, f"**/{pattern}"):
114
+ # For directory-only patterns on a file, only match if path is under that dir
115
+ if self.directory_only and not is_dir:
116
+ # Check if pattern matches a parent directory
117
+ return f"{pattern}/" in path or path.startswith(f"{pattern}/")
118
+ return True
119
+ return False
120
+
121
+ # Otherwise, match against any path component
122
+ parts = path.split("/")
123
+ for i, part in enumerate(parts):
124
+ if fnmatch(part, pattern):
125
+ if self.directory_only:
126
+ # Directory-only patterns match if:
127
+ # 1. This component is not the last (so it's a directory in the path), OR
128
+ # 2. The path itself is a directory
129
+ if i < len(parts) - 1 or is_dir:
130
+ return True
131
+ else:
132
+ return True
133
+
134
+ return False
135
+
136
+
137
+ @dataclass
138
+ class IgnoreSpec:
139
+ """Collection of ignore patterns with match logic."""
140
+
141
+ patterns: list[IgnorePattern] = field(default_factory=list)
142
+ source: str = "default"
143
+
144
+ def is_ignored(self, path: str | Path, is_dir: bool = False) -> bool:
145
+ """Check if a path should be ignored.
146
+
147
+ Patterns are evaluated in order. Later patterns can override earlier ones.
148
+ Negated patterns (starting with !) un-ignore previously ignored paths.
149
+
150
+ Args:
151
+ path: Path to check (relative to project root)
152
+ is_dir: Whether the path is a directory
153
+
154
+ Returns:
155
+ True if the path should be ignored
156
+ """
157
+ if isinstance(path, Path):
158
+ path = str(path)
159
+
160
+ # Normalize path separators and strip ./ prefix (but not lone .)
161
+ path = path.replace("\\", "/")
162
+ while path.startswith("./"):
163
+ path = path[2:]
164
+
165
+ ignored = False
166
+ for pattern in self.patterns:
167
+ if pattern.matches(path, is_dir):
168
+ ignored = not pattern.negated
169
+
170
+ return ignored
171
+
172
+ def filter_paths(self, paths: list[Path], base: Path | None = None) -> list[Path]:
173
+ """Filter a list of paths, removing ignored ones.
174
+
175
+ Args:
176
+ paths: List of paths to filter
177
+ base: Base path for computing relative paths
178
+
179
+ Returns:
180
+ List of non-ignored paths
181
+ """
182
+ result = []
183
+ for path in paths:
184
+ rel_path = path.relative_to(base) if base else path
185
+ is_dir = path.is_dir()
186
+ if not self.is_ignored(str(rel_path), is_dir):
187
+ result.append(path)
188
+ return result
189
+
190
+
191
+ def parse_ignore_file(content: str) -> list[IgnorePattern]:
192
+ """Parse a .crucibleignore file into patterns.
193
+
194
+ Args:
195
+ content: File content
196
+
197
+ Returns:
198
+ List of parsed patterns
199
+ """
200
+ patterns = []
201
+
202
+ for line in content.splitlines():
203
+ # Strip whitespace
204
+ line = line.strip()
205
+
206
+ # Skip empty lines and comments
207
+ if not line or line.startswith("#"):
208
+ continue
209
+
210
+ # Check for negation
211
+ negated = False
212
+ if line.startswith("!"):
213
+ negated = True
214
+ line = line[1:]
215
+
216
+ # Check for directory-only marker
217
+ directory_only = line.endswith("/")
218
+
219
+ patterns.append(
220
+ IgnorePattern(
221
+ pattern=line,
222
+ negated=negated,
223
+ directory_only=directory_only,
224
+ )
225
+ )
226
+
227
+ return patterns
228
+
229
+
230
+ def _load_ignore_file(path: Path) -> list[IgnorePattern]:
231
+ """Load patterns from an ignore file."""
232
+ try:
233
+ content = path.read_text()
234
+ return parse_ignore_file(content)
235
+ except OSError:
236
+ return []
237
+
238
+
239
+ @lru_cache(maxsize=1)
240
+ def _get_default_patterns() -> list[IgnorePattern]:
241
+ """Get default patterns (cached)."""
242
+ return parse_ignore_file("\n".join(DEFAULT_PATTERNS))
243
+
244
+
245
+ def load_ignore_spec(project_root: Path | None = None) -> IgnoreSpec:
246
+ """Load ignore specification with cascade resolution.
247
+
248
+ Args:
249
+ project_root: Project root directory (defaults to cwd)
250
+
251
+ Returns:
252
+ IgnoreSpec with all applicable patterns
253
+ """
254
+ if project_root is None:
255
+ project_root = Path.cwd()
256
+
257
+ patterns: list[IgnorePattern] = []
258
+ source = "default"
259
+
260
+ # Start with defaults
261
+ patterns.extend(_get_default_patterns())
262
+
263
+ # Layer user patterns (if exists)
264
+ if IGNORE_USER.exists():
265
+ patterns.extend(_load_ignore_file(IGNORE_USER))
266
+ source = "user"
267
+
268
+ # Layer project patterns (highest priority)
269
+ project_ignore = project_root / IGNORE_PROJECT
270
+ if project_ignore.exists():
271
+ patterns.extend(_load_ignore_file(project_ignore))
272
+ source = "project"
273
+
274
+ return IgnoreSpec(patterns=patterns, source=source)
275
+
276
+
277
+ def should_ignore(path: str | Path, project_root: Path | None = None) -> bool:
278
+ """Convenience function to check if a path should be ignored.
279
+
280
+ Args:
281
+ path: Path to check
282
+ project_root: Project root for loading ignore spec
283
+
284
+ Returns:
285
+ True if path should be ignored
286
+ """
287
+ spec = load_ignore_spec(project_root)
288
+ is_dir = Path(path).is_dir() if isinstance(path, str) else path.is_dir()
289
+ return spec.is_ignored(path, is_dir)
290
+
291
+
292
+ def clear_ignore_cache() -> None:
293
+ """Clear the ignore pattern cache. Useful for testing."""
294
+ _get_default_patterns.cache_clear()
@@ -247,14 +247,17 @@ def get_custom_knowledge_files() -> set[str]:
247
247
 
248
248
 
249
249
  def load_all_knowledge(
250
- include_bundled: bool = False,
250
+ include_bundled: bool = True,
251
251
  filenames: set[str] | None = None,
252
252
  ) -> tuple[list[str], str]:
253
253
  """Load multiple knowledge files.
254
254
 
255
+ Knowledge follows cascade priority: project > user > bundled.
256
+ Project/user files override bundled files with the same name.
257
+
255
258
  Args:
256
- include_bundled: If True, include bundled knowledge files
257
- filenames: Specific files to load (if None, loads based on include_bundled)
259
+ include_bundled: If True, include bundled knowledge files (default: True)
260
+ filenames: Specific files to load (if None, loads all from cascade)
258
261
 
259
262
  Returns:
260
263
  Tuple of (list of loaded filenames, combined content)
crucible/models.py CHANGED
@@ -61,16 +61,3 @@ DOMAIN_HEURISTICS: dict[Domain, dict[str, list[str]]] = {
61
61
  }
62
62
 
63
63
 
64
- @dataclass(frozen=True)
65
- class FullReviewResult:
66
- """Result from full_review tool."""
67
-
68
- domains_detected: tuple[str, ...]
69
- severity_summary: dict[str, int]
70
- findings: tuple[ToolFinding, ...]
71
- applicable_skills: tuple[str, ...]
72
- skill_triggers_matched: dict[str, tuple[str, ...]]
73
- principles_loaded: tuple[str, ...]
74
- principles_content: str
75
- sage_knowledge: str | None = None
76
- sage_query_used: str | None = None
crucible/review/core.py CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from collections import Counter
6
7
  from pathlib import Path
7
8
 
8
9
  from crucible.enforcement.models import BudgetState, ComplianceConfig
10
+ from crucible.ignore import load_ignore_spec
9
11
  from crucible.models import Domain, Severity, ToolFinding
10
12
  from crucible.tools.delegation import (
11
13
  delegate_bandit,
@@ -64,17 +66,16 @@ def detect_domain(path: str) -> tuple[Domain, list[str]]:
64
66
  # Scan files in directory (up to 1000 to avoid huge repos)
65
67
  file_count = 0
66
68
  max_files = 1000
67
- skip_dirs = {"node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
69
+ ignore_spec = load_ignore_spec(p)
68
70
 
69
71
  for file_path in p.rglob("*"):
70
72
  if file_count >= max_files:
71
73
  break
72
74
  if not file_path.is_file():
73
75
  continue
74
- # Skip hidden files and common non-code directories
75
- if any(part.startswith(".") for part in file_path.parts):
76
- continue
77
- if any(part in skip_dirs for part in file_path.parts):
76
+ # Use .crucibleignore patterns
77
+ rel_path = file_path.relative_to(p)
78
+ if ignore_spec.is_ignored(str(rel_path), is_dir=False):
78
79
  continue
79
80
 
80
81
  domain, tags = detect_domain_for_file(str(file_path))
@@ -189,6 +190,46 @@ def deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
189
190
  return list(seen.values())
190
191
 
191
192
 
193
+ def filter_ignored_findings(
194
+ findings: list[ToolFinding],
195
+ base_path: str | Path | None = None,
196
+ ) -> list[ToolFinding]:
197
+ """Filter out findings from ignored paths.
198
+
199
+ Args:
200
+ findings: All findings from analysis
201
+ base_path: Base path for loading ignore spec (defaults to cwd)
202
+
203
+ Returns:
204
+ Findings that are not in ignored paths
205
+ """
206
+ if base_path is None:
207
+ base_path = Path.cwd()
208
+ elif isinstance(base_path, str):
209
+ base_path = Path(base_path)
210
+
211
+ spec = load_ignore_spec(base_path)
212
+ filtered: list[ToolFinding] = []
213
+
214
+ for finding in findings:
215
+ # Parse location: "path:line" or "path:line:col" or just "path"
216
+ parts = finding.location.split(":")
217
+ file_path = parts[0]
218
+
219
+ # Make path relative if it's absolute
220
+ try:
221
+ path_obj = Path(file_path)
222
+ if path_obj.is_absolute():
223
+ file_path = str(path_obj.relative_to(base_path))
224
+ except ValueError:
225
+ pass # Not under base_path, keep as-is
226
+
227
+ if not spec.is_ignored(file_path, is_dir=False):
228
+ filtered.append(finding)
229
+
230
+ return filtered
231
+
232
+
192
233
  def filter_findings_to_changes(
193
234
  findings: list[ToolFinding],
194
235
  context: GitContext,
@@ -330,7 +371,6 @@ def run_enforcement(
330
371
  Returns:
331
372
  (enforcement_findings, errors, assertions_checked, assertions_skipped, budget_state)
332
373
  """
333
- import os
334
374
 
335
375
  from crucible.enforcement.assertions import load_assertions
336
376
  from crucible.enforcement.compliance import run_llm_assertions, run_llm_assertions_batch
@@ -421,10 +461,23 @@ def run_enforcement(
421
461
 
422
462
  elif os.path.isdir(path):
423
463
  # Directory - collect all files for batch processing
424
- for root, _, files in os.walk(path):
464
+ ignore_spec = load_ignore_spec(Path(path))
465
+ for root, dirs, files in os.walk(path):
466
+ # Filter out ignored directories in-place (modifies os.walk behavior)
467
+ rel_root = os.path.relpath(root, path)
468
+ dirs[:] = [
469
+ d for d in dirs
470
+ if not ignore_spec.is_ignored(
471
+ os.path.join(rel_root, d) if rel_root != "." else d,
472
+ is_dir=True
473
+ )
474
+ ]
425
475
  for fname in files:
426
476
  fpath = os.path.join(root, fname)
427
477
  rel_path = os.path.relpath(fpath, path)
478
+ # Skip ignored files
479
+ if ignore_spec.is_ignored(rel_path, is_dir=False):
480
+ continue
428
481
  try:
429
482
  with open(fpath) as f:
430
483
  file_content = f.read()