mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
442 lines
14 KiB
Python
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()
|