Systematic rename of all semantic anchors (#region, [DEF], @RELATION) across 1400+ files — backend Python, frontend Svelte/TS, specs, docs: - Flat anchors become Namespace.Module.Entity - @RELATION references updated to match new anchor paths - Zero business logic changes
272 lines
9.7 KiB
Python
Executable File
272 lines
9.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Smart test selector — finds tests related to a source file.
|
|
|
|
Algorithm:
|
|
1. Extract module/class names from the source file
|
|
(from #region anchors, class/function definitions, filename)
|
|
2. Search all test files for @RELATION BINDS_TO -> [ModuleName] annotations
|
|
3. Return matching test files with confidence scores
|
|
4. Optionally run pytest on matched tests
|
|
|
|
Usage:
|
|
python3 scripts/find-related-tests.py --file backend/src/plugins/migration.py
|
|
python3 scripts/find-related-tests.py --file path/to/file.py --run
|
|
python3 scripts/find-related-tests.py --file path/to/file.py --verbose
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
WORKSPACE = Path(__file__).resolve().parent.parent
|
|
|
|
# ── Regex patterns ─────────────────────────────────────────
|
|
REGION_RE = re.compile(r'#region\s+([\w.]+)')
|
|
CLASS_RE = re.compile(r'^class\s+(\w+)', re.MULTILINE)
|
|
DEF_RE = re.compile(r'^def\s+(\w+)', re.MULTILINE)
|
|
BINDSTO_RE = re.compile(r'@RELATION\s+BINDS_TO\s*->\s*\[([^\]]+)\]')
|
|
# Frontend: test files may contain @TEST_CONTRACT / @RELATION
|
|
FRONTEND_BINDSTO_RE = re.compile(r'@(?:RELATION\s+BINDS_TO|TEST_CONTRACT|TEST_INVARIANT)[^\n]*')
|
|
|
|
# ── Helpers ────────────────────────────────────────────────
|
|
def extract_names(filepath: Path) -> set[str]:
|
|
"""Extract candidate module names from a source file."""
|
|
names: set[str] = set()
|
|
try:
|
|
content = filepath.read_text(encoding='utf-8')
|
|
except Exception:
|
|
return names
|
|
|
|
# #region Tooling.FindRelatedTests.Anchors
|
|
for m in REGION_RE.finditer(content):
|
|
names.add(m.group(1))
|
|
|
|
# class names (PascalCase)
|
|
for m in CLASS_RE.finditer(content):
|
|
name = m.group(1)
|
|
if name[0].isupper():
|
|
names.add(name)
|
|
|
|
# function names (snake_case)
|
|
for m in DEF_RE.finditer(content):
|
|
names.add(m.group(1))
|
|
|
|
# filename stem (strip test_ prefix for test files, otherwise use as-is)
|
|
stem = filepath.stem
|
|
if stem.startswith('test_'):
|
|
stem = stem[5:]
|
|
# camelCase or snake_case variants
|
|
names.add(stem)
|
|
# PascalCase variant
|
|
pascal = ''.join(w.capitalize() for w in stem.replace('-', '_').split('_'))
|
|
names.add(pascal)
|
|
names.add(pascal + 'Module')
|
|
names.add(pascal + 'Router')
|
|
names.add(pascal + 'Api')
|
|
names.add(pascal + 'Routes')
|
|
|
|
return {n for n in names if len(n) > 1}
|
|
|
|
|
|
def build_index(test_dirs: list[Path]) -> dict[str, list[Path]]:
|
|
"""Build reverse index: module_name -> [test_file_paths].
|
|
|
|
Scans all test_*.py files in the given directories.
|
|
"""
|
|
index: dict[str, list[Path]] = {}
|
|
|
|
for test_dir in test_dirs:
|
|
if not test_dir.exists():
|
|
continue
|
|
for test_file in test_dir.rglob('test_*.py'):
|
|
try:
|
|
content = test_file.read_text(encoding='utf-8')
|
|
except Exception:
|
|
continue
|
|
|
|
for m in BINDSTO_RE.finditer(content):
|
|
targets = [t.strip() for t in m.group(1).split(',')]
|
|
for target in targets:
|
|
if target:
|
|
index.setdefault(target, []).append(test_file)
|
|
|
|
return index
|
|
|
|
|
|
def match(source_names: set[str], index: dict[str, list[Path]]) -> list[tuple[Path, str, int]]:
|
|
"""Find matching test files with confidence scores.
|
|
|
|
Returns list of (test_path, matched_name, score) sorted by score desc.
|
|
Score: 3 = exact match, 2 = case-insensitive exact, 1 = substring match.
|
|
"""
|
|
results: list[tuple[Path, str, int]] = []
|
|
seen: set[str] = set()
|
|
|
|
for source_name in source_names:
|
|
source_lower = source_name.lower()
|
|
|
|
for idx_key, test_files in index.items():
|
|
idx_lower = idx_key.lower()
|
|
|
|
score = 0
|
|
if idx_key == source_name:
|
|
score = 3 # exact
|
|
elif idx_lower == source_lower:
|
|
score = 2 # case-insensitive
|
|
elif idx_lower in source_lower or source_lower in idx_lower:
|
|
score = 1 # substring
|
|
|
|
if score > 0:
|
|
for tf in test_files:
|
|
key = str(tf)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
results.append((tf, idx_key, score))
|
|
|
|
# Deduplicate: keep highest score per file
|
|
best: dict[str, tuple[Path, str, int]] = {}
|
|
for tf, key, score in results:
|
|
tf_str = str(tf)
|
|
if tf_str not in best or score > best[tf_str][2]:
|
|
best[tf_str] = (tf, key, score)
|
|
|
|
return sorted(best.values(), key=lambda x: (-x[2], str(x[0])))
|
|
|
|
|
|
# ── Main ───────────────────────────────────────────────────
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description='Find tests related to a source file via @RELATION BINDS_TO'
|
|
)
|
|
parser.add_argument('--file', '-f', required=True, help='Source file path')
|
|
parser.add_argument('--run', action='store_true', help='Run found tests with pytest')
|
|
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
|
|
args = parser.parse_args()
|
|
|
|
source_file = Path(args.file).resolve()
|
|
if not source_file.exists():
|
|
print(f"Error: file not found: {args.file}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# ── Determine which directories to scan ────────────────
|
|
try:
|
|
rel = source_file.relative_to(WORKSPACE)
|
|
rel_str = str(rel)
|
|
except ValueError:
|
|
rel = source_file
|
|
rel_str = str(source_file)
|
|
|
|
test_dirs: list[Path] = []
|
|
|
|
if 'backend' in rel_str:
|
|
test_dirs = [
|
|
WORKSPACE / 'backend' / 'tests',
|
|
# Co-located __tests__ directories
|
|
WORKSPACE / 'backend' / 'src',
|
|
]
|
|
elif 'frontend' in rel_str:
|
|
test_dirs = [
|
|
WORKSPACE / 'frontend' / 'src',
|
|
]
|
|
elif 'agent' in rel_str:
|
|
test_dirs = [
|
|
WORKSPACE / 'agent' / 'tests',
|
|
]
|
|
else:
|
|
# Default: scan everything
|
|
test_dirs = [
|
|
WORKSPACE / 'backend' / 'tests',
|
|
WORKSPACE / 'backend' / 'src',
|
|
WORKSPACE / 'agent' / 'tests',
|
|
]
|
|
|
|
# ── Build index and match ──────────────────────────────
|
|
source_names = extract_names(source_file)
|
|
|
|
if args.verbose:
|
|
print(f"[find-related-tests] Source: {source_file}")
|
|
print(f"[find-related-tests] Extracted names ({len(source_names)}):")
|
|
for n in sorted(source_names):
|
|
print(f" - {n}")
|
|
|
|
index = build_index(test_dirs)
|
|
|
|
if args.verbose:
|
|
print(f"[find-related-tests] BINDS_TO index: {len(index)} entries")
|
|
sample = dict(sorted(index.items())[:5])
|
|
for k, v in sample.items():
|
|
print(f" {k}: {len(v)} test(s)")
|
|
|
|
matches = match(source_names, index)
|
|
|
|
# ── Fallback: filename-based heuristic ─────────────────
|
|
if not matches:
|
|
stem_lower = source_file.stem.lower()
|
|
if stem_lower.startswith('test_'):
|
|
stem_lower = stem_lower[5:]
|
|
|
|
for test_dir in test_dirs:
|
|
if not test_dir.exists():
|
|
continue
|
|
for test_file in test_dir.rglob(f'*{stem_lower}*test*.py'):
|
|
matches.append((test_file, 'heuristic', 0))
|
|
for test_file in test_dir.rglob(f'test_*{stem_lower}*.py'):
|
|
matches.append((test_file, 'heuristic', 0))
|
|
for test_file in test_dir.rglob(f'__tests__/{stem_lower}*.py'):
|
|
matches.append((test_file, 'heuristic', 0))
|
|
|
|
# Deduplicate heuristic results
|
|
seen_heuristic: set[str] = set()
|
|
unique_matches: list[tuple[Path, str, int]] = []
|
|
for tf, key, score in matches:
|
|
tf_str = str(tf)
|
|
if tf_str not in seen_heuristic:
|
|
seen_heuristic.add(tf_str)
|
|
unique_matches.append((tf, key, score))
|
|
matches = unique_matches
|
|
|
|
# ── Output ─────────────────────────────────────────────
|
|
if not matches:
|
|
print(f"No related tests found for {args.file}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
print(f"Found {len(matches)} related test file(s):")
|
|
for tf, key, score in matches:
|
|
score_label = {3: 'exact', 2: 'case', 1: 'substr', 0: 'heur'}.get(score, '?')
|
|
try:
|
|
rel_path = tf.relative_to(WORKSPACE)
|
|
except ValueError:
|
|
rel_path = tf
|
|
print(f" [{score_label}] {rel_path} (via '{key}')")
|
|
|
|
# ── Run tests (optional) ───────────────────────────────
|
|
if args.run:
|
|
test_paths = [str(tf.relative_to(WORKSPACE / 'backend') if 'backend' in str(tf) else tf) for tf, _, _ in matches]
|
|
# Run from backend dir for consistency
|
|
backend_dir = WORKSPACE / 'backend'
|
|
cmd = [
|
|
'timeout', '120',
|
|
'python', '-m', 'pytest', '-v', '--tb=short',
|
|
] + test_paths
|
|
|
|
print(f"\nRunning: {' '.join(cmd)}")
|
|
env = os.environ.copy()
|
|
env.setdefault('DATABASE_URL', 'sqlite:///tmp/ss_tools_test.db')
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=backend_dir,
|
|
env=env,
|
|
)
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
# #endregion Tooling.FindRelatedTests.Anchors
|