crucible-mcp 1.2.0__tar.gz → 1.3.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-1.2.0 → crucible_mcp-1.3.0}/PKG-INFO +1 -1
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/pyproject.toml +1 -1
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/cli.py +153 -4
- crucible_mcp-1.3.0/src/crucible/ignore.py +294 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/review/core.py +60 -7
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/server.py +13 -0
- crucible_mcp-1.3.0/src/crucible/skills/code-hygiene/SKILL.md +83 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/PKG-INFO +1 -1
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/SOURCES.txt +3 -0
- crucible_mcp-1.3.0/tests/test_ignore.py +194 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_skills.py +5 -4
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/README.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/setup.cfg +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/domain/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/domain/detection.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/assertions.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/budget.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/error-handling.yaml +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/security.yaml +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/smart-contract.yaml +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/compliance.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/models.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/patterns.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/errors.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/hooks/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/hooks/claudecode.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/hooks/precommit.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/loader.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/API_DESIGN.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/COMMITS.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/DATABASE.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/DOCUMENTATION.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/ERROR_HANDLING.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/FP.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/GITIGNORE.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/OBSERVABILITY.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/PRECOMMIT.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SECURITY.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SMART_CONTRACT.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SYSTEM_DESIGN.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/TESTING.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/TYPE_SAFETY.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/models.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/review/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/accessibility-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/backend-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/customer-success/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/data-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/devops-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/fde-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/formal-verification/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/gas-optimizer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/incident-responder/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/loader.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/mev-researcher/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/mobile-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/performance-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/product-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/protocol-architect/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/security-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/tech-lead/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/uiux-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/web3-engineer/SKILL.md +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/synthesis/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/tools/__init__.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/tools/delegation.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/tools/git.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/dependency_links.txt +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/entry_points.txt +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/requires.txt +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/top_level.txt +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_cli.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_compliance.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_detection.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_enforcement.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_full_review.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_git.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_hooks_cli.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_integration.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_knowledge.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_precommit.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_server.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_skills_loader.py +0 -0
- {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_tools.py +0 -0
|
@@ -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")
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -18,6 +18,7 @@ from crucible.review.core import (
|
|
|
18
18
|
deduplicate_findings,
|
|
19
19
|
detect_domain,
|
|
20
20
|
filter_findings_to_changes,
|
|
21
|
+
filter_ignored_findings,
|
|
21
22
|
load_skills_and_knowledge,
|
|
22
23
|
run_enforcement,
|
|
23
24
|
run_static_analysis,
|
|
@@ -509,6 +510,14 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
509
510
|
if not changed_files:
|
|
510
511
|
return [TextContent(type="text", text="No files to analyze (only deletions).")]
|
|
511
512
|
|
|
513
|
+
# Filter out ignored files (.crucibleignore)
|
|
514
|
+
from crucible.ignore import load_ignore_spec
|
|
515
|
+
repo_path_for_ignore = get_repo_root(path if path else os.getcwd()).value
|
|
516
|
+
ignore_spec = load_ignore_spec(repo_path_for_ignore)
|
|
517
|
+
changed_files = [f for f in changed_files if not ignore_spec.is_ignored(f, is_dir=False)]
|
|
518
|
+
if not changed_files:
|
|
519
|
+
return [TextContent(type="text", text="No files to analyze (all changes are in ignored paths).")]
|
|
520
|
+
|
|
512
521
|
elif not path:
|
|
513
522
|
return [TextContent(type="text", text="Error: Either 'path' or 'mode' is required.")]
|
|
514
523
|
|
|
@@ -546,6 +555,10 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
546
555
|
# Deduplicate findings
|
|
547
556
|
all_findings = deduplicate_findings(all_findings)
|
|
548
557
|
|
|
558
|
+
# Filter out findings from ignored paths (.crucibleignore)
|
|
559
|
+
base_path = path if path else os.getcwd()
|
|
560
|
+
all_findings = filter_ignored_findings(all_findings, base_path)
|
|
561
|
+
|
|
549
562
|
# Run pattern and LLM assertions
|
|
550
563
|
enforcement_findings = []
|
|
551
564
|
enforcement_errors: list[str] = []
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
version: "1.0"
|
|
3
|
+
triggers: [cleanup, refactor, deprecate, dead-code, unused, tech-debt, maintenance]
|
|
4
|
+
always_run: false
|
|
5
|
+
always_run_for_domains: []
|
|
6
|
+
knowledge: []
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Code Hygiene Engineer
|
|
10
|
+
|
|
11
|
+
You are reviewing code for cleanliness and maintainability. Your job is to identify dead code, deprecated patterns, stale task markers, and cleanup opportunities.
|
|
12
|
+
|
|
13
|
+
## Key Questions
|
|
14
|
+
|
|
15
|
+
Ask yourself these questions about the code:
|
|
16
|
+
|
|
17
|
+
- Is this code still used? By what?
|
|
18
|
+
- Are these imports actually needed?
|
|
19
|
+
- Is this marked deprecated but still present?
|
|
20
|
+
- Are there task markers (FIXME/XXX) that reference completed work?
|
|
21
|
+
- Is there commented-out code that should be deleted?
|
|
22
|
+
|
|
23
|
+
## Red Flags
|
|
24
|
+
|
|
25
|
+
Watch for these patterns:
|
|
26
|
+
|
|
27
|
+
- Functions/classes with no callers
|
|
28
|
+
- Imports that are never used
|
|
29
|
+
- `DEPRECATED` or `@deprecated` markers
|
|
30
|
+
- Stale task markers like `FIXME: remove` or `XXX: delete`
|
|
31
|
+
- Large blocks of commented-out code
|
|
32
|
+
- Feature flags for features that shipped long ago
|
|
33
|
+
- Backwards-compatibility shims that are no longer needed
|
|
34
|
+
- Variables assigned but never read
|
|
35
|
+
- Unreachable code after return/raise/break
|
|
36
|
+
|
|
37
|
+
## Before Approving
|
|
38
|
+
|
|
39
|
+
Verify these criteria:
|
|
40
|
+
|
|
41
|
+
- [ ] No deprecated code introduced
|
|
42
|
+
- [ ] No unused imports added
|
|
43
|
+
- [ ] Task markers have actionable context (ticket/date/owner)
|
|
44
|
+
- [ ] Commented-out code removed or justified
|
|
45
|
+
- [ ] No dead code paths
|
|
46
|
+
- [ ] Backwards-compat shims have removal dates
|
|
47
|
+
- [ ] Feature flags have cleanup tickets
|
|
48
|
+
|
|
49
|
+
## Cleanup Opportunities
|
|
50
|
+
|
|
51
|
+
When reviewing, note opportunities to:
|
|
52
|
+
|
|
53
|
+
- Remove unused parameters
|
|
54
|
+
- Delete deprecated functions after migration
|
|
55
|
+
- Clean up old feature flags
|
|
56
|
+
- Remove backwards-compat code after version bump
|
|
57
|
+
- Consolidate duplicate code
|
|
58
|
+
- Remove redundant comments
|
|
59
|
+
|
|
60
|
+
## Output Format
|
|
61
|
+
|
|
62
|
+
Structure your hygiene review as:
|
|
63
|
+
|
|
64
|
+
### Dead Code Found
|
|
65
|
+
List unused or unreachable code with confidence level.
|
|
66
|
+
|
|
67
|
+
### Deprecation Issues
|
|
68
|
+
Code marked deprecated or using deprecated patterns.
|
|
69
|
+
|
|
70
|
+
### Stale Task Markers
|
|
71
|
+
FIXME/XXX comments that appear outdated or reference completed work.
|
|
72
|
+
|
|
73
|
+
### Cleanup Opportunities
|
|
74
|
+
Suggestions for improving code cleanliness.
|
|
75
|
+
|
|
76
|
+
### Approval Status
|
|
77
|
+
- APPROVE: Code is clean
|
|
78
|
+
- REQUEST CHANGES: Dead code or deprecated patterns must be addressed
|
|
79
|
+
- COMMENT: Minor cleanup suggestions
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
*Template. Adapt to your needs.*
|
|
@@ -3,6 +3,7 @@ pyproject.toml
|
|
|
3
3
|
src/crucible/__init__.py
|
|
4
4
|
src/crucible/cli.py
|
|
5
5
|
src/crucible/errors.py
|
|
6
|
+
src/crucible/ignore.py
|
|
6
7
|
src/crucible/models.py
|
|
7
8
|
src/crucible/server.py
|
|
8
9
|
src/crucible/domain/__init__.py
|
|
@@ -41,6 +42,7 @@ src/crucible/skills/__init__.py
|
|
|
41
42
|
src/crucible/skills/loader.py
|
|
42
43
|
src/crucible/skills/accessibility-engineer/SKILL.md
|
|
43
44
|
src/crucible/skills/backend-engineer/SKILL.md
|
|
45
|
+
src/crucible/skills/code-hygiene/SKILL.md
|
|
44
46
|
src/crucible/skills/customer-success/SKILL.md
|
|
45
47
|
src/crucible/skills/data-engineer/SKILL.md
|
|
46
48
|
src/crucible/skills/devops-engineer/SKILL.md
|
|
@@ -74,6 +76,7 @@ tests/test_enforcement.py
|
|
|
74
76
|
tests/test_full_review.py
|
|
75
77
|
tests/test_git.py
|
|
76
78
|
tests/test_hooks_cli.py
|
|
79
|
+
tests/test_ignore.py
|
|
77
80
|
tests/test_integration.py
|
|
78
81
|
tests/test_knowledge.py
|
|
79
82
|
tests/test_precommit.py
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Tests for crucible.ignore module."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from crucible.ignore import (
|
|
8
|
+
DEFAULT_PATTERNS,
|
|
9
|
+
IgnorePattern,
|
|
10
|
+
IgnoreSpec,
|
|
11
|
+
clear_ignore_cache,
|
|
12
|
+
load_ignore_spec,
|
|
13
|
+
parse_ignore_file,
|
|
14
|
+
should_ignore,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestIgnorePattern:
|
|
19
|
+
"""Tests for IgnorePattern matching."""
|
|
20
|
+
|
|
21
|
+
def test_simple_glob(self) -> None:
|
|
22
|
+
"""Simple glob patterns match files."""
|
|
23
|
+
pattern = IgnorePattern(pattern="*.log")
|
|
24
|
+
assert pattern.matches("error.log", is_dir=False)
|
|
25
|
+
assert pattern.matches("logs/error.log", is_dir=False)
|
|
26
|
+
assert not pattern.matches("error.txt", is_dir=False)
|
|
27
|
+
|
|
28
|
+
def test_directory_pattern(self) -> None:
|
|
29
|
+
"""Directory patterns match directories and files inside them."""
|
|
30
|
+
pattern = IgnorePattern(pattern="node_modules/", directory_only=True)
|
|
31
|
+
# Matches files inside the directory
|
|
32
|
+
assert pattern.matches("node_modules/foo.js", is_dir=False)
|
|
33
|
+
assert pattern.matches("src/node_modules/bar.js", is_dir=False)
|
|
34
|
+
# Matches the directory itself
|
|
35
|
+
assert pattern.matches("node_modules", is_dir=True)
|
|
36
|
+
# Does not match files with similar names
|
|
37
|
+
assert not pattern.matches("node_modules_backup.txt", is_dir=False)
|
|
38
|
+
|
|
39
|
+
def test_dot_directory(self) -> None:
|
|
40
|
+
"""Directories starting with . are matched correctly."""
|
|
41
|
+
pattern = IgnorePattern(pattern=".next/", directory_only=True)
|
|
42
|
+
assert pattern.matches(".next/build.js", is_dir=False)
|
|
43
|
+
assert pattern.matches("apps/web/.next/cache.js", is_dir=False)
|
|
44
|
+
assert pattern.matches(".next", is_dir=True)
|
|
45
|
+
|
|
46
|
+
def test_hidden_files(self) -> None:
|
|
47
|
+
"""Hidden files are matched."""
|
|
48
|
+
pattern = IgnorePattern(pattern=".DS_Store")
|
|
49
|
+
assert pattern.matches(".DS_Store", is_dir=False)
|
|
50
|
+
assert pattern.matches("src/.DS_Store", is_dir=False)
|
|
51
|
+
|
|
52
|
+
def test_nested_path(self) -> None:
|
|
53
|
+
"""Nested path patterns match correctly."""
|
|
54
|
+
pattern = IgnorePattern(pattern="__pycache__/", directory_only=True)
|
|
55
|
+
assert pattern.matches("src/__pycache__/mod.pyc", is_dir=False)
|
|
56
|
+
assert pattern.matches("__pycache__/mod.pyc", is_dir=False)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestIgnoreSpec:
|
|
60
|
+
"""Tests for IgnoreSpec collection."""
|
|
61
|
+
|
|
62
|
+
def test_is_ignored_basic(self) -> None:
|
|
63
|
+
"""Basic ignore checking works."""
|
|
64
|
+
spec = IgnoreSpec(
|
|
65
|
+
patterns=[
|
|
66
|
+
IgnorePattern(pattern="*.log"),
|
|
67
|
+
IgnorePattern(pattern="node_modules/", directory_only=True),
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
assert spec.is_ignored("error.log")
|
|
71
|
+
assert spec.is_ignored("node_modules/foo.js")
|
|
72
|
+
assert not spec.is_ignored("src/main.py")
|
|
73
|
+
|
|
74
|
+
def test_negation(self) -> None:
|
|
75
|
+
"""Negated patterns un-ignore files."""
|
|
76
|
+
spec = IgnoreSpec(
|
|
77
|
+
patterns=[
|
|
78
|
+
IgnorePattern(pattern="*.log"),
|
|
79
|
+
IgnorePattern(pattern="important.log", negated=True),
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
assert spec.is_ignored("error.log")
|
|
83
|
+
assert not spec.is_ignored("important.log")
|
|
84
|
+
|
|
85
|
+
def test_filter_paths(self) -> None:
|
|
86
|
+
"""Filter paths removes ignored ones."""
|
|
87
|
+
spec = IgnoreSpec(
|
|
88
|
+
patterns=[IgnorePattern(pattern="*.pyc")],
|
|
89
|
+
)
|
|
90
|
+
base = Path("/project")
|
|
91
|
+
paths = [
|
|
92
|
+
Path("/project/main.py"),
|
|
93
|
+
Path("/project/__pycache__/main.pyc"),
|
|
94
|
+
Path("/project/util.py"),
|
|
95
|
+
]
|
|
96
|
+
filtered = spec.filter_paths(paths, base)
|
|
97
|
+
assert len(filtered) == 2
|
|
98
|
+
assert Path("/project/main.py") in filtered
|
|
99
|
+
assert Path("/project/util.py") in filtered
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestParseIgnoreFile:
|
|
103
|
+
"""Tests for parsing .crucibleignore files."""
|
|
104
|
+
|
|
105
|
+
def test_parse_simple(self) -> None:
|
|
106
|
+
"""Simple patterns are parsed."""
|
|
107
|
+
content = """
|
|
108
|
+
*.log
|
|
109
|
+
node_modules/
|
|
110
|
+
"""
|
|
111
|
+
patterns = parse_ignore_file(content)
|
|
112
|
+
assert len(patterns) == 2
|
|
113
|
+
assert patterns[0].pattern == "*.log"
|
|
114
|
+
assert patterns[1].pattern == "node_modules/"
|
|
115
|
+
assert patterns[1].directory_only
|
|
116
|
+
|
|
117
|
+
def test_parse_comments(self) -> None:
|
|
118
|
+
"""Comments and blank lines are ignored."""
|
|
119
|
+
content = """
|
|
120
|
+
# This is a comment
|
|
121
|
+
*.log
|
|
122
|
+
|
|
123
|
+
# Another comment
|
|
124
|
+
build/
|
|
125
|
+
"""
|
|
126
|
+
patterns = parse_ignore_file(content)
|
|
127
|
+
assert len(patterns) == 2
|
|
128
|
+
|
|
129
|
+
def test_parse_negation(self) -> None:
|
|
130
|
+
"""Negated patterns are parsed."""
|
|
131
|
+
content = """
|
|
132
|
+
*.log
|
|
133
|
+
!important.log
|
|
134
|
+
"""
|
|
135
|
+
patterns = parse_ignore_file(content)
|
|
136
|
+
assert len(patterns) == 2
|
|
137
|
+
assert not patterns[0].negated
|
|
138
|
+
assert patterns[1].negated
|
|
139
|
+
assert patterns[1].pattern == "important.log"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestLoadIgnoreSpec:
|
|
143
|
+
"""Tests for loading ignore specs."""
|
|
144
|
+
|
|
145
|
+
def test_default_patterns_included(self) -> None:
|
|
146
|
+
"""Default patterns are always included."""
|
|
147
|
+
clear_ignore_cache()
|
|
148
|
+
spec = load_ignore_spec()
|
|
149
|
+
assert len(spec.patterns) >= len(DEFAULT_PATTERNS)
|
|
150
|
+
# Check some key defaults
|
|
151
|
+
assert spec.is_ignored("node_modules/foo.js")
|
|
152
|
+
assert spec.is_ignored(".git/config")
|
|
153
|
+
assert spec.is_ignored("__pycache__/mod.pyc")
|
|
154
|
+
|
|
155
|
+
def test_project_file_override(self, tmp_path: Path) -> None:
|
|
156
|
+
"""Project .crucibleignore overrides defaults."""
|
|
157
|
+
clear_ignore_cache()
|
|
158
|
+
crucible_dir = tmp_path / ".crucible"
|
|
159
|
+
crucible_dir.mkdir()
|
|
160
|
+
ignore_file = crucible_dir / ".crucibleignore"
|
|
161
|
+
ignore_file.write_text("custom_ignore/\n")
|
|
162
|
+
|
|
163
|
+
spec = load_ignore_spec(tmp_path)
|
|
164
|
+
assert spec.source == "project"
|
|
165
|
+
assert spec.is_ignored("custom_ignore/file.txt")
|
|
166
|
+
# Defaults still apply
|
|
167
|
+
assert spec.is_ignored("node_modules/foo.js")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestDefaultPatterns:
|
|
171
|
+
"""Tests for default ignore patterns."""
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize(
|
|
174
|
+
"path,expected",
|
|
175
|
+
[
|
|
176
|
+
("node_modules/react/index.js", True),
|
|
177
|
+
(".next/build/manifest.json", True),
|
|
178
|
+
(".git/objects/abc123", True),
|
|
179
|
+
("__pycache__/module.cpython-311.pyc", True),
|
|
180
|
+
(".venv/lib/python3.11/site.py", True),
|
|
181
|
+
("dist/bundle.js", True),
|
|
182
|
+
("build/output.o", True),
|
|
183
|
+
("package-lock.json", True),
|
|
184
|
+
("yarn.lock", True),
|
|
185
|
+
("src/main.py", False),
|
|
186
|
+
("components/Button.tsx", False),
|
|
187
|
+
("contracts/Token.sol", False),
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
def test_default_ignore_patterns(self, path: str, expected: bool) -> None:
|
|
191
|
+
"""Default patterns ignore common non-source files."""
|
|
192
|
+
clear_ignore_cache()
|
|
193
|
+
spec = load_ignore_spec()
|
|
194
|
+
assert spec.is_ignored(path) == expected, f"{path} should be {'ignored' if expected else 'included'}"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for bundled skills - validates all
|
|
1
|
+
"""Tests for bundled skills - validates all 19 skills have proper structure."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
@@ -26,6 +26,7 @@ EXPECTED_SKILLS = {
|
|
|
26
26
|
"mev-researcher",
|
|
27
27
|
"formal-verification",
|
|
28
28
|
"incident-responder",
|
|
29
|
+
"code-hygiene",
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
|
|
@@ -38,13 +39,13 @@ class TestSkillsExist:
|
|
|
38
39
|
for skill in EXPECTED_SKILLS:
|
|
39
40
|
assert skill in actual, f"Missing skill: {skill}"
|
|
40
41
|
|
|
41
|
-
def
|
|
42
|
-
"""Should have exactly
|
|
42
|
+
def test_exactly_19_bundled_skills(self) -> None:
|
|
43
|
+
"""Should have exactly 19 bundled skills."""
|
|
43
44
|
bundled = set()
|
|
44
45
|
for skill_dir in SKILLS_BUNDLED.iterdir():
|
|
45
46
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
46
47
|
bundled.add(skill_dir.name)
|
|
47
|
-
assert len(bundled) ==
|
|
48
|
+
assert len(bundled) == 19, f"Expected 19 skills, got {len(bundled)}: {bundled}"
|
|
48
49
|
|
|
49
50
|
def test_skills_directory_exists(self) -> None:
|
|
50
51
|
"""Skills bundled directory should exist."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/error-handling.yaml
RENAMED
|
File without changes
|
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/smart-contract.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/DOCUMENTATION.md
RENAMED
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/ERROR_HANDLING.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/OBSERVABILITY.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SMART_CONTRACT.md
RENAMED
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SYSTEM_DESIGN.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/accessibility-engineer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|