Files

442 lines
14 KiB
Python

"""
Guard Tool - Repository invariants and boundaries
================================================
Purpose: enforce repo invariants (boundaries, forbidden imports/strings, generated dirs).
Rules:
- regex rule: {id, glob, pattern} - Match regex patterns in files
- import rule: {id, glob, forbid_import_prefix} - Forbidden import prefixes
- generated rule: {id, glob, forbid_writes: true} - Protect generated files
Example rules for Hanzo ecosystem:
- no node in sdk: forbid import prefix in sdk/** of github.com/luxfi/node/
- no net/http in api/** contracts
- no edits under generated: api/pb/**, api/capnp/**
"""
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
from .dev_tools import DevResult, DevToolBase, create_dev_result
@dataclass
class GuardRule:
"""Base guard rule"""
id: str
glob: str
description: str = ""
@dataclass
class RegexRule(GuardRule):
"""Regex pattern rule"""
pattern: str
message: str = "Pattern match found"
@dataclass
class ImportRule(GuardRule):
"""Import prefix rule"""
forbid_import_prefix: str
message: str = "Forbidden import found"
@dataclass
class GeneratedRule(GuardRule):
"""Generated file protection rule"""
forbid_writes: bool = True
message: str = "Modification of generated files forbidden"
@dataclass
class Violation:
"""Rule violation"""
file: str
line: int
text: str
rule_id: str
message: str
class GuardTool(DevToolBase):
"""Repository invariant enforcement tool"""
def __init__(self, target: str, **kwargs):
super().__init__(target, **kwargs)
self.rules = self._parse_rules(kwargs.get("rules", []))
async def execute(self) -> DevResult:
"""Execute guard operation"""
try:
violations = []
# Get files to check
files = self._get_files_to_check()
# Run each rule
for rule in self.rules:
rule_violations = await self._check_rule(rule, files)
violations.extend(rule_violations)
return create_dev_result(
ok=len(violations) == 0,
root=self.workspace["root"],
language_used=self.language,
backend_used="guard",
scope_resolved=self.resolved["scope"],
stdout=self._format_violations(violations),
stderr="",
exit_code=0 if len(violations) == 0 else 1,
errors=[f"Found {len(violations)} violations"] if violations else [],
)
except Exception as e:
return create_dev_result(
ok=False,
root=self.workspace["root"],
language_used=self.language,
backend_used="guard",
scope_resolved=self.target,
errors=[str(e)],
)
def _parse_rules(self, rules_data: List[Dict[str, Any]]) -> List[GuardRule]:
"""Parse rule definitions into rule objects"""
rules = []
for rule_data in rules_data:
rule_id = rule_data.get("id", "unnamed")
glob_pattern = rule_data.get("glob", "**")
description = rule_data.get("description", "")
if "pattern" in rule_data:
# Regex rule
rules.append(
RegexRule(
id=rule_id,
glob=glob_pattern,
description=description,
pattern=rule_data["pattern"],
message=rule_data.get("message", "Pattern match found"),
)
)
elif "forbid_import_prefix" in rule_data:
# Import rule
rules.append(
ImportRule(
id=rule_id,
glob=glob_pattern,
description=description,
forbid_import_prefix=rule_data["forbid_import_prefix"],
message=rule_data.get("message", "Forbidden import found"),
)
)
elif "forbid_writes" in rule_data:
# Generated file rule
rules.append(
GeneratedRule(
id=rule_id,
glob=glob_pattern,
description=description,
forbid_writes=rule_data["forbid_writes"],
message=rule_data.get(
"message", "Modification of generated files forbidden"
),
)
)
return rules
def _get_files_to_check(self) -> List[str]:
"""Get list of files to check"""
if self.resolved["type"] == "file":
return [self.resolved["scope"]]
elif self.resolved["type"] == "directory":
return self._scan_directory(self.resolved["scope"])
elif self.resolved["type"] == "workspace":
return self._scan_directory(self.workspace["root"])
else:
return self.resolved["files"]
def _scan_directory(self, directory: str) -> List[str]:
"""Scan directory for source files"""
dir_path = Path(directory)
files = []
# Common source file patterns
patterns = [
"**/*.go",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"**/*.py",
"**/*.rs",
"**/*.c",
"**/*.cpp",
"**/*.cc",
"**/*.h",
"**/*.hpp",
"**/*.sol",
"**/*.proto",
]
for pattern in patterns:
files.extend([str(f) for f in dir_path.glob(pattern) if f.is_file()])
return files
async def _check_rule(self, rule: GuardRule, files: List[str]) -> List[Violation]:
"""Check a rule against files"""
violations = []
# Filter files by glob pattern
matching_files = self._filter_files_by_glob(files, rule.glob)
if isinstance(rule, RegexRule):
violations.extend(await self._check_regex_rule(rule, matching_files))
elif isinstance(rule, ImportRule):
violations.extend(await self._check_import_rule(rule, matching_files))
elif isinstance(rule, GeneratedRule):
violations.extend(await self._check_generated_rule(rule, matching_files))
return violations
def _filter_files_by_glob(self, files: List[str], glob_pattern: str) -> List[str]:
"""Filter files by glob pattern"""
root_path = Path(self.workspace["root"])
matching_files = []
for file_path in files:
# Make path relative to workspace root for matching
try:
Path(file_path).relative_to(root_path)
if Path(file_path).match(glob_pattern):
matching_files.append(file_path)
except ValueError:
# File outside workspace
continue
return matching_files
async def _check_regex_rule(
self, rule: RegexRule, files: List[str]
) -> List[Violation]:
"""Check regex rule against files"""
violations = []
pattern = re.compile(rule.pattern)
for file_path in files:
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, 1):
if pattern.search(line):
violations.append(
Violation(
file=file_path,
line=line_num,
text=line.strip(),
rule_id=rule.id,
message=rule.message,
)
)
except Exception:
# Skip files that can't be read
continue
return violations
async def _check_import_rule(
self, rule: ImportRule, files: List[str]
) -> List[Violation]:
"""Check import rule against files"""
violations = []
for file_path in files:
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, 1):
# Check for import statements containing forbidden prefix
if self._line_has_forbidden_import(
line, rule.forbid_import_prefix
):
violations.append(
Violation(
file=file_path,
line=line_num,
text=line.strip(),
rule_id=rule.id,
message=f"{rule.message}: {rule.forbid_import_prefix}",
)
)
except Exception:
continue
return violations
async def _check_generated_rule(
self, rule: GeneratedRule, files: List[str]
) -> List[Violation]:
"""Check generated file rule"""
violations = []
if not rule.forbid_writes:
return violations
# Check if any matching files have been modified
for file_path in files:
if self._is_file_modified(file_path):
violations.append(
Violation(
file=file_path,
line=1,
text="[Generated file was modified]",
rule_id=rule.id,
message=rule.message,
)
)
return violations
def _line_has_forbidden_import(self, line: str, forbidden_prefix: str) -> bool:
"""Check if line contains forbidden import"""
line = line.strip()
# Go imports
if line.startswith("import ") or ('"' in line and "import" in line):
return forbidden_prefix in line
# Python imports
if line.startswith("import ") or line.startswith("from "):
return forbidden_prefix in line
# TypeScript/JavaScript imports
if "import" in line and ("from" in line or "require(" in line):
return forbidden_prefix in line
# Rust use statements
if line.startswith("use "):
return forbidden_prefix in line
return False
def _is_file_modified(self, file_path: str) -> bool:
"""Check if file has been modified (simplified check)"""
# For now, just check if file exists and is not empty
# In a real implementation, this would check git status,
# modification times, or other indicators
try:
path = Path(file_path)
return path.exists() and path.stat().st_size > 0
except (OSError, PermissionError):
return False
def _format_violations(self, violations: List[Violation]) -> str:
"""Format violations for output"""
if not violations:
return "✅ All guard rules passed"
output = [f"❌ Found {len(violations)} guard violations:"]
output.append("")
# Group by rule
by_rule = {}
for violation in violations:
if violation.rule_id not in by_rule:
by_rule[violation.rule_id] = []
by_rule[violation.rule_id].append(violation)
for rule_id, rule_violations in by_rule.items():
output.append(f"Rule: {rule_id}")
for violation in rule_violations:
output.append(
f" {violation.file}:{violation.line} - {violation.message}"
)
output.append(f" {violation.text}")
output.append("")
return "\n".join(output)
# Default rules for Hanzo ecosystem
HANZO_DEFAULT_RULES = [
{
"id": "no-node-in-sdk",
"glob": "sdk/**/*.go",
"forbid_import_prefix": "github.com/luxfi/node/",
"description": "SDK packages should not import node internals",
"message": "SDK boundary violation - importing node package",
},
{
"id": "no-http-in-contracts",
"glob": "api/**/*.go",
"forbid_import_prefix": "net/http",
"description": "API contracts should not import net/http",
"message": "API contract should be transport agnostic",
},
{
"id": "protect-generated-pb",
"glob": "api/pb/**/*",
"forbid_writes": True,
"description": "Protect generated protobuf files",
"message": "Generated protobuf files should not be manually edited",
},
{
"id": "protect-generated-capnp",
"glob": "api/capnp/**/*",
"forbid_writes": True,
"description": "Protect generated Cap'n Proto files",
"message": "Generated Cap'n Proto files should not be manually edited",
},
{
"id": "no-todo-in-main",
"glob": "**/*.go",
"pattern": r"// TODO.*(?:FIXME|HACK|XXX)",
"description": "No TODO/FIXME in production code",
"message": "Remove TODO/FIXME before merging",
},
]
# MCP tool integration
async def guard_tool_handler(
target: str,
rules: Optional[List[Dict[str, Any]]] = None,
language: str = "auto",
backend: str = "auto",
root: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
dry_run: bool = False,
use_defaults: bool = True,
) -> Dict[str, Any]:
"""MCP handler for guard tool"""
# Combine provided rules with defaults if requested
all_rules = []
if use_defaults:
all_rules.extend(HANZO_DEFAULT_RULES)
if rules:
all_rules.extend(rules)
tool = GuardTool(
target=target,
language=language,
backend=backend,
root=root,
env=env,
dry_run=dry_run,
rules=all_rules,
)
result = await tool.execute()
return result.dict()