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 +153 -4
- crucible/ignore.py +294 -0
- crucible/knowledge/loader.py +6 -3
- crucible/models.py +0 -13
- crucible/review/core.py +60 -7
- crucible/server.py +20 -528
- crucible/skills/code-hygiene/SKILL.md +83 -0
- {crucible_mcp-1.1.0.dist-info → crucible_mcp-1.3.0.dist-info}/METADATA +15 -1
- {crucible_mcp-1.1.0.dist-info → crucible_mcp-1.3.0.dist-info}/RECORD +12 -10
- {crucible_mcp-1.1.0.dist-info → crucible_mcp-1.3.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-1.1.0.dist-info → crucible_mcp-1.3.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-1.1.0.dist-info → crucible_mcp-1.3.0.dist-info}/top_level.txt +0 -0
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
|
-
#
|
|
545
|
-
|
|
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
|
-
|
|
549
|
-
if
|
|
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()
|
crucible/knowledge/loader.py
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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()
|