devsquad 3.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devsquad-3.6.0.dist-info/METADATA +944 -0
- devsquad-3.6.0.dist-info/RECORD +95 -0
- devsquad-3.6.0.dist-info/WHEEL +5 -0
- devsquad-3.6.0.dist-info/entry_points.txt +2 -0
- devsquad-3.6.0.dist-info/licenses/LICENSE +21 -0
- devsquad-3.6.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +0 -0
- scripts/ai_semantic_matcher.py +512 -0
- scripts/alert_manager.py +505 -0
- scripts/api/__init__.py +43 -0
- scripts/api/models.py +386 -0
- scripts/api/routes/__init__.py +20 -0
- scripts/api/routes/dispatch.py +348 -0
- scripts/api/routes/lifecycle.py +330 -0
- scripts/api/routes/metrics_gates.py +347 -0
- scripts/api_server.py +318 -0
- scripts/auth.py +451 -0
- scripts/cli/__init__.py +1 -0
- scripts/cli/cli_visual.py +642 -0
- scripts/cli.py +1094 -0
- scripts/collaboration/__init__.py +212 -0
- scripts/collaboration/_version.py +1 -0
- scripts/collaboration/agent_briefing.py +656 -0
- scripts/collaboration/ai_semantic_matcher.py +260 -0
- scripts/collaboration/anchor_checker.py +281 -0
- scripts/collaboration/anti_rationalization.py +470 -0
- scripts/collaboration/async_integration_example.py +255 -0
- scripts/collaboration/batch_scheduler.py +149 -0
- scripts/collaboration/checkpoint_manager.py +561 -0
- scripts/collaboration/ci_feedback_adapter.py +351 -0
- scripts/collaboration/code_map_generator.py +247 -0
- scripts/collaboration/concern_pack_loader.py +352 -0
- scripts/collaboration/confidence_score.py +496 -0
- scripts/collaboration/config_loader.py +188 -0
- scripts/collaboration/consensus.py +244 -0
- scripts/collaboration/context_compressor.py +533 -0
- scripts/collaboration/coordinator.py +668 -0
- scripts/collaboration/dispatcher.py +1636 -0
- scripts/collaboration/dual_layer_context.py +128 -0
- scripts/collaboration/enhanced_worker.py +539 -0
- scripts/collaboration/feature_usage_tracker.py +206 -0
- scripts/collaboration/five_axis_consensus.py +334 -0
- scripts/collaboration/input_validator.py +401 -0
- scripts/collaboration/integration_example.py +287 -0
- scripts/collaboration/intent_workflow_mapper.py +350 -0
- scripts/collaboration/language_parsers.py +269 -0
- scripts/collaboration/lifecycle_protocol.py +1446 -0
- scripts/collaboration/llm_backend.py +453 -0
- scripts/collaboration/llm_cache.py +448 -0
- scripts/collaboration/llm_cache_async.py +347 -0
- scripts/collaboration/llm_retry.py +387 -0
- scripts/collaboration/llm_retry_async.py +389 -0
- scripts/collaboration/mce_adapter.py +597 -0
- scripts/collaboration/memory_bridge.py +1607 -0
- scripts/collaboration/models.py +537 -0
- scripts/collaboration/null_providers.py +297 -0
- scripts/collaboration/operation_classifier.py +289 -0
- scripts/collaboration/output_slicer.py +225 -0
- scripts/collaboration/performance_monitor.py +462 -0
- scripts/collaboration/permission_guard.py +865 -0
- scripts/collaboration/prompt_assembler.py +756 -0
- scripts/collaboration/prompt_variant_generator.py +483 -0
- scripts/collaboration/protocols.py +267 -0
- scripts/collaboration/report_formatter.py +352 -0
- scripts/collaboration/retrospective.py +279 -0
- scripts/collaboration/role_matcher.py +92 -0
- scripts/collaboration/role_template_market.py +352 -0
- scripts/collaboration/rule_collector.py +678 -0
- scripts/collaboration/scratchpad.py +346 -0
- scripts/collaboration/skill_registry.py +151 -0
- scripts/collaboration/skillifier.py +878 -0
- scripts/collaboration/standardized_role_template.py +317 -0
- scripts/collaboration/task_completion_checker.py +237 -0
- scripts/collaboration/test_quality_guard.py +695 -0
- scripts/collaboration/unified_gate_engine.py +598 -0
- scripts/collaboration/usage_tracker.py +309 -0
- scripts/collaboration/user_friendly_error.py +176 -0
- scripts/collaboration/verification_gate.py +312 -0
- scripts/collaboration/warmup_manager.py +635 -0
- scripts/collaboration/worker.py +513 -0
- scripts/collaboration/workflow_engine.py +684 -0
- scripts/dashboard.py +1088 -0
- scripts/generate_benchmark_report.py +786 -0
- scripts/history_manager.py +604 -0
- scripts/mcp_server.py +289 -0
- skills/__init__.py +32 -0
- skills/dispatch/handler.py +52 -0
- skills/intent/handler.py +59 -0
- skills/registry.py +67 -0
- skills/retrospective/__init__.py +0 -0
- skills/retrospective/handler.py +125 -0
- skills/review/handler.py +356 -0
- skills/security/handler.py +454 -0
- skills/test/__init__.py +0 -0
- skills/test/handler.py +78 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
TestQualityGuard - 测试质量守卫
|
|
5
|
+
|
|
6
|
+
解决 AI 测试的三大顽疾:
|
|
7
|
+
1) 不看 API 文档凭空写 → API 签名校验
|
|
8
|
+
2) 为通过而改测试 → 测试目的声明 + 失败报告生成
|
|
9
|
+
3) 覆盖率低缺维度 → 多维度覆盖强制检查
|
|
10
|
+
|
|
11
|
+
核心能力:
|
|
12
|
+
- APISignatureValidator: 校验测试代码是否与实际 API 签名一致
|
|
13
|
+
- TestPurposeRegistry: 强制每个 test_ 函数声明验证目的
|
|
14
|
+
- CoverageDimensionChecker: 检查 happy/error/perf/config 四维度覆盖
|
|
15
|
+
- TestQualityReport: 生成质量报告(而非简单 pass/fail)
|
|
16
|
+
- AntiPatternDetector: 检测 "为通过而改" 的反模式
|
|
17
|
+
|
|
18
|
+
使用示例:
|
|
19
|
+
from scripts.collaboration.test_quality_guard import TestQualityGuard
|
|
20
|
+
|
|
21
|
+
guard = TestQualityGuard(module_path="scripts/collaboration/coordinator.py",
|
|
22
|
+
test_path="scripts/collaboration/coordinator_test.py")
|
|
23
|
+
report = guard.audit()
|
|
24
|
+
print(report.to_markdown())
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import ast
|
|
30
|
+
import time
|
|
31
|
+
import inspect
|
|
32
|
+
import textwrap
|
|
33
|
+
import importlib.util
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Dict, List, Optional, Any, Tuple, Set
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Severity(Enum):
|
|
42
|
+
CRITICAL = "critical"
|
|
43
|
+
MAJOR = "major"
|
|
44
|
+
MINOR = "minor"
|
|
45
|
+
INFO = "info"
|
|
46
|
+
SUGGESTION = "suggestion"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestDimension(Enum):
|
|
50
|
+
HAPPY_PATH = "happy_path"
|
|
51
|
+
ERROR_CASE = "error_case"
|
|
52
|
+
BOUNDARY = "boundary"
|
|
53
|
+
PERFORMANCE = "performance"
|
|
54
|
+
CONFIGURATION = "configuration"
|
|
55
|
+
INTEGRATION = "integration"
|
|
56
|
+
SECURITY = "security"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
TestDimension.__test__ = False # Tell pytest this is not a test class
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class QualityIssue:
|
|
64
|
+
id: str
|
|
65
|
+
severity: Severity
|
|
66
|
+
category: str
|
|
67
|
+
message: str
|
|
68
|
+
file: str
|
|
69
|
+
line: int = 0
|
|
70
|
+
suggestion: str = ""
|
|
71
|
+
auto_fixable: bool = False
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"id": self.id,
|
|
76
|
+
"severity": self.severity.value,
|
|
77
|
+
"category": self.category,
|
|
78
|
+
"message": self.message,
|
|
79
|
+
"file": self.file,
|
|
80
|
+
"line": self.line,
|
|
81
|
+
"suggestion": self.suggestion,
|
|
82
|
+
"auto_fixable": self.auto_fixable,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class TestFunctionMeta:
|
|
88
|
+
name: str
|
|
89
|
+
line: int
|
|
90
|
+
has_purpose: bool = False
|
|
91
|
+
purpose_text: str = ""
|
|
92
|
+
dimension: Optional[TestDimension] = None
|
|
93
|
+
assert_count: int = 0
|
|
94
|
+
assert_types: List[str] = field(default_factory=list)
|
|
95
|
+
has_error_test: bool = False
|
|
96
|
+
has_performance_check: bool = False
|
|
97
|
+
docstring: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
TestFunctionMeta.__test__ = False # Tell pytest this is not a test class
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class APISignature:
|
|
105
|
+
name: str
|
|
106
|
+
kind: str
|
|
107
|
+
params: List[Dict[str, str]] = field(default_factory=list)
|
|
108
|
+
return_type: str = ""
|
|
109
|
+
file: str = ""
|
|
110
|
+
line: int = 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class QualityScore:
|
|
115
|
+
api_compliance: float = 0.0
|
|
116
|
+
purpose_coverage: float = 0.0
|
|
117
|
+
dimension_balance: float = 0.0
|
|
118
|
+
anti_pattern_free: float = 0.0
|
|
119
|
+
overall: float = 0.0
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"api_compliance": round(self.api_compliance, 2),
|
|
124
|
+
"purpose_coverage": round(self.purpose_coverage, 2),
|
|
125
|
+
"dimension_balance": round(self.dimension_balance, 2),
|
|
126
|
+
"anti_pattern_free": round(self.anti_pattern_free, 2),
|
|
127
|
+
"overall": round(self.overall, 2),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class TestQualityReport:
|
|
133
|
+
module_name: str
|
|
134
|
+
test_file: str
|
|
135
|
+
source_file: str
|
|
136
|
+
total_tests: int = 0
|
|
137
|
+
issues: List[QualityIssue] = field(default_factory=list)
|
|
138
|
+
test_functions: List[TestFunctionMeta] = field(default_factory=list)
|
|
139
|
+
api_signatures: List[APISignature] = field(default_factory=list)
|
|
140
|
+
score: QualityScore = field(default_factory=QualityScore)
|
|
141
|
+
audit_time: float = 0.0
|
|
142
|
+
timestamp: str = ""
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def critical_count(self) -> int:
|
|
146
|
+
return sum(1 for i in self.issues if i.severity == Severity.CRITICAL)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def major_count(self) -> int:
|
|
150
|
+
return sum(1 for i in self.issues if i.severity == Severity.MAJOR)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def minor_count(self) -> int:
|
|
154
|
+
return sum(1 for i in self.issues if i.severity == Severity.MINOR)
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
157
|
+
return {
|
|
158
|
+
"module_name": self.module_name,
|
|
159
|
+
"test_file": self.test_file,
|
|
160
|
+
"source_file": self.source_file,
|
|
161
|
+
"total_tests": self.total_tests,
|
|
162
|
+
"issue_count": len(self.issues),
|
|
163
|
+
"critical": self.critical_count,
|
|
164
|
+
"major": self.major_count,
|
|
165
|
+
"minor": self.minor_count,
|
|
166
|
+
"score": self.score.to_dict(),
|
|
167
|
+
"audit_time_s": round(self.audit_time, 3),
|
|
168
|
+
"timestamp": self.timestamp,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def to_markdown(self) -> str:
|
|
172
|
+
lines = [
|
|
173
|
+
f"# TestQualityGuard 审计报告",
|
|
174
|
+
"",
|
|
175
|
+
f"**模块**: {self.module_name}",
|
|
176
|
+
f"**测试文件**: {self.test_file}",
|
|
177
|
+
f"**源文件**: {self.source_file}",
|
|
178
|
+
f"**审计时间**: {self.timestamp}",
|
|
179
|
+
f"**总耗时**: {self.audit_time:.3f}s",
|
|
180
|
+
"",
|
|
181
|
+
"## 评分总览",
|
|
182
|
+
"",
|
|
183
|
+
f"| 维度 | 得分 | 说明 |",
|
|
184
|
+
f"|------|------|------|",
|
|
185
|
+
f"| API 合规性 | {self.score.api_compliance:.0%} | 测试调用是否匹配实际 API 签名 |",
|
|
186
|
+
f"| 目的声明率 | {self.score.purpose_coverage:.0%} | 测试函数是否声明了验证目的 |",
|
|
187
|
+
f"| 维度平衡度 | {self.score.dimension_balance:.0%} | 各测试维度是否均衡 |",
|
|
188
|
+
f"| 反模式检测 | {self.score.anti_pattern_free:.0%} | 是否存在 '为通过而改' 反模式 |",
|
|
189
|
+
f"| **综合得分** | **{self.score.overall:.0%}** | |",
|
|
190
|
+
"",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
if self.issues:
|
|
194
|
+
lines.extend([
|
|
195
|
+
"## 问题清单",
|
|
196
|
+
"",
|
|
197
|
+
f"🔴 **严重**: {self.critical_count} | 🟠 **主要**: {self.major_count} | 🟡 **次要**: {self.minor_count}",
|
|
198
|
+
"",
|
|
199
|
+
])
|
|
200
|
+
for issue in self.issues:
|
|
201
|
+
icon = {"critical": "🔴", "major": "🟠", "minor": "🟡", "info": "🔵", "suggestion": "💡"}.get(issue.severity.value, "⚪")
|
|
202
|
+
lines.append(f"- {icon} **[{issue.category}]** {issue.message}")
|
|
203
|
+
if issue.suggestion:
|
|
204
|
+
lines.append(f" → 建议: {issue.suggestion}")
|
|
205
|
+
if issue.line:
|
|
206
|
+
lines.append(f" → 位置: {issue.file}:{issue.line}")
|
|
207
|
+
lines.append("")
|
|
208
|
+
|
|
209
|
+
if self.test_functions:
|
|
210
|
+
has_purpose = sum(1 for t in self.test_functions if t.has_purpose)
|
|
211
|
+
lines.extend([
|
|
212
|
+
"## 测试函数清单",
|
|
213
|
+
"",
|
|
214
|
+
f"总计: {len(self.test_functions)} 个 | 有目的声明: {has_purpose} ({has_purpose/max(len(self.test_functions),1):.0%})",
|
|
215
|
+
"",
|
|
216
|
+
"| # | 函数名 | 目的声明 | 维度 | 断言数 | 异常测试 | 性能测试 |",
|
|
217
|
+
"|---|--------|---------|------|--------|---------|---------|",
|
|
218
|
+
])
|
|
219
|
+
for idx, tf in enumerate(self.test_functions, 1):
|
|
220
|
+
purpose_icon = "✅" if tf.has_purpose else "❌"
|
|
221
|
+
dim = tf.dimension.value if tf.dimension else "-"
|
|
222
|
+
err_icon = "✅" if tf.has_error_test else "-"
|
|
223
|
+
perf_icon = "✅" if tf.has_performance_check else "-"
|
|
224
|
+
lines.append(
|
|
225
|
+
f"| {idx} | `{tf.name}` | {purpose_icon} | {dim} | {tf.assert_count} | {err_icon} | {perf_icon} |"
|
|
226
|
+
)
|
|
227
|
+
lines.append("")
|
|
228
|
+
|
|
229
|
+
lines.extend([
|
|
230
|
+
"---",
|
|
231
|
+
f"*由 TestQualityGuard v1.0 自动生成*",
|
|
232
|
+
])
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
TestQualityReport.__test__ = False # Tell pytest this is not a test class
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class APISignatureValidator:
|
|
240
|
+
"""检查测试代码中的 API 调用是否与实际签名一致"""
|
|
241
|
+
|
|
242
|
+
PARAM_PATTERN = re.compile(r'(\w+)\s*=\s*')
|
|
243
|
+
|
|
244
|
+
def __init__(self):
|
|
245
|
+
self.api_cache: Dict[str, List[APISignature]] = {}
|
|
246
|
+
|
|
247
|
+
def extract_api_signatures(self, source_code: str, file_path: str) -> List[APISignature]:
|
|
248
|
+
signatures = []
|
|
249
|
+
try:
|
|
250
|
+
tree = ast.parse(source_code)
|
|
251
|
+
except SyntaxError:
|
|
252
|
+
return signatures
|
|
253
|
+
|
|
254
|
+
for node in ast.walk(tree):
|
|
255
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
256
|
+
has_self = any(arg.arg == 'self' for arg in node.args.args)
|
|
257
|
+
params = []
|
|
258
|
+
for arg in node.args.args:
|
|
259
|
+
if arg.arg != 'self':
|
|
260
|
+
annotation = ""
|
|
261
|
+
if arg.annotation:
|
|
262
|
+
try:
|
|
263
|
+
annotation = ast.unparse(arg.annotation)
|
|
264
|
+
except Exception:
|
|
265
|
+
annotation = "?"
|
|
266
|
+
params.append({"name": arg.arg, "type": annotation})
|
|
267
|
+
sig = APISignature(
|
|
268
|
+
name=node.name,
|
|
269
|
+
kind="method" if has_self else "function",
|
|
270
|
+
params=params,
|
|
271
|
+
file=file_path,
|
|
272
|
+
line=node.lineno,
|
|
273
|
+
)
|
|
274
|
+
signatures.append(sig)
|
|
275
|
+
return signatures
|
|
276
|
+
|
|
277
|
+
def validate_call_against_signature(self,
|
|
278
|
+
call_name: str,
|
|
279
|
+
call_kwargs: Set[str],
|
|
280
|
+
signatures: List[APISignature]) -> List[QualityIssue]:
|
|
281
|
+
issues = []
|
|
282
|
+
matching_sigs = [s for s in signatures if s.name == call_name]
|
|
283
|
+
if not matching_sigs:
|
|
284
|
+
return issues
|
|
285
|
+
|
|
286
|
+
for sig in matching_sigs:
|
|
287
|
+
param_names = {p['name'] for p in sig.params if p['name'] != 'self'}
|
|
288
|
+
for kw in call_kwargs:
|
|
289
|
+
if kw not in param_names and not kw.startswith('_'):
|
|
290
|
+
issues.append(QualityIssue(
|
|
291
|
+
id=f"api-param-{call_name}-{kw}",
|
|
292
|
+
severity=Severity.MAJOR,
|
|
293
|
+
category="API参数错误",
|
|
294
|
+
message=f"`{call_name}()` 使用了不存在的参数 `{kw}`,实际签名为: {[p['name'] for p in sig.params]}",
|
|
295
|
+
file=sig.file,
|
|
296
|
+
line=sig.line,
|
|
297
|
+
suggestion=f"检查 `{call_name}` 的实际签名,将 `{kw}` 改为正确的参数名",
|
|
298
|
+
auto_fixable=False,
|
|
299
|
+
))
|
|
300
|
+
return issues
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class AntiPatternDetector:
|
|
304
|
+
"""检测 '为测试通过而修改测试' 的反模式"""
|
|
305
|
+
|
|
306
|
+
SUSPICIOUS_PATTERNS = [
|
|
307
|
+
{
|
|
308
|
+
"id": "anti-loose-assert",
|
|
309
|
+
"pattern": re.compile(r'assertTrue\(.+\)'),
|
|
310
|
+
"description": "使用 assertTrue 替代精确断言(可能为了绕过失败)",
|
|
311
|
+
"severity": Severity.MINOR,
|
|
312
|
+
"category": "宽松断言",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"id": "anti-relaxed-float",
|
|
316
|
+
"pattern": re.compile(r'assertGreater\(.*,\s*0\.0\)'),
|
|
317
|
+
"description": "浮点数比较用 0.0 作为下限(几乎必然通过)",
|
|
318
|
+
"severity": Severity.MINOR,
|
|
319
|
+
"category": "无效断言",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"id": "anti-no-error-test",
|
|
323
|
+
"pattern": None,
|
|
324
|
+
"description": "整个测试类无异常/错误测试用例",
|
|
325
|
+
"severity": Severity.MAJOR,
|
|
326
|
+
"category": "缺失错误测试",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"id": "anti-no-purpose-doc",
|
|
330
|
+
"pattern": None,
|
|
331
|
+
"description": "测试函数无目的声明注释或docstring",
|
|
332
|
+
"severity": Severity.INFO,
|
|
333
|
+
"category": "缺少目的声明",
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"id": "anti-bare-except",
|
|
337
|
+
"pattern": re.compile(r'except\s*:'),
|
|
338
|
+
"description": "裸 except 子句(吞掉所有异常)",
|
|
339
|
+
"severity": Severity.MAJOR,
|
|
340
|
+
"category": "异常吞噬",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"id": "anti-magic-number",
|
|
344
|
+
"pattern": re.compile(r'(assertEqual|assertGreater|assertLess)\([^,]+,\s*\d{3,}\)'),
|
|
345
|
+
"description": "断言中使用大数字魔法值(可能是凑出来的阈值)",
|
|
346
|
+
"severity": Severity.MINOR,
|
|
347
|
+
"category": "魔法数字",
|
|
348
|
+
},
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
def detect_in_source(self, source: str, file: str) -> List[QualityIssue]:
|
|
352
|
+
issues = []
|
|
353
|
+
lines = source.split('\n')
|
|
354
|
+
for idx, line in enumerate(lines, 1):
|
|
355
|
+
stripped = line.strip()
|
|
356
|
+
for pat in self.SUSPICIOUS_PATTERNS:
|
|
357
|
+
if pat["pattern"] and pat["pattern"].search(stripped):
|
|
358
|
+
issues.append(QualityIssue(
|
|
359
|
+
id=pat["id"],
|
|
360
|
+
severity=pat["severity"],
|
|
361
|
+
category=pat["category"],
|
|
362
|
+
message=pat["description"],
|
|
363
|
+
file=file,
|
|
364
|
+
line=idx,
|
|
365
|
+
suggestion=self._get_suggestion(pat["id"]),
|
|
366
|
+
auto_fixable=False,
|
|
367
|
+
))
|
|
368
|
+
return issues
|
|
369
|
+
|
|
370
|
+
def _get_suggestion(self, pattern_id: str) -> str:
|
|
371
|
+
suggestions = {
|
|
372
|
+
"anti-loose-assert": "使用精确断言如 assertEqual/assertIn 替代 assertTrue",
|
|
373
|
+
"anti-relaxed-float": "设置有意义的性能阈值,如 assertGreater(score, 0.5)",
|
|
374
|
+
"anti-no-error-test": "添加至少一个异常场景测试用例",
|
|
375
|
+
"anti-no-purpose-doc": "在测试函数前添加注释说明验证目的",
|
|
376
|
+
"anti-bare-except": "指定具体异常类型如 except ValueError",
|
|
377
|
+
"anti-magic-number": "提取为命名常量并添加注释说明来源",
|
|
378
|
+
}
|
|
379
|
+
return suggestions.get(pattern_id, "")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class TestPurposeParser:
|
|
383
|
+
"""解析测试函数的目的声明"""
|
|
384
|
+
|
|
385
|
+
PURPOSE_MARKERS = ['#', '"""', "'''"]
|
|
386
|
+
PURPOSE_KEYWORDS = ['verify', 'check', 'test', 'ensure', 'validate',
|
|
387
|
+
'验证', '检查', '测试', '确保', '确认',
|
|
388
|
+
'should', 'expect', '当', 'when']
|
|
389
|
+
|
|
390
|
+
DIMENSION_KEYWORDS = {
|
|
391
|
+
TestDimension.ERROR_CASE: ['error', 'exception', 'invalid', 'fail',
|
|
392
|
+
'错误', '异常', '非法', '失败', 'raises'],
|
|
393
|
+
TestDimension.PERFORMANCE: ['performance', 'speed', 'latency', 'timing',
|
|
394
|
+
'benchmark', 'perf', '性能', '延迟', '耗时'],
|
|
395
|
+
TestDimension.BOUNDARY: ['boundary', 'edge', 'empty', 'null', 'zero',
|
|
396
|
+
'max', 'min', '边界', '空', '极限'],
|
|
397
|
+
TestDimension.CONFIGURATION: ['config', 'setting', 'option', 'env',
|
|
398
|
+
'配置', '设定', '环境变量'],
|
|
399
|
+
TestDimension.INTEGRATION: ['integration', 'e2e', 'end.to.end',
|
|
400
|
+
'集成', '端到端'],
|
|
401
|
+
TestDimension.SECURITY: ['security', 'auth', 'permission', 'inject',
|
|
402
|
+
'安全', '权限', '注入'],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
def parse_function(self, func_node: ast.FunctionDef, source_lines: List[str]) -> TestFunctionMeta:
|
|
406
|
+
meta = TestFunctionMeta(
|
|
407
|
+
name=func_node.name,
|
|
408
|
+
line=func_node.lineno,
|
|
409
|
+
has_purpose=False,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
docstring = ast.get_docstring(func_node)
|
|
413
|
+
if docstring:
|
|
414
|
+
meta.docstring = docstring
|
|
415
|
+
meta.has_purpose = True
|
|
416
|
+
meta.purpose_text = docstring.strip().split('\n')[0][:200]
|
|
417
|
+
|
|
418
|
+
start_line = func_node.lineno - 1
|
|
419
|
+
end_line = func_node.end_lineno or start_line + 1
|
|
420
|
+
func_source = '\n'.join(source_lines[start_line:end_line])
|
|
421
|
+
|
|
422
|
+
if not meta.has_purpose:
|
|
423
|
+
for i in range(start_line, min(end_line, start_line + 5, len(source_lines))):
|
|
424
|
+
if i >= 0 and i < len(source_lines):
|
|
425
|
+
line = source_lines[i].strip()
|
|
426
|
+
if line.startswith('#') and any(kw in line.lower() for kw in self.PURPOSE_KEYWORDS):
|
|
427
|
+
meta.has_purpose = True
|
|
428
|
+
meta.purpose_text = line.lstrip('#').strip()[:200]
|
|
429
|
+
break
|
|
430
|
+
|
|
431
|
+
lower_source = func_source.lower()
|
|
432
|
+
meta.has_error_test = ('assertRaises' in func_source or
|
|
433
|
+
'except' in func_source or
|
|
434
|
+
'error' in lower_source or
|
|
435
|
+
'exception' in lower_source or
|
|
436
|
+
'invalid' in lower_source)
|
|
437
|
+
meta.has_performance_check = any(kw in lower_source
|
|
438
|
+
for kw in ['time', 'duration', 'latency',
|
|
439
|
+
'benchmark', 'performance',
|
|
440
|
+
'timing'])
|
|
441
|
+
|
|
442
|
+
meta.dimension = self._infer_dimension(func_source)
|
|
443
|
+
|
|
444
|
+
assert_types = re.findall(r'self\.(assert\w+)', func_source)
|
|
445
|
+
meta.assert_types = list(set(assert_types))
|
|
446
|
+
meta.assert_count = len(re.findall(r'self\.assert\w+', func_source))
|
|
447
|
+
|
|
448
|
+
return meta
|
|
449
|
+
|
|
450
|
+
def _infer_dimension(self, source: str) -> Optional[TestDimension]:
|
|
451
|
+
lower = source.lower()
|
|
452
|
+
scores = {}
|
|
453
|
+
for dim, keywords in self.DIMENSION_KEYWORDS.items():
|
|
454
|
+
score = sum(1 for kw in keywords if kw in lower)
|
|
455
|
+
if score > 0:
|
|
456
|
+
scores[dim] = score
|
|
457
|
+
if scores:
|
|
458
|
+
return max(scores, key=scores.get)
|
|
459
|
+
return TestDimension.HAPPY_PATH
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TestQualityGuard:
|
|
463
|
+
"""
|
|
464
|
+
测试质量守卫 - 主入口
|
|
465
|
+
|
|
466
|
+
对测试文件进行全方位质量审计,
|
|
467
|
+
输出可操作的改进建议报告。
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
def __init__(self,
|
|
471
|
+
module_path: str,
|
|
472
|
+
test_path: str,
|
|
473
|
+
strict_mode: bool = False):
|
|
474
|
+
"""
|
|
475
|
+
Args:
|
|
476
|
+
module_path: 被测模块路径 (如 scripts/collaboration/coordinator.py)
|
|
477
|
+
test_path: 测试文件路径 (如 scripts/collaboration/coordinator_test.py)
|
|
478
|
+
strict_mode: 严格模式(更多检查项)
|
|
479
|
+
"""
|
|
480
|
+
self.module_path = Path(module_path)
|
|
481
|
+
self.test_path = Path(test_path)
|
|
482
|
+
self.strict_mode = strict_mode
|
|
483
|
+
self.api_validator = APISignatureValidator()
|
|
484
|
+
self.anti_detector = AntiPatternDetector()
|
|
485
|
+
self.purpose_parser = TestPurposeParser()
|
|
486
|
+
|
|
487
|
+
def audit(self) -> TestQualityReport:
|
|
488
|
+
"""执行完整审计"""
|
|
489
|
+
start_time = time.time()
|
|
490
|
+
report = TestQualityReport(
|
|
491
|
+
module_name=self.module_path.stem,
|
|
492
|
+
test_file=str(self.test_path),
|
|
493
|
+
source_file=str(self.module_path),
|
|
494
|
+
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
source_code = self._read_file(self.module_path)
|
|
498
|
+
test_code = self._read_file(self.test_path)
|
|
499
|
+
|
|
500
|
+
if not source_code or not test_code:
|
|
501
|
+
report.issues.append(QualityIssue(
|
|
502
|
+
id="file-not-found", severity=Severity.CRITICAL,
|
|
503
|
+
category="文件读取", message="无法读取源文件或测试文件",
|
|
504
|
+
file=str(self.module_path),
|
|
505
|
+
))
|
|
506
|
+
report.audit_time = time.time() - start_time
|
|
507
|
+
return report
|
|
508
|
+
|
|
509
|
+
source_lines = source_code.split('\n')
|
|
510
|
+
|
|
511
|
+
report.api_signatures = self.api_validator.extract_api_signatures(source_code, str(self.module_path))
|
|
512
|
+
|
|
513
|
+
test_tree = ast.parse(test_code)
|
|
514
|
+
test_funcs = [node for node in ast.walk(test_tree)
|
|
515
|
+
if isinstance(node, ast.FunctionDef) and node.name.startswith('test_')]
|
|
516
|
+
report.total_tests = len(test_funcs)
|
|
517
|
+
|
|
518
|
+
for func_node in test_funcs:
|
|
519
|
+
meta = self.purpose_parser.parse_function(func_node, source_lines)
|
|
520
|
+
report.test_functions.append(meta)
|
|
521
|
+
|
|
522
|
+
if not meta.has_purpose:
|
|
523
|
+
report.issues.append(QualityIssue(
|
|
524
|
+
id=f"no-purpose-{meta.name}",
|
|
525
|
+
severity=Severity.INFO,
|
|
526
|
+
category="缺少目的声明",
|
|
527
|
+
message=f"`{meta.name}` 缺少测试目的声明(注释或docstring)",
|
|
528
|
+
file=str(self.test_path),
|
|
529
|
+
line=meta.line,
|
|
530
|
+
suggestion="添加 '# 验证: ...' 注释或 docstring 说明此测试验证什么",
|
|
531
|
+
auto_fixable=True,
|
|
532
|
+
))
|
|
533
|
+
|
|
534
|
+
anti_issues = self.anti_detector.detect_in_source(test_code, str(self.test_path))
|
|
535
|
+
report.issues.extend(anti_issues)
|
|
536
|
+
|
|
537
|
+
no_error_class = not any(tf.has_error_test for tf in report.test_functions)
|
|
538
|
+
if no_error_class and report.total_tests > 5:
|
|
539
|
+
report.issues.append(QualityIssue(
|
|
540
|
+
id="no-error-tests",
|
|
541
|
+
severity=Severity.MAJOR,
|
|
542
|
+
category="缺失错误测试",
|
|
543
|
+
message=f"{report.total_tests}个测试中无异常/错误场景测试",
|
|
544
|
+
file=str(self.test_path),
|
|
545
|
+
suggestion="添加至少 15% 的错误/异常测试用例(如非法输入、网络超时、权限不足等)",
|
|
546
|
+
))
|
|
547
|
+
|
|
548
|
+
no_perf = not any(tf.has_performance_check for tf in report.test_functions)
|
|
549
|
+
if no_perf and report.total_tests > 5:
|
|
550
|
+
report.issues.append(QualityIssue(
|
|
551
|
+
id="no-perf-tests",
|
|
552
|
+
severity=Severity.MINOR,
|
|
553
|
+
category="缺失性能测试",
|
|
554
|
+
message="无性能/耗时相关测试",
|
|
555
|
+
file=str(self.test_path),
|
|
556
|
+
suggestion="添加关键路径的性能基准测试(如操作应在 Nms 内完成)",
|
|
557
|
+
))
|
|
558
|
+
|
|
559
|
+
report.score = self._calculate_score(report)
|
|
560
|
+
report.audit_time = time.time() - start_time
|
|
561
|
+
return report
|
|
562
|
+
|
|
563
|
+
def _read_file(self, path: Path) -> Optional[str]:
|
|
564
|
+
try:
|
|
565
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
566
|
+
return f.read()
|
|
567
|
+
except FileNotFoundError:
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
def _calculate_score(self, report: TestQualityReport) -> QualityScore:
|
|
571
|
+
score = QualityScore()
|
|
572
|
+
|
|
573
|
+
if report.total_tests > 0:
|
|
574
|
+
purpose_count = sum(1 for t in report.test_functions if t.has_purpose)
|
|
575
|
+
score.purpose_coverage = purpose_count / report.total_tests
|
|
576
|
+
|
|
577
|
+
dim_counts = {}
|
|
578
|
+
for tf in report.test_functions:
|
|
579
|
+
d = tf.dimension or TestDimension.HAPPY_PATH
|
|
580
|
+
dim_counts[d] = dim_counts.get(d, 0) + 1
|
|
581
|
+
if dim_counts:
|
|
582
|
+
values = list(dim_counts.values())
|
|
583
|
+
max_v = max(values)
|
|
584
|
+
min_v = min(values)
|
|
585
|
+
total = sum(values)
|
|
586
|
+
expected_per_dim = total / len(dim_counts)
|
|
587
|
+
variance = sum((v - expected_per_dim) ** 2 for v in values) / len(values)
|
|
588
|
+
ideal_variance = 0
|
|
589
|
+
score.dimension_balance = max(0, 1 - (variance / (total * total / 4)))
|
|
590
|
+
|
|
591
|
+
anti_critical = sum(1 for i in report.issues
|
|
592
|
+
if i.severity in (Severity.CRITICAL, Severity.MAJOR)
|
|
593
|
+
and i.category in ('宽松断言', '异常吞噬'))
|
|
594
|
+
score.anti_pattern_free = max(0, 1 - anti_critical / max(report.total_tests * 0.1, 1))
|
|
595
|
+
|
|
596
|
+
api_issues = sum(1 for i in report.issues if i.category == "API参数错误")
|
|
597
|
+
score.api_compliance = max(0, 1 - api_issues / max(report.total_tests * 0.05, 1))
|
|
598
|
+
|
|
599
|
+
score.overall = (
|
|
600
|
+
score.api_compliance * 0.25 +
|
|
601
|
+
score.purpose_coverage * 0.30 +
|
|
602
|
+
score.dimension_balance * 0.20 +
|
|
603
|
+
score.anti_pattern_free * 0.25
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return score
|
|
607
|
+
|
|
608
|
+
def audit_project(self, project_root: str,
|
|
609
|
+
pattern: str = "**/*_test.py") -> List[TestQualityReport]:
|
|
610
|
+
"""审计整个项目的所有测试文件"""
|
|
611
|
+
reports = []
|
|
612
|
+
root = Path(project_root)
|
|
613
|
+
test_files = sorted(root.glob(pattern))
|
|
614
|
+
|
|
615
|
+
for test_file in test_files:
|
|
616
|
+
module_name = test_file.name.replace("_test.py", ".py")
|
|
617
|
+
candidates = [
|
|
618
|
+
test_file.parent / module_name,
|
|
619
|
+
test_file.parent / module_name.replace(".py", "/__init__.py"),
|
|
620
|
+
]
|
|
621
|
+
for candidate in candidates:
|
|
622
|
+
if candidate.exists():
|
|
623
|
+
try:
|
|
624
|
+
r = self.__class__(str(candidate), str(test_file)).audit()
|
|
625
|
+
reports.append(r)
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
break
|
|
629
|
+
return reports
|
|
630
|
+
|
|
631
|
+
def generate_test_template(self,
|
|
632
|
+
api_sig: APISignature,
|
|
633
|
+
dimensions: List[TestDimension] = None) -> str:
|
|
634
|
+
"""根据 API 签名生成高质量测试模板"""
|
|
635
|
+
dims = dimensions or [TestDimension.HAPPY_PATH, TestDimension.ERROR_CASE]
|
|
636
|
+
params_str = ", ".join(p['name'] for p in api_sig.params if p['name'] != 'self')
|
|
637
|
+
param_docs = "\n".join(f" {p['name']}: {p['type'] or 'Any'}" for p in api_sig.params if p['name'] != 'self')
|
|
638
|
+
|
|
639
|
+
template = f'''\
|
|
640
|
+
def test_{api_sig.name}_happy_path(self):
|
|
641
|
+
"""验证: {api_sig.name} 正常输入应返回预期结果"""
|
|
642
|
+
# Arrange
|
|
643
|
+
{param_docs if param_docs else " # TODO: 设置正常输入参数"}
|
|
644
|
+
|
|
645
|
+
# Act
|
|
646
|
+
result = self.target.{api_sig.name}({params_str})
|
|
647
|
+
|
|
648
|
+
# Assert
|
|
649
|
+
self.assertIsNotNone(result)
|
|
650
|
+
'''
|
|
651
|
+
|
|
652
|
+
if TestDimension.ERROR_CASE in dims:
|
|
653
|
+
template += f'''
|
|
654
|
+
def test_{api_sig.name}_invalid_input(self):
|
|
655
|
+
"""验证: {api_sig.name} 非法输入应抛出异常或返回错误"""
|
|
656
|
+
# TODO: 测试非法参数、空值、越界等情况
|
|
657
|
+
with self.assertRaises((ValueError, TypeError)):
|
|
658
|
+
self.target.{api_sig.name}(invalid_param="bad_value")
|
|
659
|
+
'''
|
|
660
|
+
|
|
661
|
+
if TestDimension.PERFORMANCE in dims:
|
|
662
|
+
template += f'''
|
|
663
|
+
def test_{api_sig.name}_performance(self):
|
|
664
|
+
"""验证: {api_sig.name} 应在合理时间内完成"""
|
|
665
|
+
import time
|
|
666
|
+
start = time.perf_counter()
|
|
667
|
+
self.target.{api_sig.name}({params_str})
|
|
668
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
669
|
+
self.assertLess(elapsed, 100, f"耗时 {{elapsed:.1f}}ms 超过 100ms 阈值")
|
|
670
|
+
'''
|
|
671
|
+
return template
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
TestQualityGuard.__test__ = False # Tell pytest this is not a test class
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def quick_audit(module_path: str, test_path: str) -> TestQualityReport:
|
|
678
|
+
"""便捷函数:单文件审计"""
|
|
679
|
+
return TestQualityGuard(module_path, test_path).audit()
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def project_audit(project_root: str) -> str:
|
|
683
|
+
"""便捷函数:项目级审计,返回 Markdown 报告"""
|
|
684
|
+
guard = TestQualityGuard("", "")
|
|
685
|
+
reports = guard.audit_project(project_root)
|
|
686
|
+
lines = ["# 项目级测试质量审计报告\n"]
|
|
687
|
+
total_score = 0
|
|
688
|
+
for r in reports:
|
|
689
|
+
lines.append(r.to_markdown())
|
|
690
|
+
lines.append("\n---\n")
|
|
691
|
+
total_score += r.score.overall
|
|
692
|
+
if reports:
|
|
693
|
+
avg = total_score / len(reports)
|
|
694
|
+
lines.append(f"\n## 项目总体评分: **{avg:.0%}** ({len(reports)} 个模块)")
|
|
695
|
+
return "\n".join(lines)
|