mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
390 lines
13 KiB
Python
390 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Run Project Health Auditor MCP tool equivalents and save results.
|
|
|
|
This script implements the functionality of:
|
|
- list_repo_files() - Lists all files in the repository
|
|
- map_tests(path=".") - Maps source files to test files
|
|
- git_churn(path=".") - Returns git churn metrics
|
|
|
|
Results are saved to:
|
|
- reports/mcp_health_files.json
|
|
- reports/mcp_health_test_map.json
|
|
- reports/mcp_health_churn.json
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
from collections import defaultdict
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
def list_repo_files(root: Path = Path(".")) -> dict:
|
|
"""
|
|
List all files in the repository (excluding common ignore patterns).
|
|
|
|
Returns a structured response similar to the MCP tool.
|
|
"""
|
|
ignore_patterns = {
|
|
'.git', '.venv', '.venv-audit', '__pycache__', '.pytest_cache',
|
|
'.ruff_cache', '.mypy_cache', '.hypothesis', 'node_modules',
|
|
'build', 'dist', '*.egg-info', 'htmlcov', '.coverage',
|
|
'*.pyc', '*.pyo', '.DS_Store', 'Thumbs.db'
|
|
}
|
|
|
|
files = []
|
|
for path in root.rglob("*"):
|
|
# Skip ignored directories
|
|
skip = False
|
|
for part in path.parts:
|
|
if part.startswith('.venv') or part in {'.git', 'node_modules', '__pycache__'}:
|
|
skip = True
|
|
break
|
|
if part.endswith('.egg-info'):
|
|
skip = True
|
|
break
|
|
|
|
if skip:
|
|
continue
|
|
|
|
if path.is_file():
|
|
rel_path = str(path.relative_to(root))
|
|
# Skip binary/cache files
|
|
if any(rel_path.endswith(ext) for ext in ['.pyc', '.pyo', '.db', '.so']):
|
|
if not rel_path.endswith('.db') or 'shard' in rel_path:
|
|
continue
|
|
|
|
files.append({
|
|
"path": rel_path,
|
|
"size": path.stat().st_size,
|
|
"type": get_file_type(rel_path)
|
|
})
|
|
|
|
files.sort(key=lambda x: x["path"])
|
|
|
|
return {
|
|
"tool": "list_repo_files",
|
|
"path": str(root.absolute()),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"total_files": len(files),
|
|
"files": files,
|
|
"by_type": count_by_type(files)
|
|
}
|
|
|
|
|
|
def get_file_type(path: str) -> str:
|
|
"""Determine file type based on extension and path."""
|
|
if path.endswith('.py'):
|
|
if 'test' in path.lower() or path.startswith('test_'):
|
|
return 'test'
|
|
return 'python'
|
|
elif path.endswith(('.js', '.ts', '.jsx', '.tsx')):
|
|
return 'javascript'
|
|
elif path.endswith(('.yaml', '.yml')):
|
|
return 'yaml'
|
|
elif path.endswith('.json'):
|
|
return 'json'
|
|
elif path.endswith('.md'):
|
|
return 'markdown'
|
|
elif path.endswith('.txt'):
|
|
return 'text'
|
|
elif path.endswith('.toml'):
|
|
return 'toml'
|
|
elif path.endswith('.ini'):
|
|
return 'ini'
|
|
elif path.endswith('.cfg'):
|
|
return 'config'
|
|
elif path.endswith('.sh'):
|
|
return 'shell'
|
|
elif path.endswith('Dockerfile') or 'dockerfile' in path.lower():
|
|
return 'dockerfile'
|
|
elif path.endswith('.gitignore'):
|
|
return 'gitignore'
|
|
elif path.endswith('.xml'):
|
|
return 'xml'
|
|
else:
|
|
return 'other'
|
|
|
|
|
|
def count_by_type(files: list) -> dict:
|
|
"""Count files by type."""
|
|
counts = defaultdict(int)
|
|
for f in files:
|
|
counts[f["type"]] += 1
|
|
return dict(counts)
|
|
|
|
|
|
def map_tests(root: Path = Path(".")) -> dict:
|
|
"""
|
|
Map source files to their corresponding test files.
|
|
|
|
Identifies:
|
|
- Source files with tests
|
|
- Source files without tests (untested)
|
|
- Test files without corresponding source (orphaned tests)
|
|
"""
|
|
source_files = []
|
|
test_files = []
|
|
|
|
for path in root.rglob("*"):
|
|
# Skip ignored directories
|
|
skip = False
|
|
for part in path.parts:
|
|
if part.startswith('.venv') or part in {'.git', 'node_modules', '__pycache__'}:
|
|
skip = True
|
|
break
|
|
if part.endswith('.egg-info'):
|
|
skip = True
|
|
break
|
|
|
|
if skip:
|
|
continue
|
|
|
|
if path.is_file() and path.suffix == '.py':
|
|
rel_path = str(path.relative_to(root))
|
|
|
|
if 'test' in path.name.lower() or 'tests' in path.parts:
|
|
test_files.append(rel_path)
|
|
elif not any(x in rel_path for x in ['conftest', '__pycache__']):
|
|
# Only include source files from main packages
|
|
if any(pkg in rel_path for pkg in ['nodupe/', 'pipeline/', 'github/']):
|
|
source_files.append(rel_path)
|
|
|
|
# Build mapping
|
|
source_to_test = {}
|
|
tested_sources = set()
|
|
|
|
for test_file in test_files:
|
|
# Try to find corresponding source file
|
|
test_name = Path(test_file).name
|
|
source_candidates = []
|
|
|
|
# Pattern: test_foo.py -> foo.py
|
|
if test_name.startswith('test_'):
|
|
source_name = test_name[5:] # Remove 'test_' prefix
|
|
for src in source_files:
|
|
if src.endswith(source_name):
|
|
source_candidates.append(src)
|
|
|
|
# Pattern: tests/test_foo.py -> nodupe/foo.py
|
|
if not source_candidates:
|
|
base_name = test_name.replace('test_', '')
|
|
for src in source_files:
|
|
if src.endswith(base_name) or base_name.replace('.py', '') in src:
|
|
source_candidates.append(src)
|
|
|
|
if source_candidates:
|
|
for src in source_candidates:
|
|
if src not in source_to_test:
|
|
source_to_test[src] = []
|
|
source_to_test[src].append(test_file)
|
|
tested_sources.add(src)
|
|
|
|
untested_sources = [s for s in source_files if s not in tested_sources]
|
|
|
|
return {
|
|
"tool": "map_tests",
|
|
"path": str(root.absolute()),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"summary": {
|
|
"total_source_files": len(source_files),
|
|
"total_test_files": len(test_files),
|
|
"tested_source_files": len(tested_sources),
|
|
"untested_source_files": len(untested_sources),
|
|
"coverage_percentage": round(len(tested_sources) / len(source_files) * 100, 1) if source_files else 0
|
|
},
|
|
"source_to_test": source_to_test,
|
|
"untested_sources": sorted(untested_sources),
|
|
"test_files": sorted(test_files)
|
|
}
|
|
|
|
|
|
def git_churn(root: Path = Path(".")) -> dict:
|
|
"""
|
|
Calculate git churn metrics for files.
|
|
|
|
Churn is measured by:
|
|
- Number of commits touching each file
|
|
- Lines added/removed per file
|
|
- Last modified date
|
|
"""
|
|
churn_data = []
|
|
|
|
try:
|
|
# Get list of tracked files
|
|
result = subprocess.run(
|
|
["git", "-C", str(root), "ls-files"],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return {
|
|
"tool": "git_churn",
|
|
"path": str(root.absolute()),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"error": "Failed to get git files list",
|
|
"stderr": result.stderr
|
|
}
|
|
|
|
tracked_files = result.stdout.strip().split('\n')
|
|
|
|
for file_path in tracked_files:
|
|
if not file_path or any(x in file_path for x in ['.venv', 'node_modules']):
|
|
continue
|
|
|
|
try:
|
|
# Get commit count for file
|
|
commit_result = subprocess.run(
|
|
["git", "-C", str(root), "log", "--oneline", "--", file_path],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
commits = commit_result.stdout.strip().split('\n') if commit_result.stdout.strip() else []
|
|
commit_count = len([c for c in commits if c])
|
|
|
|
# Get last modified date
|
|
date_result = subprocess.run(
|
|
["git", "-C", str(root), "log", "-1", "--format=%ci", "--", file_path],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
last_modified = date_result.stdout.strip()
|
|
|
|
# Get lines added/removed
|
|
blame_result = subprocess.run(
|
|
["git", "-C", str(root), "blame", "--line-porcelain", "--", file_path],
|
|
capture_output=True, text=True, timeout=60
|
|
)
|
|
|
|
# Count unique authors
|
|
authors = set()
|
|
if blame_result.stdout:
|
|
for line in blame_result.stdout.split('\n'):
|
|
if line.startswith('author '):
|
|
authors.add(line[7:])
|
|
|
|
churn_data.append({
|
|
"path": file_path,
|
|
"commit_count": commit_count,
|
|
"last_modified": last_modified,
|
|
"unique_authors": len(authors),
|
|
"churn_level": categorize_churn(commit_count)
|
|
})
|
|
|
|
except subprocess.TimeoutExpired:
|
|
churn_data.append({
|
|
"path": file_path,
|
|
"error": "timeout"
|
|
})
|
|
except Exception as e:
|
|
churn_data.append({
|
|
"path": file_path,
|
|
"error": str(e)
|
|
})
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {
|
|
"tool": "git_churn",
|
|
"path": str(root.absolute()),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"error": "Git command timed out"
|
|
}
|
|
except FileNotFoundError:
|
|
return {
|
|
"tool": "git_churn",
|
|
"path": str(root.absolute()),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"error": "Git not found or not a git repository"
|
|
}
|
|
|
|
# Sort by commit count (highest first)
|
|
churn_data.sort(key=lambda x: x.get('commit_count', 0), reverse=True)
|
|
|
|
# Calculate summary
|
|
high_churn = [f for f in churn_data if f.get('churn_level') == 'high']
|
|
medium_churn = [f for f in churn_data if f.get('churn_level') == 'medium']
|
|
low_churn = [f for f in churn_data if f.get('churn_level') == 'low']
|
|
|
|
return {
|
|
"tool": "git_churn",
|
|
"path": str(root.absolute()),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"summary": {
|
|
"total_files": len(churn_data),
|
|
"high_churn_files": len(high_churn),
|
|
"medium_churn_files": len(medium_churn),
|
|
"low_churn_files": len(low_churn)
|
|
},
|
|
"files": churn_data
|
|
}
|
|
|
|
|
|
def categorize_churn(commit_count: int) -> str:
|
|
"""Categorize churn level based on commit count."""
|
|
if commit_count >= 20:
|
|
return "high"
|
|
elif commit_count >= 5:
|
|
return "medium"
|
|
else:
|
|
return "low"
|
|
|
|
|
|
def main():
|
|
"""Run all health audit tools and save results."""
|
|
root = Path("/home/prod/Workspaces/repos/github/NoDupeLabs")
|
|
reports_dir = root / "reports"
|
|
reports_dir.mkdir(exist_ok=True)
|
|
|
|
print("=" * 60)
|
|
print("Running Project Health Auditor MCP Tool Equivalents")
|
|
print("=" * 60)
|
|
|
|
# 1. list_repo_files()
|
|
print("\n[1/3] Running list_repo_files()...")
|
|
files_result = list_repo_files(root)
|
|
files_path = reports_dir / "mcp_health_files.json"
|
|
with open(files_path, 'w') as f:
|
|
json.dump(files_result, f, indent=2)
|
|
print(f" Total files: {files_result['total_files']}")
|
|
print(f" By type: {files_result['by_type']}")
|
|
print(f" Saved to: {files_path}")
|
|
|
|
# 2. map_tests()
|
|
print("\n[2/3] Running map_tests(path='.')...")
|
|
tests_result = map_tests(root)
|
|
tests_path = reports_dir / "mcp_health_test_map.json"
|
|
with open(tests_path, 'w') as f:
|
|
json.dump(tests_result, f, indent=2)
|
|
print(f" Source files: {tests_result['summary']['total_source_files']}")
|
|
print(f" Test files: {tests_result['summary']['total_test_files']}")
|
|
print(f" Coverage: {tests_result['summary']['coverage_percentage']}%")
|
|
print(f" Untested: {tests_result['summary']['untested_source_files']}")
|
|
print(f" Saved to: {tests_path}")
|
|
|
|
# 3. git_churn()
|
|
print("\n[3/3] Running git_churn(path='.')...")
|
|
churn_result = git_churn(root)
|
|
churn_path = reports_dir / "mcp_health_churn.json"
|
|
with open(churn_path, 'w') as f:
|
|
json.dump(churn_result, f, indent=2)
|
|
if 'error' not in churn_result:
|
|
print(f" Total files: {churn_result['summary']['total_files']}")
|
|
print(f" High churn: {churn_result['summary']['high_churn_files']}")
|
|
print(f" Medium churn: {churn_result['summary']['medium_churn_files']}")
|
|
print(f" Low churn: {churn_result['summary']['low_churn_files']}")
|
|
else:
|
|
print(f" Error: {churn_result.get('error', 'Unknown error')}")
|
|
print(f" Saved to: {churn_path}")
|
|
|
|
print("\n" + "=" * 60)
|
|
print("Health audit complete!")
|
|
print("=" * 60)
|
|
|
|
return {
|
|
"files_report": str(files_path),
|
|
"test_map_report": str(tests_path),
|
|
"churn_report": str(churn_path)
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|