Files
ss-tools/scripts/find-related-tests.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
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
2026-07-22 11:48:15 +03:00

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