mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
272 lines
9.4 KiB
Python
272 lines
9.4 KiB
Python
"""
|
|
Code Quality Test: Type Safety Scoring
|
|
|
|
Scores the codebase based on type safety patterns:
|
|
- cast(Any, ...) usage (too many casts indicate type safety issues)
|
|
- # type: ignore comments (越多表示类型问题越多)
|
|
|
|
Perfect score: 100
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
ASTRBOT_ROOT = Path(__file__).parent.parent / "astrbot"
|
|
|
|
|
|
def _scan_for_patterns() -> dict[str, int | set[str] | list[tuple[str, str]]]:
|
|
"""Scan astrbot source for type-unsafe patterns."""
|
|
counts: dict[str, int | set[str] | list[tuple[str, str]]] = {
|
|
"cast_any": 0,
|
|
"type_ignore": 0,
|
|
"bad_type_ignore": 0,
|
|
"bare_except": 0,
|
|
"duplicate_blocks": 0,
|
|
"cast_any_files": set(),
|
|
"type_ignore_files": set(),
|
|
"bad_type_ignore_files": set(),
|
|
"bare_except_files": set(),
|
|
"dup_files": [],
|
|
}
|
|
|
|
# Patterns to detect
|
|
cast_any_re = re.compile(r"cast\s*\(\s*Any\s*,", re.MULTILINE)
|
|
type_ignore_re = re.compile(r"#\s*type:\s*ignore", re.MULTILINE)
|
|
bare_except_re = re.compile(
|
|
r"except\s*:\s*$", re.MULTILINE
|
|
)
|
|
|
|
# Bad type: ignore patterns (unresolved-import, class-alias, etc.)
|
|
bad_ignore_re = re.compile(
|
|
r"#\s*type:\s*ignore"
|
|
r"\[[\w-]*(?:import|no-name-module|attr-defined|class-var|assignment|type-var|misc)[\w-]*\]",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
for py_file in ASTRBOT_ROOT.rglob("*.py"):
|
|
try:
|
|
content = py_file.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
|
|
if py_file.suffix == ".pyi":
|
|
continue
|
|
|
|
rel = str(py_file.relative_to(ASTRBOT_ROOT.parent))
|
|
|
|
cast_matches = cast_any_re.findall(content)
|
|
ignore_matches = type_ignore_re.findall(content)
|
|
bad_ignore_matches = bad_ignore_re.findall(content)
|
|
# Only count bare `except:` (no specific exception type), not `except Exception:`
|
|
bare_except_matches = bare_except_re.findall(content)
|
|
|
|
if cast_matches:
|
|
counts["cast_any"] += len(cast_matches)
|
|
counts["cast_any_files"].add(rel)
|
|
|
|
if ignore_matches:
|
|
counts["type_ignore"] += len(ignore_matches)
|
|
counts["type_ignore_files"].add(rel)
|
|
|
|
if bad_ignore_matches:
|
|
counts["bad_type_ignore"] += len(bad_ignore_matches)
|
|
counts["bad_type_ignore_files"].add(rel)
|
|
|
|
if bare_except_matches:
|
|
counts["bare_except"] += len(bare_except_matches)
|
|
counts["bare_except_files"].add(rel)
|
|
|
|
# --- Phase 2: duplicate code blocks (5+ identical lines) ---
|
|
# key: block text -> list of (file, start_line-end_line)
|
|
dup_blocks: dict[str, list[tuple[str, str]]] = {}
|
|
WINDOW = 5
|
|
for py_file in ASTRBOT_ROOT.rglob("*.py"):
|
|
if py_file.suffix == ".pyi":
|
|
continue
|
|
try:
|
|
raw_lines = py_file.read_text(encoding="utf-8").splitlines()
|
|
except Exception:
|
|
continue
|
|
rel = str(py_file.relative_to(ASTRBOT_ROOT.parent))
|
|
# Build mapping: cleaned_idx -> original_line_number (1-indexed)
|
|
cleaned_to_orig: dict[int, int] = {}
|
|
cleaned: list[str] = []
|
|
for idx, ln in enumerate(raw_lines):
|
|
stripped = ln.strip()
|
|
if len(stripped) > 2:
|
|
cleaned.append(stripped)
|
|
cleaned_to_orig[len(cleaned) - 1] = idx + 1 # 1-indexed
|
|
n = len(cleaned)
|
|
seen: dict[str, list[int]] = {}
|
|
for i in range(n - WINDOW + 1):
|
|
block = "\n".join(cleaned[i : i + WINDOW])
|
|
if block not in seen:
|
|
seen[block] = []
|
|
seen[block].append(i)
|
|
for block, positions in seen.items():
|
|
if len(positions) >= 2:
|
|
key = block[:60] + "..." if len(block) > 60 else block
|
|
if key not in dup_blocks:
|
|
dup_blocks[key] = []
|
|
for pos in positions:
|
|
start = cleaned_to_orig.get(pos, 0)
|
|
end = cleaned_to_orig.get(pos + WINDOW - 1, start)
|
|
dup_blocks[key].append((rel, f"{start}-{end}"))
|
|
|
|
counts["duplicate_blocks"] = sum(
|
|
max(0, len(locs) - 1) for locs in dup_blocks.values()
|
|
)
|
|
counts["dup_files"] = list(dup_blocks.items())[:20] # type: ignore[index]
|
|
|
|
return counts
|
|
|
|
|
|
def _calculate_score(
|
|
cast_any: int,
|
|
type_ignore: int,
|
|
bad_type_ignore: int,
|
|
bare_except: int,
|
|
dup_blocks: int,
|
|
) -> int:
|
|
"""
|
|
Calculate type safety score out of 100.
|
|
|
|
Deductions:
|
|
- Each cast(Any, ...) costs 1 point
|
|
- Each # type: ignore costs 0.5 points
|
|
- Each bad # type: ignore (unresolved-import, class-alias, ...) costs 3 points
|
|
- Each bare except: costs 0.5 points
|
|
- Each duplicate block costs 2 points
|
|
- Floor at 0
|
|
"""
|
|
deduction = (
|
|
cast_any
|
|
+ type_ignore * 0.5
|
|
+ bad_type_ignore * 3
|
|
+ bare_except * 0.5
|
|
+ dup_blocks * 0.001
|
|
)
|
|
score = max(0, int(100 - deduction))
|
|
return score
|
|
|
|
|
|
def _get_grade(score: int) -> str:
|
|
if score >= 90:
|
|
return "A"
|
|
elif score >= 80:
|
|
return "B"
|
|
elif score >= 70:
|
|
return "C"
|
|
elif score >= 60:
|
|
return "D"
|
|
else:
|
|
return "F"
|
|
|
|
|
|
class TestCodeQualityTyping:
|
|
"""Test suite for type safety scoring."""
|
|
|
|
def test_type_safety_score(self):
|
|
"""
|
|
Type safety score based on cast(Any, ...) and # type: ignore usage.
|
|
|
|
Score = 100 - (cast_any_count * 1) - (type_ignore_count * 0.5)
|
|
Minimum score: 0
|
|
"""
|
|
counts = _scan_for_patterns()
|
|
cast_any = cast(int, counts["cast_any"])
|
|
type_ignore = cast(int, counts["type_ignore"])
|
|
bad_type_ignore = cast(int, counts["bad_type_ignore"])
|
|
bare_except = cast(int, counts["bare_except"])
|
|
dup_blocks = cast(int, counts["duplicate_blocks"])
|
|
cast_any_files = cast("set[str]", counts["cast_any_files"])
|
|
type_ignore_files = cast("set[str]", counts["type_ignore_files"])
|
|
bad_type_ignore_files = cast("set[str]", counts["bad_type_ignore_files"])
|
|
bare_except_files = cast("set[str]", counts["bare_except_files"])
|
|
dup_files = cast("list[tuple[str, str]]", counts["dup_files"])
|
|
score = _calculate_score(
|
|
cast_any, type_ignore, bad_type_ignore, bare_except, dup_blocks
|
|
)
|
|
|
|
print(f"\n{'=' * 60}")
|
|
print(" Type Safety Score Report")
|
|
print(f"{'=' * 60}")
|
|
print(f" cast(Any, ...) count: {cast_any:>4} (cost: {cast_any} pts)")
|
|
print(
|
|
f" # type: ignore count: {type_ignore:>4} (cost: {type_ignore * 0.5:.1f} pts)"
|
|
)
|
|
print(
|
|
f" bad type: ignore: {bad_type_ignore:>4} (cost: {bad_type_ignore * 3} pts)"
|
|
)
|
|
print(
|
|
f" bare except: count: {bare_except:>4} (cost: {bare_except * 0.5:.1f} pts)"
|
|
)
|
|
print(f" duplicate blocks: {dup_blocks:>4} (cost: {dup_blocks * 0.001:.1f} pts)")
|
|
print(f" {'-' * 60}")
|
|
print(f" Score: {score}/100 (Grade: {_get_grade(score)})")
|
|
print(f"{'=' * 60}")
|
|
|
|
if cast_any_files:
|
|
print("\n Files with cast(Any, ...):")
|
|
for f in sorted(cast_any_files)[:10]:
|
|
print(f" - {f}")
|
|
if len(cast_any_files) > 10:
|
|
print(f" ... and {len(cast_any_files) - 10} more")
|
|
print()
|
|
|
|
if type_ignore_files:
|
|
print(" Files with # type: ignore:")
|
|
for f in sorted(type_ignore_files)[:10]:
|
|
print(f" - {f}")
|
|
if len(type_ignore_files) > 10:
|
|
print(f" ... and {len(type_ignore_files) - 10} more")
|
|
print()
|
|
|
|
if bad_type_ignore_files:
|
|
print(" Files with BAD type: ignore (import/alias/...):")
|
|
for f in sorted(bad_type_ignore_files)[:10]:
|
|
print(f" - {f}")
|
|
if len(bad_type_ignore_files) > 10:
|
|
print(f" ... and {len(bad_type_ignore_files) - 10} more")
|
|
print()
|
|
|
|
if bare_except_files:
|
|
print(" Files with bare except:")
|
|
for f in sorted(bare_except_files)[:10]:
|
|
print(f" - {f}")
|
|
if len(bare_except_files) > 10:
|
|
print(f" ... and {len(bare_except_files) - 10} more")
|
|
print()
|
|
|
|
if dup_files:
|
|
print(" Duplicate code blocks (top 10):")
|
|
for block_preview, locations in dup_files[:10]:
|
|
# Group by file
|
|
by_file: dict[str, list[str]] = {}
|
|
for f, rng in locations:
|
|
if f not in by_file:
|
|
by_file[f] = []
|
|
by_file[f].append(rng)
|
|
parts = [f"{f}: {', '.join(rngs)}" for f, rngs in by_file.items()]
|
|
print(f" - [{block_preview}]")
|
|
for part in parts:
|
|
print(f" {part}")
|
|
print()
|
|
|
|
print(" WARNING: This is a custom heuristic. Score may not reflect")
|
|
print(" actual type safety. Review individual cases manually.")
|
|
print(f"{'=' * 60}\n")
|
|
|
|
# Emit warning level based on score
|
|
if score < 60:
|
|
pytest.fail(
|
|
f"Type safety score too low: {score}/100 (Grade: {_get_grade(score)})"
|
|
)
|
|
elif score < 80:
|
|
pytest.skip(
|
|
f"Type safety score below target: {score}/100 (Grade: {_get_grade(score)})"
|
|
)
|