vigil-codeintel 0.1.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.
- vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
- vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
- vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
- vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
- vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
- vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
- vigil_forensic/__init__.py +224 -0
- vigil_forensic/_git_utils.py +178 -0
- vigil_forensic/_shared.py +510 -0
- vigil_forensic/_stubs.py +156 -0
- vigil_forensic/gate_checks/__init__.py +1 -0
- vigil_forensic/gate_checks/_ast_helpers.py +629 -0
- vigil_forensic/gate_checks/_deployment_detector.py +573 -0
- vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
- vigil_forensic/gate_checks/authority_checks.py +95 -0
- vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
- vigil_forensic/gate_checks/broad_except_checks.py +301 -0
- vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
- vigil_forensic/gate_checks/common.py +253 -0
- vigil_forensic/gate_checks/config_safety_checks.py +704 -0
- vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
- vigil_forensic/gate_checks/conflict_checks.py +193 -0
- vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
- vigil_forensic/gate_checks/context_health_checks.py +289 -0
- vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
- vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
- vigil_forensic/gate_checks/duplication_checks.py +387 -0
- vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
- vigil_forensic/gate_checks/empty_output_checks.py +87 -0
- vigil_forensic/gate_checks/encoding_checks.py +847 -0
- vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
- vigil_forensic/gate_checks/fallback_checks.py +41 -0
- vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
- vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
- vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
- vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
- vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
- vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
- vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
- vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
- vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
- vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
- vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
- vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
- vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
- vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
- vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
- vigil_forensic/gate_checks/hallucination_checks.py +566 -0
- vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
- vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
- vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
- vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
- vigil_forensic/gate_checks/ml_checks.py +318 -0
- vigil_forensic/gate_checks/performance_checks.py +106 -0
- vigil_forensic/gate_checks/project_specific_runner.py +691 -0
- vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
- vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
- vigil_forensic/gate_checks/reliability_checks.py +389 -0
- vigil_forensic/gate_checks/reporting_checks.py +55 -0
- vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
- vigil_forensic/gate_checks/security_injection_checks.py +332 -0
- vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
- vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
- vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
- vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
- vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
- vigil_forensic/gate_checks/test_quality_checks.py +946 -0
- vigil_forensic/gate_checks/testing_checks.py +149 -0
- vigil_forensic/gate_checks/toctou_checks.py +367 -0
- vigil_forensic/gate_checks/type_checking_checks.py +316 -0
- vigil_forensic/gate_models.py +392 -0
- vigil_forensic/gate_packs/__init__.py +1 -0
- vigil_forensic/gate_packs/universal.py +179 -0
- vigil_forensic/gate_profile.json +31 -0
- vigil_forensic/gate_registry.py +21 -0
- vigil_forensic/language_profiles.py +219 -0
- vigil_forensic/meta_findings.py +207 -0
- vigil_forensic/self_audit.py +725 -0
- vigil_forensic/source_analysis.py +175 -0
- vigil_mapper/__init__.py +103 -0
- vigil_mapper/_ast_helpers_minimal.py +229 -0
- vigil_mapper/_extract_imports_impl.py +123 -0
- vigil_mapper/_file_count_guard.py +129 -0
- vigil_mapper/_git_utils.py +178 -0
- vigil_mapper/_runtime_ast.py +438 -0
- vigil_mapper/_runtime_dispatch.py +137 -0
- vigil_mapper/_seed_helpers.py +82 -0
- vigil_mapper/authority_builder.py +1102 -0
- vigil_mapper/cli_entry.py +731 -0
- vigil_mapper/conflict_builder.py +818 -0
- vigil_mapper/data_contract_builder.py +446 -0
- vigil_mapper/findings_builder.py +716 -0
- vigil_mapper/fingerprint.py +53 -0
- vigil_mapper/hotspot_builder.py +539 -0
- vigil_mapper/map_common.py +449 -0
- vigil_mapper/map_errors.py +55 -0
- vigil_mapper/map_models.py +431 -0
- vigil_mapper/map_models_ext.py +206 -0
- vigil_mapper/map_models_findings.py +130 -0
- vigil_mapper/map_storage.py +455 -0
- vigil_mapper/parse_cache.py +795 -0
- vigil_mapper/refactor_boundary_builder.py +266 -0
- vigil_mapper/runtime_builder.py +527 -0
- vigil_mapper/runtime_tracer.py +243 -0
- vigil_mapper/runtime_tracer_entry.py +199 -0
- vigil_mapper/semantic_diff.py +71 -0
- vigil_mapper/source_adapters/__init__.py +109 -0
- vigil_mapper/source_adapters/_base.py +264 -0
- vigil_mapper/source_adapters/_ir.py +156 -0
- vigil_mapper/source_adapters/_lexer.py +309 -0
- vigil_mapper/source_adapters/_patterns.py +212 -0
- vigil_mapper/source_adapters/_treesitter.py +182 -0
- vigil_mapper/source_adapters/go.py +553 -0
- vigil_mapper/source_adapters/java.py +541 -0
- vigil_mapper/source_adapters/javascript.py +626 -0
- vigil_mapper/source_adapters/python.py +325 -0
- vigil_mapper/source_adapters/typescript.py +749 -0
- vigil_mapper/structural_builder.py +586 -0
- vigil_mcp/__init__.py +1 -0
- vigil_mcp/_jobs.py +587 -0
- vigil_mcp/_paths.py +93 -0
- vigil_mcp/forensic_server.py +419 -0
- vigil_mcp/map_server.py +452 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity
|
|
5
|
+
from vigil_forensic.gate_models import PostExecGateContext
|
|
6
|
+
from .common import build_check_result, build_finding, has_allowlist_for, iter_touched_snapshots, normalize_path
|
|
7
|
+
from ._ast_helpers import parse_python_source_or_emit_finding
|
|
8
|
+
from ..source_analysis import is_source_file
|
|
9
|
+
import logging
|
|
10
|
+
_log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Sprint B2 (2026-04-23): TestTopology-first applicability ordering.
|
|
15
|
+
#
|
|
16
|
+
# Each sub-check consults TestTopology.role(rel_path) BEFORE running its
|
|
17
|
+
# detection logic. Files whose role is not in the applicable set are
|
|
18
|
+
# skipped. This replaces the ad-hoc ``basename.startswith("test_")``
|
|
19
|
+
# heuristic at the top of ``run_test_quality_checks``.
|
|
20
|
+
#
|
|
21
|
+
# Graceful degradation: if ctx.project_context.test_topology is None
|
|
22
|
+
# (integration step lands later), the gate falls back to the legacy
|
|
23
|
+
# ``_is_test_path`` path-based check. That path preserves the pre-B2
|
|
24
|
+
# behaviour exactly so the rollout is safe.
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_topology(ctx: PostExecGateContext):
|
|
29
|
+
project_context = getattr(ctx, "project_context", None)
|
|
30
|
+
if project_context is None:
|
|
31
|
+
return None
|
|
32
|
+
return getattr(project_context, "test_topology", None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _applicable_roles_for(check_id: str) -> frozenset[str]:
|
|
36
|
+
"""Return the set of roles for which ``check_id`` is applicable.
|
|
37
|
+
|
|
38
|
+
Per Sprint B2 plan: all test_quality sub-checks run on ``test_module``
|
|
39
|
+
surfaces only. Helpers, fixtures, and standalone utility tests are
|
|
40
|
+
explicitly out-of-scope.
|
|
41
|
+
"""
|
|
42
|
+
# Every sub-check in this module targets the same surface today.
|
|
43
|
+
return frozenset({"test_module"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _skip_for_topology(ctx: PostExecGateContext, rel_path: str, check_id: str) -> bool:
|
|
47
|
+
"""Return True when topology says this file is not in scope for check_id.
|
|
48
|
+
|
|
49
|
+
* Topology present & role not in applicable set → skip (True).
|
|
50
|
+
* Topology present & role in applicable set → proceed (False).
|
|
51
|
+
* Topology absent → proceed (False) — the
|
|
52
|
+
outer loop has already filtered via legacy ``_is_test_path`` or
|
|
53
|
+
basename guard, so we can safely defer to that behaviour.
|
|
54
|
+
"""
|
|
55
|
+
topology = _get_topology(ctx)
|
|
56
|
+
if topology is None:
|
|
57
|
+
return False
|
|
58
|
+
role = topology.role(rel_path)
|
|
59
|
+
return role not in _applicable_roles_for(check_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_module_index(ctx: PostExecGateContext):
|
|
63
|
+
project_context = getattr(ctx, "project_context", None)
|
|
64
|
+
if project_context is None:
|
|
65
|
+
return None
|
|
66
|
+
return getattr(project_context, "python_module_index", None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Shared helpers (used by test_quality + new masking / empty / simulated gates)
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_test_path(path: str) -> bool:
|
|
75
|
+
"""True if ``path`` looks like a pytest test module.
|
|
76
|
+
|
|
77
|
+
Pytest discovers tests when the filename matches ``test_*.py`` or
|
|
78
|
+
``*_test.py`` AND pytest is run from a location that collects the file.
|
|
79
|
+
To keep false-positive rate low we require **either**:
|
|
80
|
+
|
|
81
|
+
- file lives under a ``tests/`` or ``test/`` directory component, OR
|
|
82
|
+
- filename ends with ``_test.py`` (unambiguous suffix convention).
|
|
83
|
+
|
|
84
|
+
A file merely named ``test_foo.py`` outside a test directory (for example
|
|
85
|
+
``BRAIN/autoforensics/gate_checks/test_quality_checks.py``) is treated as
|
|
86
|
+
production code — it carries the ``test_`` prefix for semantic reasons
|
|
87
|
+
but pytest will not collect it in a well-configured project.
|
|
88
|
+
"""
|
|
89
|
+
normalized = path.replace("\\", "/")
|
|
90
|
+
basename = normalized.rsplit("/", 1)[-1]
|
|
91
|
+
if not basename.endswith(".py"):
|
|
92
|
+
return False
|
|
93
|
+
parts = normalized.split("/")
|
|
94
|
+
in_tests_dir = any(part in ("tests", "test") for part in parts[:-1])
|
|
95
|
+
if in_tests_dir and (basename.startswith("test_") or basename.endswith("_test.py")):
|
|
96
|
+
return True
|
|
97
|
+
if basename.endswith("_test.py"):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _basename(path: str) -> str:
|
|
103
|
+
return path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_TEST_HELPER_BASENAMES: frozenset[str] = frozenset({
|
|
107
|
+
"conftest.py",
|
|
108
|
+
"test_helpers.py",
|
|
109
|
+
"test_utils.py",
|
|
110
|
+
"test_fixtures.py",
|
|
111
|
+
"testing_utils.py",
|
|
112
|
+
"__init__.py",
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _dotted_name(node: ast.AST) -> str | None:
|
|
117
|
+
"""Return dotted attribute name for ``ast.Name`` / ``ast.Attribute`` chains."""
|
|
118
|
+
if isinstance(node, ast.Name):
|
|
119
|
+
return node.id
|
|
120
|
+
if isinstance(node, ast.Attribute):
|
|
121
|
+
base = _dotted_name(node.value)
|
|
122
|
+
if base is None:
|
|
123
|
+
return None
|
|
124
|
+
return f"{base}.{node.attr}"
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _root_name(node: ast.AST) -> str | None:
|
|
129
|
+
"""Return the left-most identifier in a call target (``pkg.mod.fn`` → ``pkg``)."""
|
|
130
|
+
if isinstance(node, ast.Name):
|
|
131
|
+
return node.id
|
|
132
|
+
if isinstance(node, ast.Attribute):
|
|
133
|
+
return _root_name(node.value)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_pytest_ref(dotted: str) -> bool:
|
|
138
|
+
"""True if ``dotted`` is a pytest-namespaced reference like ``pytest.skip``."""
|
|
139
|
+
return dotted.startswith("pytest.") or dotted.startswith("_pytest.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _keyword_const(call: ast.Call, name: str):
|
|
143
|
+
"""Return the literal value of ``name=`` keyword, or ``None`` if not a constant."""
|
|
144
|
+
for kw in call.keywords or []:
|
|
145
|
+
if kw.arg == name and isinstance(kw.value, ast.Constant):
|
|
146
|
+
return kw.value.value
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _iter_test_functions(tree: ast.AST):
|
|
151
|
+
for node in ast.walk(tree):
|
|
152
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
|
|
153
|
+
yield node
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Existing test_quality gate
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_test_quality_checks(ctx: PostExecGateContext):
|
|
162
|
+
"""Detect tests that verify themselves instead of project code.
|
|
163
|
+
|
|
164
|
+
Common anti-patterns:
|
|
165
|
+
1. Test defines a function and immediately tests it in the same file
|
|
166
|
+
without importing anything from the project.
|
|
167
|
+
2. Test file has zero imports from the project source tree.
|
|
168
|
+
3. Test asserts only on literals (e.g. ``assert 1 + 1 == 2``).
|
|
169
|
+
|
|
170
|
+
Sprint B2 ordering: each sub-check queries ``ctx.project_context.test_topology``
|
|
171
|
+
for applicability BEFORE running detection. When topology is absent
|
|
172
|
+
(integration lands later), fallback to legacy ``_is_test_path`` /
|
|
173
|
+
basename discipline preserves behaviour.
|
|
174
|
+
"""
|
|
175
|
+
findings = []
|
|
176
|
+
topology = _get_topology(ctx)
|
|
177
|
+
|
|
178
|
+
for snapshot in iter_touched_snapshots(ctx):
|
|
179
|
+
if not snapshot.exists or not is_source_file(snapshot.path):
|
|
180
|
+
continue
|
|
181
|
+
if not snapshot.text.strip():
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
rel_path = normalize_path(snapshot.path)
|
|
185
|
+
|
|
186
|
+
# Topology-first applicability.
|
|
187
|
+
if topology is not None:
|
|
188
|
+
role = topology.role(rel_path)
|
|
189
|
+
if role not in _applicable_roles_for("test_quality"):
|
|
190
|
+
continue
|
|
191
|
+
else:
|
|
192
|
+
# Legacy fallback — preserved for safe rollout before the
|
|
193
|
+
# integration step wires ProjectContext.test_topology. The
|
|
194
|
+
# legacy rule was ``basename.startswith("test_")`` but with
|
|
195
|
+
# P1 single-source discipline we prefer ``_is_test_path``
|
|
196
|
+
# which is already the de-facto rule used by sibling gates.
|
|
197
|
+
# Keep the old permissive rule too so no new tests fire that
|
|
198
|
+
# the pre-B2 run wouldn't have — strictly narrower is OK,
|
|
199
|
+
# strictly wider is not.
|
|
200
|
+
basename = snapshot.path.rsplit("/", 1)[-1] if "/" in snapshot.path else snapshot.path
|
|
201
|
+
if not basename.startswith("test_"):
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
findings.extend(_check_no_project_imports(snapshot.path, snapshot.text, ctx, emit_finding=findings.append))
|
|
205
|
+
findings.extend(_check_self_defined_then_tested(snapshot.path, snapshot.text, emit_finding=findings.append))
|
|
206
|
+
findings.extend(_check_literal_only_asserts(snapshot.path, snapshot.text, emit_finding=findings.append))
|
|
207
|
+
|
|
208
|
+
return build_check_result(
|
|
209
|
+
check_id="test_quality",
|
|
210
|
+
category=GateCategory.TESTING,
|
|
211
|
+
findings=findings,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Gate: test_suite_masking
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_test_suite_masking_checks(ctx: PostExecGateContext):
|
|
221
|
+
"""Detect xfail / skip / importorskip patterns used to hide failing tests.
|
|
222
|
+
|
|
223
|
+
Sprint B2: topology-first applicability. Only ``test_module`` files
|
|
224
|
+
are inspected; helpers / fixtures / standalone_utility_test are skipped.
|
|
225
|
+
"""
|
|
226
|
+
findings = []
|
|
227
|
+
topology = _get_topology(ctx)
|
|
228
|
+
for snapshot in iter_touched_snapshots(ctx):
|
|
229
|
+
if not snapshot.exists or not is_source_file(snapshot.path):
|
|
230
|
+
continue
|
|
231
|
+
if not snapshot.text.strip():
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
rel_path = normalize_path(snapshot.path)
|
|
235
|
+
if topology is not None:
|
|
236
|
+
role = topology.role(rel_path)
|
|
237
|
+
if role not in _applicable_roles_for("test_suite_masking"):
|
|
238
|
+
continue
|
|
239
|
+
else:
|
|
240
|
+
# Legacy fallback preserves pre-B2 behaviour.
|
|
241
|
+
if not _is_test_path(snapshot.path):
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
findings.extend(_check_masking_patterns(snapshot.path, snapshot.text, emit_finding=findings.append))
|
|
245
|
+
|
|
246
|
+
return build_check_result(
|
|
247
|
+
check_id="test_suite_masking",
|
|
248
|
+
category=GateCategory.TESTING,
|
|
249
|
+
findings=findings,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_masking_patterns(path: str, text: str, *, emit_finding=None) -> list:
|
|
254
|
+
# B4 (2026-04-23): fail-loud parse via shared helper.
|
|
255
|
+
tree = parse_python_source_or_emit_finding(
|
|
256
|
+
text,
|
|
257
|
+
rel_path=normalize_path(path),
|
|
258
|
+
emit_finding=emit_finding,
|
|
259
|
+
emitting_gate="test_quality.masking_patterns",
|
|
260
|
+
filename=path,
|
|
261
|
+
)
|
|
262
|
+
if tree is None:
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
results: list = []
|
|
266
|
+
|
|
267
|
+
# --- Decorator-based xfail / skip / skipif ---
|
|
268
|
+
for node in ast.walk(tree):
|
|
269
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
270
|
+
continue
|
|
271
|
+
for deco in getattr(node, "decorator_list", ()) or ():
|
|
272
|
+
call = deco if isinstance(deco, ast.Call) else None
|
|
273
|
+
target = call.func if call is not None else deco
|
|
274
|
+
name_str = _dotted_name(target)
|
|
275
|
+
if name_str is None:
|
|
276
|
+
continue
|
|
277
|
+
line_no = int(getattr(deco, "lineno", 0) or 0)
|
|
278
|
+
if has_allowlist_for(text, "test_suite_masking", line_no):
|
|
279
|
+
continue
|
|
280
|
+
if name_str.endswith(".xfail"):
|
|
281
|
+
strict_val = _keyword_const(call, "strict") if call is not None else None
|
|
282
|
+
if strict_val is False:
|
|
283
|
+
results.append(_mk_masking_finding(
|
|
284
|
+
path, line_no,
|
|
285
|
+
severity=GateSeverity.HIGH,
|
|
286
|
+
title=f"xfail(strict=False) masks regressions: {path}:{line_no}",
|
|
287
|
+
detail="xfail_non_strict",
|
|
288
|
+
summary=(
|
|
289
|
+
f"{path}:{line_no} uses @pytest.mark.xfail(strict=False). "
|
|
290
|
+
"Non-strict xfail silently accepts unexpected passes and "
|
|
291
|
+
"hides the real verdict when the bug is fixed or the "
|
|
292
|
+
"test fails for a different reason."
|
|
293
|
+
),
|
|
294
|
+
))
|
|
295
|
+
elif strict_val is True:
|
|
296
|
+
# Acceptable — skip.
|
|
297
|
+
continue
|
|
298
|
+
else:
|
|
299
|
+
results.append(_mk_masking_finding(
|
|
300
|
+
path, line_no,
|
|
301
|
+
severity=GateSeverity.MEDIUM,
|
|
302
|
+
title=f"xfail without strict=True: {path}:{line_no}",
|
|
303
|
+
detail="xfail_missing_strict",
|
|
304
|
+
summary=(
|
|
305
|
+
f"{path}:{line_no} uses @pytest.mark.xfail without explicit "
|
|
306
|
+
"strict=True. Pytest's default is strict=False which masks "
|
|
307
|
+
"unexpected passes. Set strict=True or fix the test."
|
|
308
|
+
),
|
|
309
|
+
))
|
|
310
|
+
elif name_str.endswith(".skip") and "mark" in name_str:
|
|
311
|
+
results.append(_mk_masking_finding(
|
|
312
|
+
path, line_no,
|
|
313
|
+
severity=GateSeverity.HIGH,
|
|
314
|
+
title=f"@pytest.mark.skip unconditional: {path}:{line_no}",
|
|
315
|
+
detail="mark_skip_unconditional",
|
|
316
|
+
summary=(
|
|
317
|
+
f"{path}:{line_no} decorates a test with @pytest.mark.skip. "
|
|
318
|
+
"Unconditional skip hides a broken or abandoned test. "
|
|
319
|
+
"Replace with skipif(condition) or delete the test."
|
|
320
|
+
),
|
|
321
|
+
))
|
|
322
|
+
elif name_str.endswith(".skipif") and "mark" in name_str and call is not None and call.args:
|
|
323
|
+
cond = call.args[0]
|
|
324
|
+
if isinstance(cond, ast.Constant) and cond.value is True:
|
|
325
|
+
results.append(_mk_masking_finding(
|
|
326
|
+
path, line_no,
|
|
327
|
+
severity=GateSeverity.HIGH,
|
|
328
|
+
title=f"@pytest.mark.skipif(True, ...) literal: {path}:{line_no}",
|
|
329
|
+
detail="mark_skipif_literal_true",
|
|
330
|
+
summary=(
|
|
331
|
+
f"{path}:{line_no} uses @pytest.mark.skipif(True, ...). "
|
|
332
|
+
"Literal True condition is equivalent to unconditional "
|
|
333
|
+
"skip and masks a broken test."
|
|
334
|
+
),
|
|
335
|
+
))
|
|
336
|
+
|
|
337
|
+
# --- Body-level skip / skipif / importorskip ---
|
|
338
|
+
for fn in _iter_test_functions(tree):
|
|
339
|
+
for node in ast.walk(fn):
|
|
340
|
+
if not isinstance(node, ast.Call):
|
|
341
|
+
continue
|
|
342
|
+
name_str = _dotted_name(node.func)
|
|
343
|
+
if name_str is None:
|
|
344
|
+
continue
|
|
345
|
+
line_no = int(getattr(node, "lineno", 0) or 0)
|
|
346
|
+
if not _is_pytest_ref(name_str):
|
|
347
|
+
continue
|
|
348
|
+
if has_allowlist_for(text, "test_suite_masking", line_no):
|
|
349
|
+
continue
|
|
350
|
+
tail = name_str.rsplit(".", 1)[-1]
|
|
351
|
+
if tail == "skip":
|
|
352
|
+
results.append(_mk_masking_finding(
|
|
353
|
+
path, line_no,
|
|
354
|
+
severity=GateSeverity.HIGH,
|
|
355
|
+
title=f"pytest.skip() inside test body: {path}:{line_no}",
|
|
356
|
+
detail="unconditional_skip",
|
|
357
|
+
summary=(
|
|
358
|
+
f"{path}:{line_no} calls pytest.skip(...) inside a test "
|
|
359
|
+
"function body. Unconditional skip masks a broken test. "
|
|
360
|
+
"Use pytest.mark.skipif with a real condition, or fix the test."
|
|
361
|
+
),
|
|
362
|
+
))
|
|
363
|
+
elif tail == "skipif" and node.args:
|
|
364
|
+
cond = node.args[0]
|
|
365
|
+
if isinstance(cond, ast.Constant) and cond.value is True:
|
|
366
|
+
results.append(_mk_masking_finding(
|
|
367
|
+
path, line_no,
|
|
368
|
+
severity=GateSeverity.HIGH,
|
|
369
|
+
title=f"pytest.skipif(True, ...) literal condition: {path}:{line_no}",
|
|
370
|
+
detail="skipif_literal_true",
|
|
371
|
+
summary=(
|
|
372
|
+
f"{path}:{line_no} passes a literal True to "
|
|
373
|
+
"pytest.skipif(). That is equivalent to unconditional "
|
|
374
|
+
"skip and masks a broken test."
|
|
375
|
+
),
|
|
376
|
+
))
|
|
377
|
+
elif tail == "importorskip":
|
|
378
|
+
results.append(_mk_masking_finding(
|
|
379
|
+
path, line_no,
|
|
380
|
+
severity=GateSeverity.MEDIUM,
|
|
381
|
+
title=f"pytest.importorskip inside test body: {path}:{line_no}",
|
|
382
|
+
detail="importorskip_in_test",
|
|
383
|
+
summary=(
|
|
384
|
+
f"{path}:{line_no} calls pytest.importorskip(...) from "
|
|
385
|
+
"inside a test body. That pattern is acceptable at "
|
|
386
|
+
"module scope for optional dependencies but suspect "
|
|
387
|
+
"inside a test — it will silently skip the test if the "
|
|
388
|
+
"import is missing at runtime."
|
|
389
|
+
),
|
|
390
|
+
))
|
|
391
|
+
return results
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _mk_masking_finding(path: str, line_no: int, *, severity: GateSeverity, title: str, detail: str, summary: str):
|
|
395
|
+
return build_finding(
|
|
396
|
+
check_id=f"test_suite_masking.{detail}",
|
|
397
|
+
category=GateCategory.TESTING,
|
|
398
|
+
title=title,
|
|
399
|
+
severity=severity,
|
|
400
|
+
impact=GateImpact.REVISE,
|
|
401
|
+
summary=summary,
|
|
402
|
+
recommendation=(
|
|
403
|
+
"Remove xfail/skip or mark strict=True and fix the underlying test. "
|
|
404
|
+
"If the mask is intentional, add a justification docstring and "
|
|
405
|
+
"suppress with `# noqa: test_suite_masking` on the decorator/call line."
|
|
406
|
+
),
|
|
407
|
+
evidence=[EvidenceReference(kind="file", path=path, detail=f"{detail}:{line_no}")],
|
|
408
|
+
repair_kind="remove_masking",
|
|
409
|
+
executor_action="remove xfail/skip or mark strict=True and fix underlying test",
|
|
410
|
+
proof_required="test passes without xfail/skip",
|
|
411
|
+
allowlist_allowed=True,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Gate: empty_test_module
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def run_empty_test_module_checks(ctx: PostExecGateContext):
|
|
421
|
+
"""Detect test modules that contain zero pytest tests.
|
|
422
|
+
|
|
423
|
+
Sprint B2: topology decides applicability. In topology mode only
|
|
424
|
+
``test_module`` surfaces are inspected — files classified as
|
|
425
|
+
``fixture`` / ``helper`` / ``standalone_utility_test`` are skipped
|
|
426
|
+
at the classifier level (no need for basename denylists inside the
|
|
427
|
+
gate body).
|
|
428
|
+
|
|
429
|
+
Legacy exclusions (preserved for fallback path):
|
|
430
|
+
- conftest.py and well-known helper module names (test_helpers.py etc.).
|
|
431
|
+
- Files that define pytest fixtures (``@pytest.fixture``) but no tests.
|
|
432
|
+
- Files that declare ``pytest_plugins`` at module level.
|
|
433
|
+
"""
|
|
434
|
+
findings = []
|
|
435
|
+
topology = _get_topology(ctx)
|
|
436
|
+
for snapshot in iter_touched_snapshots(ctx):
|
|
437
|
+
if not snapshot.exists or not is_source_file(snapshot.path):
|
|
438
|
+
continue
|
|
439
|
+
if not snapshot.text.strip():
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
rel_path = normalize_path(snapshot.path)
|
|
443
|
+
if topology is not None:
|
|
444
|
+
role = topology.role(rel_path)
|
|
445
|
+
if role not in _applicable_roles_for("empty_test_module"):
|
|
446
|
+
continue
|
|
447
|
+
else:
|
|
448
|
+
# Legacy fallback.
|
|
449
|
+
if not _is_test_path(snapshot.path):
|
|
450
|
+
continue
|
|
451
|
+
if _basename(snapshot.path) in _TEST_HELPER_BASENAMES:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
findings.extend(_check_empty_test_module(snapshot.path, snapshot.text, emit_finding=findings.append))
|
|
455
|
+
|
|
456
|
+
return build_check_result(
|
|
457
|
+
check_id="empty_test_module",
|
|
458
|
+
category=GateCategory.TESTING,
|
|
459
|
+
findings=findings,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _check_empty_test_module(path: str, text: str, *, emit_finding=None) -> list:
|
|
464
|
+
# B4 (2026-04-23): fail-loud parse via shared helper.
|
|
465
|
+
tree = parse_python_source_or_emit_finding(
|
|
466
|
+
text,
|
|
467
|
+
rel_path=normalize_path(path),
|
|
468
|
+
emit_finding=emit_finding,
|
|
469
|
+
emitting_gate="test_quality.empty_test_module",
|
|
470
|
+
filename=path,
|
|
471
|
+
)
|
|
472
|
+
if tree is None:
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
# Module-level pytest_plugins = ... → fixture-config module, skip.
|
|
476
|
+
for node in ast.iter_child_nodes(tree):
|
|
477
|
+
if isinstance(node, ast.Assign):
|
|
478
|
+
for target in node.targets:
|
|
479
|
+
if isinstance(target, ast.Name) and target.id == "pytest_plugins":
|
|
480
|
+
return []
|
|
481
|
+
|
|
482
|
+
has_test_fn = False
|
|
483
|
+
has_fixture = False
|
|
484
|
+
for node in ast.walk(tree):
|
|
485
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
486
|
+
if node.name.startswith("test_"):
|
|
487
|
+
has_test_fn = True
|
|
488
|
+
for deco in node.decorator_list:
|
|
489
|
+
name_str = _dotted_name(deco if not isinstance(deco, ast.Call) else deco.func)
|
|
490
|
+
if name_str and (name_str.endswith(".fixture") or name_str == "fixture"):
|
|
491
|
+
has_fixture = True
|
|
492
|
+
elif isinstance(node, ast.ClassDef):
|
|
493
|
+
if node.name.startswith("Test"):
|
|
494
|
+
for child in node.body:
|
|
495
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_"):
|
|
496
|
+
has_test_fn = True
|
|
497
|
+
break
|
|
498
|
+
|
|
499
|
+
if has_test_fn:
|
|
500
|
+
return []
|
|
501
|
+
if has_fixture:
|
|
502
|
+
# Fixture-only module — acceptable, skip.
|
|
503
|
+
return []
|
|
504
|
+
if has_allowlist_for(text, "empty_test_module"):
|
|
505
|
+
return []
|
|
506
|
+
return [
|
|
507
|
+
build_finding(
|
|
508
|
+
check_id="empty_test_module",
|
|
509
|
+
category=GateCategory.TESTING,
|
|
510
|
+
title=f"Test module contains no tests: {path}",
|
|
511
|
+
severity=GateSeverity.HIGH,
|
|
512
|
+
impact=GateImpact.REVISE,
|
|
513
|
+
summary=(
|
|
514
|
+
f"{path} matches test-file naming conventions but defines zero "
|
|
515
|
+
"`test_*` functions, zero `Test*` classes with `test_*` methods, "
|
|
516
|
+
"zero `@pytest.fixture` definitions, and no `pytest_plugins` "
|
|
517
|
+
"declaration. Pytest will collect nothing from this file, so it "
|
|
518
|
+
"contributes no verification and silently 'passes'."
|
|
519
|
+
),
|
|
520
|
+
recommendation=(
|
|
521
|
+
"Add real tests, or rename the module to drop the `test_` prefix "
|
|
522
|
+
"if it is a helper. If this is intentional (e.g. placeholder "
|
|
523
|
+
"pending implementation), add `# noqa: empty_test_module` at "
|
|
524
|
+
"module top with a justification docstring."
|
|
525
|
+
),
|
|
526
|
+
evidence=[EvidenceReference(kind="file", path=path, detail="no_tests_defined")],
|
|
527
|
+
repair_kind="add_test",
|
|
528
|
+
executor_action="add test_* functions or rename module out of the test namespace",
|
|
529
|
+
proof_required="pytest collects at least one test from the file",
|
|
530
|
+
allowlist_allowed=True,
|
|
531
|
+
)
|
|
532
|
+
]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
# Gate: simulated_instead_of_executed_test
|
|
537
|
+
# ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def run_simulated_test_checks(ctx: PostExecGateContext):
|
|
541
|
+
"""Detect tests that assert on locally-defined helpers instead of real code.
|
|
542
|
+
|
|
543
|
+
Heuristic (AST-only, tuned for low FP):
|
|
544
|
+
1. For each `def test_X(...)` in a test file:
|
|
545
|
+
- Collect local Lambda / FunctionDef / ClassDef names defined inside body.
|
|
546
|
+
- Collect all Call nodes inside the body.
|
|
547
|
+
- Classify each call as local_def / project_call / other.
|
|
548
|
+
- If local_def >= 1 AND project_call == 0 → flag as simulated.
|
|
549
|
+
2. Tests with zero local defs or that touch project code are ignored.
|
|
550
|
+
3. Files without any project imports are ignored (covered by
|
|
551
|
+
test_quality.no_project_imports).
|
|
552
|
+
"""
|
|
553
|
+
findings = []
|
|
554
|
+
roots = ctx.source_package_roots
|
|
555
|
+
if not roots:
|
|
556
|
+
# Without known roots we cannot tell project calls from stdlib; skip.
|
|
557
|
+
return build_check_result(
|
|
558
|
+
check_id="simulated_instead_of_executed_test",
|
|
559
|
+
category=GateCategory.TESTING,
|
|
560
|
+
findings=(),
|
|
561
|
+
)
|
|
562
|
+
topology = _get_topology(ctx)
|
|
563
|
+
for snapshot in iter_touched_snapshots(ctx):
|
|
564
|
+
if not snapshot.exists or not is_source_file(snapshot.path):
|
|
565
|
+
continue
|
|
566
|
+
if not snapshot.text.strip():
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
rel_path = normalize_path(snapshot.path)
|
|
570
|
+
if topology is not None:
|
|
571
|
+
role = topology.role(rel_path)
|
|
572
|
+
if role not in _applicable_roles_for("simulated_instead_of_executed_test"):
|
|
573
|
+
continue
|
|
574
|
+
else:
|
|
575
|
+
# Legacy fallback.
|
|
576
|
+
if not _is_test_path(snapshot.path):
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
findings.extend(_check_simulated_tests(snapshot.path, snapshot.text, roots, emit_finding=findings.append))
|
|
580
|
+
|
|
581
|
+
return build_check_result(
|
|
582
|
+
check_id="simulated_instead_of_executed_test",
|
|
583
|
+
category=GateCategory.TESTING,
|
|
584
|
+
findings=findings,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _check_simulated_tests(path: str, text: str, roots: tuple[str, ...], *, emit_finding=None) -> list:
|
|
589
|
+
# B4 (2026-04-23): fail-loud parse via shared helper.
|
|
590
|
+
tree = parse_python_source_or_emit_finding(
|
|
591
|
+
text,
|
|
592
|
+
rel_path=normalize_path(path),
|
|
593
|
+
emit_finding=emit_finding,
|
|
594
|
+
emitting_gate="test_quality.simulated_tests",
|
|
595
|
+
filename=path,
|
|
596
|
+
)
|
|
597
|
+
if tree is None:
|
|
598
|
+
return []
|
|
599
|
+
|
|
600
|
+
module_aliases = _collect_project_import_aliases(tree, roots)
|
|
601
|
+
|
|
602
|
+
results: list = []
|
|
603
|
+
for fn in _iter_test_functions(tree):
|
|
604
|
+
# Per-test scope = module imports + function-local imports. Many tests
|
|
605
|
+
# use function-local imports to side-step collection-time failures.
|
|
606
|
+
local_imports = _collect_project_import_aliases(fn, roots)
|
|
607
|
+
project_aliases = module_aliases | local_imports
|
|
608
|
+
if not project_aliases:
|
|
609
|
+
continue # covered by no_project_imports
|
|
610
|
+
local_defs, local_lambda_vars = _collect_local_defs(fn)
|
|
611
|
+
if not local_defs and not local_lambda_vars:
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
local_def_calls = 0
|
|
615
|
+
project_calls = 0
|
|
616
|
+
for node in ast.walk(fn):
|
|
617
|
+
if not isinstance(node, ast.Call):
|
|
618
|
+
continue
|
|
619
|
+
top_name = _root_name(node.func)
|
|
620
|
+
if top_name is None:
|
|
621
|
+
continue
|
|
622
|
+
if top_name in local_defs or top_name in local_lambda_vars:
|
|
623
|
+
local_def_calls += 1
|
|
624
|
+
elif top_name in project_aliases:
|
|
625
|
+
project_calls += 1
|
|
626
|
+
|
|
627
|
+
if local_def_calls == 0:
|
|
628
|
+
continue
|
|
629
|
+
if project_calls > 0:
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
line_no = int(getattr(fn, "lineno", 0) or 0)
|
|
633
|
+
if has_allowlist_for(text, "simulated_instead_of_executed_test", line_no):
|
|
634
|
+
continue
|
|
635
|
+
defs_preview = ", ".join(sorted(local_defs | local_lambda_vars)[:3])
|
|
636
|
+
results.append(build_finding(
|
|
637
|
+
check_id="simulated_instead_of_executed_test",
|
|
638
|
+
category=GateCategory.TESTING,
|
|
639
|
+
title=f"Test exercises only local simulation: {path}:{line_no}::{fn.name}",
|
|
640
|
+
severity=GateSeverity.MEDIUM,
|
|
641
|
+
impact=GateImpact.REVISE,
|
|
642
|
+
summary=(
|
|
643
|
+
f"Test function {fn.name} at {path}:{line_no} defines local "
|
|
644
|
+
f"helpers ({defs_preview}) and makes {local_def_calls} call(s) "
|
|
645
|
+
"to them but zero calls to any symbol imported from the project "
|
|
646
|
+
f"roots ({', '.join(roots)}). The test appears to verify a hand-"
|
|
647
|
+
"rolled simulation of the behaviour rather than invoking the real "
|
|
648
|
+
"production code."
|
|
649
|
+
),
|
|
650
|
+
recommendation=(
|
|
651
|
+
"Replace the local lambda/def with a call to the actual project "
|
|
652
|
+
"function being tested. Local helpers are fine as test utilities "
|
|
653
|
+
"only if the assertions call real project code afterwards."
|
|
654
|
+
),
|
|
655
|
+
evidence=[EvidenceReference(kind="file", path=path, detail=f"simulated:{fn.name}:{line_no}")],
|
|
656
|
+
repair_kind="add_test",
|
|
657
|
+
executor_action="call the production function instead of a local simulation",
|
|
658
|
+
proof_required="assertions exercise imported project symbols",
|
|
659
|
+
allowlist_allowed=True,
|
|
660
|
+
))
|
|
661
|
+
return results
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _collect_project_import_aliases(tree: ast.AST, roots: tuple[str, ...]) -> set[str]:
|
|
665
|
+
"""Return the set of names bound by imports from any project root.
|
|
666
|
+
|
|
667
|
+
Walks the full AST subtree so it catches imports made inside test-function
|
|
668
|
+
bodies as well as module-level imports.
|
|
669
|
+
"""
|
|
670
|
+
aliases: set[str] = set()
|
|
671
|
+
for node in ast.walk(tree):
|
|
672
|
+
if isinstance(node, ast.ImportFrom) and node.module:
|
|
673
|
+
if any(node.module.startswith(r) for r in roots):
|
|
674
|
+
for alias in node.names:
|
|
675
|
+
aliases.add(alias.asname or alias.name.split(".")[0])
|
|
676
|
+
elif isinstance(node, ast.Import):
|
|
677
|
+
for alias in node.names:
|
|
678
|
+
if any(alias.name.startswith(r) for r in roots):
|
|
679
|
+
aliases.add(alias.asname or alias.name.split(".")[0])
|
|
680
|
+
return aliases
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _collect_local_defs(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[set[str], set[str]]:
|
|
684
|
+
"""Return (named_defs, lambda_var_names) for symbols defined inside ``fn``.
|
|
685
|
+
|
|
686
|
+
- named_defs: names of local FunctionDef/AsyncFunctionDef/ClassDef.
|
|
687
|
+
- lambda_var_names: names bound to Lambda via ``foo = lambda ...:``.
|
|
688
|
+
"""
|
|
689
|
+
named_defs: set[str] = set()
|
|
690
|
+
lambda_vars: set[str] = set()
|
|
691
|
+
for node in ast.walk(fn):
|
|
692
|
+
if node is fn:
|
|
693
|
+
continue
|
|
694
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
695
|
+
named_defs.add(node.name)
|
|
696
|
+
elif isinstance(node, ast.Assign) and isinstance(node.value, ast.Lambda):
|
|
697
|
+
for target in node.targets:
|
|
698
|
+
if isinstance(target, ast.Name):
|
|
699
|
+
lambda_vars.add(target.id)
|
|
700
|
+
return named_defs, lambda_vars
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _check_no_project_imports(path: str, text: str, ctx: PostExecGateContext, *, emit_finding=None) -> list:
|
|
704
|
+
"""Flag test files that import nothing from the project.
|
|
705
|
+
|
|
706
|
+
Sprint B2: once topology has confirmed ``role == "test_module"``, the
|
|
707
|
+
check uses ``ctx.project_context.python_module_index`` (B1) when
|
|
708
|
+
available to decide what counts as a project import — resolving the
|
|
709
|
+
import against the project's declared layout is strictly more
|
|
710
|
+
accurate than the legacy ``name.startswith(root)`` prefix match,
|
|
711
|
+
which has false negatives for src-layout projects and false
|
|
712
|
+
positives for packages that share a top-level name with stdlib.
|
|
713
|
+
"""
|
|
714
|
+
# B4 (2026-04-23): fail-loud parse via shared helper. Previously this
|
|
715
|
+
# deferred to ``syntax_validity_checks`` but did so silently; now the
|
|
716
|
+
# meta finding also surfaces for cross-gate visibility.
|
|
717
|
+
tree = parse_python_source_or_emit_finding(
|
|
718
|
+
text,
|
|
719
|
+
rel_path=normalize_path(path),
|
|
720
|
+
emit_finding=emit_finding,
|
|
721
|
+
emitting_gate="test_quality.no_project_imports",
|
|
722
|
+
filename=path,
|
|
723
|
+
)
|
|
724
|
+
if tree is None:
|
|
725
|
+
return []
|
|
726
|
+
|
|
727
|
+
effective_roots = ctx.source_package_roots # populated from project_dir at context build time
|
|
728
|
+
module_index = _get_module_index(ctx)
|
|
729
|
+
|
|
730
|
+
if not effective_roots and module_index is None:
|
|
731
|
+
return [] # cannot determine project roots; skip rather than false-positive
|
|
732
|
+
|
|
733
|
+
def _is_project_module(name: str) -> bool:
|
|
734
|
+
"""Decide whether ``name`` refers to a project-internal module.
|
|
735
|
+
|
|
736
|
+
Preferred path (B1 available): resolve against PythonModuleIndex.
|
|
737
|
+
Fallback path (legacy): prefix match vs source_package_roots.
|
|
738
|
+
"""
|
|
739
|
+
if module_index is not None:
|
|
740
|
+
outcome = module_index.resolve(name)
|
|
741
|
+
if outcome.status == "resolved":
|
|
742
|
+
return True
|
|
743
|
+
if outcome.status == "missing_confident":
|
|
744
|
+
return False
|
|
745
|
+
# resolver_uncertain: fall through to root prefix check below so we
|
|
746
|
+
# don't flag a file that legitimately imports a src-layout package.
|
|
747
|
+
if effective_roots:
|
|
748
|
+
return any(name.startswith(root) for root in effective_roots)
|
|
749
|
+
return False
|
|
750
|
+
|
|
751
|
+
has_project_import = False
|
|
752
|
+
for node in ast.walk(tree):
|
|
753
|
+
if isinstance(node, ast.ImportFrom) and node.module:
|
|
754
|
+
if _is_project_module(node.module):
|
|
755
|
+
has_project_import = True
|
|
756
|
+
break
|
|
757
|
+
elif isinstance(node, ast.Import):
|
|
758
|
+
for alias in node.names:
|
|
759
|
+
if _is_project_module(alias.name):
|
|
760
|
+
has_project_import = True
|
|
761
|
+
break
|
|
762
|
+
if has_project_import:
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if not has_project_import:
|
|
766
|
+
# Check if there are any test functions at all
|
|
767
|
+
has_tests = any(
|
|
768
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
769
|
+
and node.name.startswith("test_")
|
|
770
|
+
for node in ast.walk(tree)
|
|
771
|
+
)
|
|
772
|
+
if has_tests:
|
|
773
|
+
return [
|
|
774
|
+
build_finding(
|
|
775
|
+
check_id="test_quality.no_project_imports",
|
|
776
|
+
category=GateCategory.TESTING,
|
|
777
|
+
title=f"Test file imports nothing from project: {path}",
|
|
778
|
+
severity=GateSeverity.HIGH,
|
|
779
|
+
impact=GateImpact.REVISE,
|
|
780
|
+
summary=(
|
|
781
|
+
f"Test file {path} contains test functions but does not "
|
|
782
|
+
f"import from any project source root ({', '.join(effective_roots)}). "
|
|
783
|
+
"The tests may be verifying their own local definitions "
|
|
784
|
+
"rather than actual project behavior."
|
|
785
|
+
),
|
|
786
|
+
recommendation=(
|
|
787
|
+
"Ensure tests import and exercise the actual project code, "
|
|
788
|
+
"not locally defined stubs or inline implementations."
|
|
789
|
+
),
|
|
790
|
+
evidence=[EvidenceReference(kind="file", path=path, detail="no_project_imports")],
|
|
791
|
+
|
|
792
|
+
repair_kind='add_test',
|
|
793
|
+
executor_action='Add tests for coverage',
|
|
794
|
+
proof_required='Tests added/passing',
|
|
795
|
+
allowlist_allowed=True,
|
|
796
|
+
)
|
|
797
|
+
]
|
|
798
|
+
return []
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _check_self_defined_then_tested(path: str, text: str, *, emit_finding=None) -> list:
|
|
802
|
+
"""Flag when a test file defines non-test functions and only tests those."""
|
|
803
|
+
# B4 (2026-04-23): fail-loud parse via shared helper.
|
|
804
|
+
tree = parse_python_source_or_emit_finding(
|
|
805
|
+
text,
|
|
806
|
+
rel_path=normalize_path(path),
|
|
807
|
+
emit_finding=emit_finding,
|
|
808
|
+
emitting_gate="test_quality.self_defined_then_tested",
|
|
809
|
+
filename=path,
|
|
810
|
+
)
|
|
811
|
+
if tree is None:
|
|
812
|
+
return []
|
|
813
|
+
|
|
814
|
+
# Collect top-level non-test function/class names defined in this file
|
|
815
|
+
local_defs: set[str] = set()
|
|
816
|
+
test_fns: list[ast.FunctionDef] = []
|
|
817
|
+
for node in ast.iter_child_nodes(tree):
|
|
818
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
819
|
+
if node.name.startswith("test_"):
|
|
820
|
+
test_fns.append(node)
|
|
821
|
+
elif not node.name.startswith("_"):
|
|
822
|
+
local_defs.add(node.name)
|
|
823
|
+
elif isinstance(node, ast.ClassDef):
|
|
824
|
+
if not node.name.startswith("Test"):
|
|
825
|
+
local_defs.add(node.name)
|
|
826
|
+
|
|
827
|
+
if not local_defs or not test_fns:
|
|
828
|
+
return []
|
|
829
|
+
|
|
830
|
+
# Check if test functions call local defs
|
|
831
|
+
calls_local = 0
|
|
832
|
+
calls_total = 0
|
|
833
|
+
for test_fn in test_fns:
|
|
834
|
+
for node in ast.walk(test_fn):
|
|
835
|
+
if isinstance(node, ast.Call):
|
|
836
|
+
calls_total += 1
|
|
837
|
+
if isinstance(node.func, ast.Name) and node.func.id in local_defs:
|
|
838
|
+
calls_local += 1
|
|
839
|
+
|
|
840
|
+
if calls_total > 0 and calls_local == calls_total:
|
|
841
|
+
return [
|
|
842
|
+
build_finding(
|
|
843
|
+
check_id="test_quality.tests_own_definitions",
|
|
844
|
+
category=GateCategory.TESTING,
|
|
845
|
+
title=f"Tests only exercise locally defined code: {path}",
|
|
846
|
+
severity=GateSeverity.HIGH,
|
|
847
|
+
impact=GateImpact.REVISE,
|
|
848
|
+
summary=(
|
|
849
|
+
f"Test file {path} defines {len(local_defs)} non-test function(s) "
|
|
850
|
+
f"({', '.join(sorted(local_defs)[:3])}) and all {calls_total} test "
|
|
851
|
+
"call(s) target these local definitions. The tests may be "
|
|
852
|
+
"verifying stub behavior rather than real project code."
|
|
853
|
+
),
|
|
854
|
+
recommendation=(
|
|
855
|
+
"Tests should import and call the actual project implementation. "
|
|
856
|
+
"Local helper functions in tests are fine as utilities, but the "
|
|
857
|
+
"test assertions should verify imported project behavior."
|
|
858
|
+
),
|
|
859
|
+
evidence=[EvidenceReference(kind="file", path=path, detail="self_testing")],
|
|
860
|
+
|
|
861
|
+
repair_kind='add_test',
|
|
862
|
+
executor_action='Add tests for coverage',
|
|
863
|
+
proof_required='Tests added/passing',
|
|
864
|
+
allowlist_allowed=True,
|
|
865
|
+
)
|
|
866
|
+
]
|
|
867
|
+
return []
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _check_literal_only_asserts(path: str, text: str, *, emit_finding=None) -> list:
|
|
871
|
+
"""Flag tests where all asserts compare only literals (no function calls)."""
|
|
872
|
+
# B4 (2026-04-23): fail-loud parse via shared helper.
|
|
873
|
+
tree = parse_python_source_or_emit_finding(
|
|
874
|
+
text,
|
|
875
|
+
rel_path=normalize_path(path),
|
|
876
|
+
emit_finding=emit_finding,
|
|
877
|
+
emitting_gate="test_quality.literal_only_asserts",
|
|
878
|
+
filename=path,
|
|
879
|
+
)
|
|
880
|
+
if tree is None:
|
|
881
|
+
return []
|
|
882
|
+
|
|
883
|
+
test_fns = [
|
|
884
|
+
node for node in ast.walk(tree)
|
|
885
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
886
|
+
and node.name.startswith("test_")
|
|
887
|
+
]
|
|
888
|
+
if not test_fns:
|
|
889
|
+
return []
|
|
890
|
+
|
|
891
|
+
all_literal_asserts = True
|
|
892
|
+
has_any_assert = False
|
|
893
|
+
for test_fn in test_fns:
|
|
894
|
+
for node in ast.walk(test_fn):
|
|
895
|
+
if isinstance(node, ast.Assert):
|
|
896
|
+
has_any_assert = True
|
|
897
|
+
if not _is_literal_only_expr(node.test):
|
|
898
|
+
all_literal_asserts = False
|
|
899
|
+
break
|
|
900
|
+
if not all_literal_asserts:
|
|
901
|
+
break
|
|
902
|
+
|
|
903
|
+
if has_any_assert and all_literal_asserts:
|
|
904
|
+
return [
|
|
905
|
+
build_finding(
|
|
906
|
+
check_id="test_quality.literal_only_asserts",
|
|
907
|
+
category=GateCategory.TESTING,
|
|
908
|
+
title=f"All test asserts compare only literals: {path}",
|
|
909
|
+
severity=GateSeverity.MEDIUM,
|
|
910
|
+
impact=GateImpact.REVISE,
|
|
911
|
+
summary=(
|
|
912
|
+
f"Every assert statement in {path} compares only literal values "
|
|
913
|
+
"(e.g. `assert 1 == 1`). This suggests the tests are not "
|
|
914
|
+
"exercising any real code paths."
|
|
915
|
+
),
|
|
916
|
+
recommendation="Assert on the result of calling project functions, not on constants.",
|
|
917
|
+
evidence=[EvidenceReference(kind="file", path=path, detail="literal_asserts")],
|
|
918
|
+
|
|
919
|
+
repair_kind='add_test',
|
|
920
|
+
executor_action='Add tests for coverage',
|
|
921
|
+
proof_required='Tests added/passing',
|
|
922
|
+
allowlist_allowed=True,
|
|
923
|
+
)
|
|
924
|
+
]
|
|
925
|
+
return []
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _is_literal_only_expr(node: ast.expr) -> bool:
|
|
929
|
+
"""Return True if the AST expression contains only literals/constants."""
|
|
930
|
+
if isinstance(node, ast.Constant):
|
|
931
|
+
return True
|
|
932
|
+
if isinstance(node, ast.Compare):
|
|
933
|
+
return _is_literal_only_expr(node.left) and all(
|
|
934
|
+
_is_literal_only_expr(c) for c in node.comparators
|
|
935
|
+
)
|
|
936
|
+
if isinstance(node, ast.BoolOp):
|
|
937
|
+
return all(_is_literal_only_expr(v) for v in node.values)
|
|
938
|
+
if isinstance(node, ast.UnaryOp):
|
|
939
|
+
return _is_literal_only_expr(node.operand)
|
|
940
|
+
if isinstance(node, ast.BinOp):
|
|
941
|
+
return _is_literal_only_expr(node.left) and _is_literal_only_expr(node.right)
|
|
942
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
943
|
+
return all(_is_literal_only_expr(e) for e in node.elts)
|
|
944
|
+
if isinstance(node, ast.NameConstant): # Python 3.7 compat
|
|
945
|
+
return True
|
|
946
|
+
return False
|