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.
Files changed (131) hide show
  1. vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
  2. vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
  3. vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
  4. vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
  5. vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
  7. vigil_forensic/__init__.py +224 -0
  8. vigil_forensic/_git_utils.py +178 -0
  9. vigil_forensic/_shared.py +510 -0
  10. vigil_forensic/_stubs.py +156 -0
  11. vigil_forensic/gate_checks/__init__.py +1 -0
  12. vigil_forensic/gate_checks/_ast_helpers.py +629 -0
  13. vigil_forensic/gate_checks/_deployment_detector.py +573 -0
  14. vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
  15. vigil_forensic/gate_checks/authority_checks.py +95 -0
  16. vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
  17. vigil_forensic/gate_checks/broad_except_checks.py +301 -0
  18. vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
  19. vigil_forensic/gate_checks/common.py +253 -0
  20. vigil_forensic/gate_checks/config_safety_checks.py +704 -0
  21. vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
  22. vigil_forensic/gate_checks/conflict_checks.py +193 -0
  23. vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
  24. vigil_forensic/gate_checks/context_health_checks.py +289 -0
  25. vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
  26. vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
  27. vigil_forensic/gate_checks/duplication_checks.py +387 -0
  28. vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
  29. vigil_forensic/gate_checks/empty_output_checks.py +87 -0
  30. vigil_forensic/gate_checks/encoding_checks.py +847 -0
  31. vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
  32. vigil_forensic/gate_checks/fallback_checks.py +41 -0
  33. vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
  34. vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
  35. vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
  36. vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
  37. vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
  38. vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
  39. vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
  40. vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
  41. vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
  42. vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
  43. vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
  44. vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
  45. vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
  46. vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
  47. vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
  48. vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
  49. vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
  50. vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
  51. vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
  52. vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
  53. vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
  54. vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
  55. vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
  56. vigil_forensic/gate_checks/hallucination_checks.py +566 -0
  57. vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
  58. vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
  59. vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
  60. vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
  61. vigil_forensic/gate_checks/ml_checks.py +318 -0
  62. vigil_forensic/gate_checks/performance_checks.py +106 -0
  63. vigil_forensic/gate_checks/project_specific_runner.py +691 -0
  64. vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
  65. vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
  66. vigil_forensic/gate_checks/reliability_checks.py +389 -0
  67. vigil_forensic/gate_checks/reporting_checks.py +55 -0
  68. vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
  69. vigil_forensic/gate_checks/security_injection_checks.py +332 -0
  70. vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
  71. vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
  72. vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
  73. vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
  74. vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
  75. vigil_forensic/gate_checks/test_quality_checks.py +946 -0
  76. vigil_forensic/gate_checks/testing_checks.py +149 -0
  77. vigil_forensic/gate_checks/toctou_checks.py +367 -0
  78. vigil_forensic/gate_checks/type_checking_checks.py +316 -0
  79. vigil_forensic/gate_models.py +392 -0
  80. vigil_forensic/gate_packs/__init__.py +1 -0
  81. vigil_forensic/gate_packs/universal.py +179 -0
  82. vigil_forensic/gate_profile.json +31 -0
  83. vigil_forensic/gate_registry.py +21 -0
  84. vigil_forensic/language_profiles.py +219 -0
  85. vigil_forensic/meta_findings.py +207 -0
  86. vigil_forensic/self_audit.py +725 -0
  87. vigil_forensic/source_analysis.py +175 -0
  88. vigil_mapper/__init__.py +103 -0
  89. vigil_mapper/_ast_helpers_minimal.py +229 -0
  90. vigil_mapper/_extract_imports_impl.py +123 -0
  91. vigil_mapper/_file_count_guard.py +129 -0
  92. vigil_mapper/_git_utils.py +178 -0
  93. vigil_mapper/_runtime_ast.py +438 -0
  94. vigil_mapper/_runtime_dispatch.py +137 -0
  95. vigil_mapper/_seed_helpers.py +82 -0
  96. vigil_mapper/authority_builder.py +1102 -0
  97. vigil_mapper/cli_entry.py +731 -0
  98. vigil_mapper/conflict_builder.py +818 -0
  99. vigil_mapper/data_contract_builder.py +446 -0
  100. vigil_mapper/findings_builder.py +716 -0
  101. vigil_mapper/fingerprint.py +53 -0
  102. vigil_mapper/hotspot_builder.py +539 -0
  103. vigil_mapper/map_common.py +449 -0
  104. vigil_mapper/map_errors.py +55 -0
  105. vigil_mapper/map_models.py +431 -0
  106. vigil_mapper/map_models_ext.py +206 -0
  107. vigil_mapper/map_models_findings.py +130 -0
  108. vigil_mapper/map_storage.py +455 -0
  109. vigil_mapper/parse_cache.py +795 -0
  110. vigil_mapper/refactor_boundary_builder.py +266 -0
  111. vigil_mapper/runtime_builder.py +527 -0
  112. vigil_mapper/runtime_tracer.py +243 -0
  113. vigil_mapper/runtime_tracer_entry.py +199 -0
  114. vigil_mapper/semantic_diff.py +71 -0
  115. vigil_mapper/source_adapters/__init__.py +109 -0
  116. vigil_mapper/source_adapters/_base.py +264 -0
  117. vigil_mapper/source_adapters/_ir.py +156 -0
  118. vigil_mapper/source_adapters/_lexer.py +309 -0
  119. vigil_mapper/source_adapters/_patterns.py +212 -0
  120. vigil_mapper/source_adapters/_treesitter.py +182 -0
  121. vigil_mapper/source_adapters/go.py +553 -0
  122. vigil_mapper/source_adapters/java.py +541 -0
  123. vigil_mapper/source_adapters/javascript.py +626 -0
  124. vigil_mapper/source_adapters/python.py +325 -0
  125. vigil_mapper/source_adapters/typescript.py +749 -0
  126. vigil_mapper/structural_builder.py +586 -0
  127. vigil_mcp/__init__.py +1 -0
  128. vigil_mcp/_jobs.py +587 -0
  129. vigil_mcp/_paths.py +93 -0
  130. vigil_mcp/forensic_server.py +419 -0
  131. 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