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.
@@ -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
+ ]