crucible-mcp 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crucible/__init__.py +1 -1
- crucible/cli.py +410 -158
- crucible/enforcement/__init__.py +40 -0
- crucible/enforcement/assertions.py +276 -0
- crucible/enforcement/models.py +107 -0
- crucible/enforcement/patterns.py +337 -0
- crucible/review/__init__.py +23 -0
- crucible/review/core.py +383 -0
- crucible/server.py +508 -273
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/METADATA +27 -7
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/RECORD +14 -8
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.2.0.dist-info → crucible_mcp-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Pattern matching for enforcement assertions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from crucible.enforcement.models import (
|
|
7
|
+
Assertion,
|
|
8
|
+
AssertionType,
|
|
9
|
+
EnforcementFinding,
|
|
10
|
+
PatternMatch,
|
|
11
|
+
Suppression,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Language to file extension mapping
|
|
15
|
+
LANGUAGE_EXTENSIONS: dict[str, tuple[str, ...]] = {
|
|
16
|
+
"python": (".py", ".pyw"),
|
|
17
|
+
"typescript": (".ts", ".tsx"),
|
|
18
|
+
"javascript": (".js", ".jsx", ".mjs", ".cjs"),
|
|
19
|
+
"solidity": (".sol",),
|
|
20
|
+
"rust": (".rs",),
|
|
21
|
+
"go": (".go",),
|
|
22
|
+
"java": (".java",),
|
|
23
|
+
"c": (".c", ".h"),
|
|
24
|
+
"cpp": (".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx"),
|
|
25
|
+
"ruby": (".rb",),
|
|
26
|
+
"php": (".php",),
|
|
27
|
+
"swift": (".swift",),
|
|
28
|
+
"kotlin": (".kt", ".kts"),
|
|
29
|
+
"scala": (".scala",),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Suppression patterns
|
|
33
|
+
# Match: // crucible-ignore: rule1, rule2 -- reason
|
|
34
|
+
SUPPRESSION_PATTERN = re.compile(
|
|
35
|
+
r"(?://|#|/\*)\s*crucible-ignore(?:-next-line)?:\s*([a-z0-9_,\s-]+?)(?:\s+--\s+(.+?))?(?:\s*\*/)?$",
|
|
36
|
+
re.IGNORECASE | re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_suppressions(content: str) -> list[Suppression]:
|
|
41
|
+
"""Parse crucible-ignore comments from file content.
|
|
42
|
+
|
|
43
|
+
Supports:
|
|
44
|
+
- // crucible-ignore: rule-id
|
|
45
|
+
- // crucible-ignore: rule-id, other-rule
|
|
46
|
+
- // crucible-ignore-next-line: rule-id
|
|
47
|
+
- # crucible-ignore: rule-id (Python/shell)
|
|
48
|
+
- /* crucible-ignore: rule-id */ (block comments)
|
|
49
|
+
- // crucible-ignore: rule-id -- reason
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of Suppression objects
|
|
53
|
+
"""
|
|
54
|
+
suppressions: list[Suppression] = []
|
|
55
|
+
lines = content.split("\n")
|
|
56
|
+
|
|
57
|
+
for i, line in enumerate(lines):
|
|
58
|
+
match = SUPPRESSION_PATTERN.search(line)
|
|
59
|
+
if match:
|
|
60
|
+
rule_ids_str = match.group(1)
|
|
61
|
+
reason = match.group(2)
|
|
62
|
+
|
|
63
|
+
rule_ids = tuple(r.strip().lower() for r in rule_ids_str.split(",") if r.strip())
|
|
64
|
+
applies_to_next = "next-line" in line.lower()
|
|
65
|
+
|
|
66
|
+
# Line numbers are 1-indexed
|
|
67
|
+
line_num = i + 1
|
|
68
|
+
|
|
69
|
+
suppressions.append(
|
|
70
|
+
Suppression(
|
|
71
|
+
line=line_num,
|
|
72
|
+
rule_ids=rule_ids,
|
|
73
|
+
reason=reason.strip() if reason else None,
|
|
74
|
+
applies_to_next_line=applies_to_next,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return suppressions
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_suppressed(line_num: int, rule_id: str, suppressions: list[Suppression]) -> Suppression | None:
|
|
82
|
+
"""Check if a rule is suppressed at a given line.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
line_num: 1-indexed line number
|
|
86
|
+
rule_id: Rule ID to check
|
|
87
|
+
suppressions: List of parsed suppressions
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The Suppression if suppressed, None otherwise
|
|
91
|
+
"""
|
|
92
|
+
rule_id_lower = rule_id.lower()
|
|
93
|
+
|
|
94
|
+
for suppression in suppressions:
|
|
95
|
+
# Check if this rule is in the suppression list
|
|
96
|
+
if rule_id_lower not in suppression.rule_ids:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if suppression.applies_to_next_line:
|
|
100
|
+
# Suppresses the line immediately after the comment
|
|
101
|
+
if line_num == suppression.line + 1:
|
|
102
|
+
return suppression
|
|
103
|
+
else:
|
|
104
|
+
# Suppresses the same line as the comment
|
|
105
|
+
if line_num == suppression.line:
|
|
106
|
+
return suppression
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def matches_language(file_path: str, languages: tuple[str, ...]) -> bool:
|
|
112
|
+
"""Check if a file matches the specified languages.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file_path: Path to check
|
|
116
|
+
languages: Tuple of language names (empty means match all)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if file matches or languages is empty
|
|
120
|
+
"""
|
|
121
|
+
if not languages:
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
ext = Path(file_path).suffix.lower()
|
|
125
|
+
|
|
126
|
+
for language in languages:
|
|
127
|
+
language_lower = language.lower()
|
|
128
|
+
if language_lower in LANGUAGE_EXTENSIONS:
|
|
129
|
+
if ext in LANGUAGE_EXTENSIONS[language_lower]:
|
|
130
|
+
return True
|
|
131
|
+
# Also allow direct extension matching (e.g., "ts" for .ts)
|
|
132
|
+
elif ext == f".{language_lower}":
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
139
|
+
"""Convert a glob pattern to a regex.
|
|
140
|
+
|
|
141
|
+
Supports:
|
|
142
|
+
- * matches any characters except /
|
|
143
|
+
- ** matches any characters including /
|
|
144
|
+
- ? matches single character
|
|
145
|
+
- [abc] character classes
|
|
146
|
+
"""
|
|
147
|
+
regex_parts = []
|
|
148
|
+
i = 0
|
|
149
|
+
n = len(pattern)
|
|
150
|
+
|
|
151
|
+
while i < n:
|
|
152
|
+
c = pattern[i]
|
|
153
|
+
if c == "*":
|
|
154
|
+
if i + 1 < n and pattern[i + 1] == "*":
|
|
155
|
+
# ** matches anything including /
|
|
156
|
+
regex_parts.append(".*")
|
|
157
|
+
i += 2
|
|
158
|
+
# Skip trailing / after **
|
|
159
|
+
if i < n and pattern[i] == "/":
|
|
160
|
+
i += 1
|
|
161
|
+
else:
|
|
162
|
+
# * matches anything except /
|
|
163
|
+
regex_parts.append("[^/]*")
|
|
164
|
+
i += 1
|
|
165
|
+
elif c == "?":
|
|
166
|
+
regex_parts.append("[^/]")
|
|
167
|
+
i += 1
|
|
168
|
+
elif c == "[":
|
|
169
|
+
# Character class - find closing ]
|
|
170
|
+
j = i + 1
|
|
171
|
+
if j < n and pattern[j] == "!":
|
|
172
|
+
j += 1
|
|
173
|
+
if j < n and pattern[j] == "]":
|
|
174
|
+
j += 1
|
|
175
|
+
while j < n and pattern[j] != "]":
|
|
176
|
+
j += 1
|
|
177
|
+
if j >= n:
|
|
178
|
+
regex_parts.append(re.escape(c))
|
|
179
|
+
else:
|
|
180
|
+
char_class = pattern[i : j + 1]
|
|
181
|
+
if char_class[1] == "!":
|
|
182
|
+
char_class = "[^" + char_class[2:]
|
|
183
|
+
regex_parts.append(char_class)
|
|
184
|
+
i = j + 1
|
|
185
|
+
continue
|
|
186
|
+
i += 1
|
|
187
|
+
else:
|
|
188
|
+
regex_parts.append(re.escape(c))
|
|
189
|
+
i += 1
|
|
190
|
+
|
|
191
|
+
return re.compile("^" + "".join(regex_parts) + "$")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _glob_match(file_path: str, pattern: str) -> bool:
|
|
195
|
+
"""Match a file path against a glob pattern supporting **.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
file_path: Path to check (forward slashes)
|
|
199
|
+
pattern: Glob pattern (supports **)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True if file matches pattern
|
|
203
|
+
"""
|
|
204
|
+
regex = _glob_to_regex(pattern)
|
|
205
|
+
return regex.match(file_path) is not None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def matches_glob(file_path: str, glob_pattern: str | None, exclude: tuple[str, ...] = ()) -> bool:
|
|
209
|
+
"""Check if a file matches glob pattern and is not excluded.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
file_path: Path to check
|
|
213
|
+
glob_pattern: Glob pattern (None matches all)
|
|
214
|
+
exclude: Patterns to exclude
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True if file matches glob and is not excluded
|
|
218
|
+
"""
|
|
219
|
+
# Normalize path separators
|
|
220
|
+
file_path = file_path.replace("\\", "/")
|
|
221
|
+
|
|
222
|
+
# Check excludes first
|
|
223
|
+
for excl in exclude:
|
|
224
|
+
if _glob_match(file_path, excl):
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
# If no glob pattern, match all (that weren't excluded)
|
|
228
|
+
if glob_pattern is None:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
return _glob_match(file_path, glob_pattern)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def find_pattern_matches(
|
|
235
|
+
file_path: str,
|
|
236
|
+
content: str,
|
|
237
|
+
pattern: str,
|
|
238
|
+
) -> list[PatternMatch]:
|
|
239
|
+
"""Find all matches of a regex pattern in file content.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
file_path: Path to the file (for location reporting)
|
|
243
|
+
content: File content to search
|
|
244
|
+
pattern: Regex pattern to match
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of PatternMatch objects
|
|
248
|
+
"""
|
|
249
|
+
matches: list[PatternMatch] = []
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
regex = re.compile(pattern)
|
|
253
|
+
except re.error:
|
|
254
|
+
return matches # Invalid pattern, return empty
|
|
255
|
+
|
|
256
|
+
lines = content.split("\n")
|
|
257
|
+
for line_num, line in enumerate(lines, start=1):
|
|
258
|
+
for match in regex.finditer(line):
|
|
259
|
+
matches.append(
|
|
260
|
+
PatternMatch(
|
|
261
|
+
assertion_id="", # Set by caller
|
|
262
|
+
line=line_num,
|
|
263
|
+
column=match.start() + 1, # 1-indexed
|
|
264
|
+
match_text=match.group(),
|
|
265
|
+
file_path=file_path,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return matches
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run_pattern_assertions(
|
|
273
|
+
file_path: str,
|
|
274
|
+
content: str,
|
|
275
|
+
assertions: list[Assertion],
|
|
276
|
+
) -> tuple[list[EnforcementFinding], int, int]:
|
|
277
|
+
"""Run pattern assertions against a file.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
file_path: Path to the file
|
|
281
|
+
content: File content
|
|
282
|
+
assertions: List of assertions to check
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Tuple of (findings, checked_count, skipped_count)
|
|
286
|
+
"""
|
|
287
|
+
findings: list[EnforcementFinding] = []
|
|
288
|
+
checked = 0
|
|
289
|
+
skipped = 0
|
|
290
|
+
|
|
291
|
+
# Parse suppressions once
|
|
292
|
+
suppressions = parse_suppressions(content)
|
|
293
|
+
|
|
294
|
+
for assertion in assertions:
|
|
295
|
+
# Skip non-pattern assertions
|
|
296
|
+
if assertion.type != AssertionType.PATTERN:
|
|
297
|
+
skipped += 1
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# Check language applicability
|
|
301
|
+
if not matches_language(file_path, assertion.languages):
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Check glob applicability
|
|
305
|
+
if assertion.applicability and not matches_glob(
|
|
306
|
+
file_path,
|
|
307
|
+
assertion.applicability.glob,
|
|
308
|
+
assertion.applicability.exclude,
|
|
309
|
+
):
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
checked += 1
|
|
313
|
+
|
|
314
|
+
# Find matches
|
|
315
|
+
if assertion.pattern is None:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
matches = find_pattern_matches(file_path, content, assertion.pattern)
|
|
319
|
+
|
|
320
|
+
for match in matches:
|
|
321
|
+
# Check for suppression
|
|
322
|
+
suppression = is_suppressed(match.line, assertion.id, suppressions)
|
|
323
|
+
|
|
324
|
+
findings.append(
|
|
325
|
+
EnforcementFinding(
|
|
326
|
+
assertion_id=assertion.id,
|
|
327
|
+
message=assertion.message,
|
|
328
|
+
severity=assertion.severity,
|
|
329
|
+
priority=assertion.priority,
|
|
330
|
+
location=f"{file_path}:{match.line}:{match.column}",
|
|
331
|
+
match_text=match.match_text,
|
|
332
|
+
suppressed=suppression is not None,
|
|
333
|
+
suppression_reason=suppression.reason if suppression else None,
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return findings, checked, skipped
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Core review functionality shared between CLI and MCP."""
|
|
2
|
+
|
|
3
|
+
from crucible.review.core import (
|
|
4
|
+
compute_severity_counts,
|
|
5
|
+
deduplicate_findings,
|
|
6
|
+
detect_domain,
|
|
7
|
+
filter_findings_to_changes,
|
|
8
|
+
get_tools_for_domain,
|
|
9
|
+
load_skills_and_knowledge,
|
|
10
|
+
run_enforcement,
|
|
11
|
+
run_static_analysis,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"compute_severity_counts",
|
|
16
|
+
"deduplicate_findings",
|
|
17
|
+
"detect_domain",
|
|
18
|
+
"filter_findings_to_changes",
|
|
19
|
+
"get_tools_for_domain",
|
|
20
|
+
"load_skills_and_knowledge",
|
|
21
|
+
"run_enforcement",
|
|
22
|
+
"run_static_analysis",
|
|
23
|
+
]
|