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,697 @@
|
|
|
1
|
+
"""Context-fallback fail-open detector (Finding G.3 plan v7 + F7 enhancement).
|
|
2
|
+
|
|
3
|
+
Two detection criteria (each emits a distinct ``check_id`` subtype):
|
|
4
|
+
|
|
5
|
+
1. **two_branch_sentinel** — historical F6 pattern:
|
|
6
|
+
``if X is None: <action> else: <action>`` where both branches contain
|
|
7
|
+
actionable statements AND share at least one common assignment target.
|
|
8
|
+
This is the F-004 / F-EMERGENCY sentinel-fallback.
|
|
9
|
+
|
|
10
|
+
2. **fallback_without_else** (F7, 2026-04-23) — actionable None-guard with no
|
|
11
|
+
else. Covers the common fail-loud-alternative / actionable fallback shape
|
|
12
|
+
where the ``else`` is elided because the None-branch explicitly diverts
|
|
13
|
+
control flow or assigns a default to module/self state. Triggers when the
|
|
14
|
+
``if X is None:`` body contains:
|
|
15
|
+
* ``return <non-trivial>`` -- sentinel Name, Call, Attribute, BinOp,
|
|
16
|
+
non-empty literal, etc. NOT flagged: bare ``return``, ``return None``,
|
|
17
|
+
``return True/False``, ``return 0/""``, ``return []/{}/()/set()``.
|
|
18
|
+
These trivial-literal returns are pure-classifier / defensive-input
|
|
19
|
+
patterns, not sentinel-fallbacks (FP tightening 2026-04-23).
|
|
20
|
+
* ``Assign`` to module/self state (``self.X = ...`` / ``cls.X = ...``)
|
|
21
|
+
|
|
22
|
+
**NOT flagged: ``raise ...``** -- a defensive ``raise`` is fail-LOUD (explicit
|
|
23
|
+
error signaling), which is the OPPOSITE of a silent fallback. The gate's
|
|
24
|
+
purpose is to find silent/implicit fallbacks; a raise can never be one.
|
|
25
|
+
Removed from emission 2026-04-23 (F15-A) after triage showed 15/15 action=raise
|
|
26
|
+
findings were genuine fail-loud defensive patterns (~100% FP rate).
|
|
27
|
+
|
|
28
|
+
Priority: if both criteria would match (body has actionable stmt AND there is
|
|
29
|
+
an else branch), criterion 1 wins -- we do not double-flag the same ``if``.
|
|
30
|
+
|
|
31
|
+
Flagged (two-branch):
|
|
32
|
+
if cache is None:
|
|
33
|
+
result = default_value
|
|
34
|
+
else:
|
|
35
|
+
result = cache.compute()
|
|
36
|
+
|
|
37
|
+
Flagged (fallback-without-else):
|
|
38
|
+
if cfg is None:
|
|
39
|
+
return DEFAULT_CONFIG
|
|
40
|
+
if handler is None:
|
|
41
|
+
self._handler = _NULL_HANDLER
|
|
42
|
+
|
|
43
|
+
Not flagged:
|
|
44
|
+
if cfg is not None and cfg.enabled: # short-circuit guard
|
|
45
|
+
if x is None: return # bare bail-out
|
|
46
|
+
if x is None: return None # explicit bare bail-out
|
|
47
|
+
if x is None: return False # pure classifier (FP tightening)
|
|
48
|
+
if x is None: return True # pure classifier (FP tightening)
|
|
49
|
+
if x is None: return [] # empty-collection defensive return
|
|
50
|
+
if x is None: return {} # empty-dict defensive return
|
|
51
|
+
if x is None: return () # empty-tuple defensive return
|
|
52
|
+
if x is None: return 0 # empty-scalar defensive return
|
|
53
|
+
if x is None: return "" # empty-scalar defensive return
|
|
54
|
+
if x is None: raise ValueError("...") # F15-A: fail-loud, not a fallback
|
|
55
|
+
if x is None: log.warning("x"); return # pure logging + bare bail-out
|
|
56
|
+
if env is None: env = os.environ # F14b: same-var rebinding
|
|
57
|
+
# (idiomatic default param)
|
|
58
|
+
if counter is None: counter += 1 # F14b: AugAssign to same var
|
|
59
|
+
|
|
60
|
+
Allowlist: ``# noqa: context_fallback_save`` on the finding line (or the
|
|
61
|
+
preceding line) suppresses both criteria.
|
|
62
|
+
|
|
63
|
+
Fail-open: parse errors, missing files -> DEBUG log + skip, never raise.
|
|
64
|
+
"""
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import ast
|
|
68
|
+
import logging
|
|
69
|
+
import re
|
|
70
|
+
|
|
71
|
+
from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity
|
|
72
|
+
from vigil_forensic.gate_models import PostExecGateContext
|
|
73
|
+
from vigil_forensic.source_analysis import is_source_file
|
|
74
|
+
from .common import build_check_result, build_finding, has_allowlist_for, normalize_path
|
|
75
|
+
|
|
76
|
+
_log = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Internal helpers
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _extract_is_none_var(test: ast.expr) -> str | None:
|
|
84
|
+
"""Return var name from ``X is None`` (Compare, Is, None), else None."""
|
|
85
|
+
if not isinstance(test, ast.Compare):
|
|
86
|
+
return None
|
|
87
|
+
if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Is):
|
|
88
|
+
return None
|
|
89
|
+
if len(test.comparators) != 1:
|
|
90
|
+
return None
|
|
91
|
+
comp = test.comparators[0]
|
|
92
|
+
if not isinstance(comp, ast.Constant) or comp.value is not None:
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(test.left, ast.Name):
|
|
95
|
+
return test.left.id
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _assigned_names(stmts: list[ast.stmt]) -> set[str]:
|
|
100
|
+
"""Return all Names that appear as Assign targets in *stmts* (top-level only)."""
|
|
101
|
+
names: set[str] = set()
|
|
102
|
+
for stmt in stmts:
|
|
103
|
+
if isinstance(stmt, ast.Assign):
|
|
104
|
+
for target in stmt.targets:
|
|
105
|
+
if isinstance(target, ast.Name):
|
|
106
|
+
names.add(target.id)
|
|
107
|
+
elif isinstance(stmt, (ast.AugAssign,)):
|
|
108
|
+
if isinstance(stmt.target, ast.Name):
|
|
109
|
+
names.add(stmt.target.id)
|
|
110
|
+
return names
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_default_rebinding(if_node: ast.If, guarded_var: str) -> bool:
|
|
114
|
+
"""Return True if ``if <guarded_var> is None:`` body rebinds *guarded_var*
|
|
115
|
+
itself to a non-None value -- the idiomatic default-parameter pattern.
|
|
116
|
+
|
|
117
|
+
This shape is NOT a fail-open: after the body runs the variable is
|
|
118
|
+
guaranteed to hold a usable non-None value and the function proceeds
|
|
119
|
+
with normal logic.
|
|
120
|
+
|
|
121
|
+
Matched (returns True -- SKIP, not a fail-open)::
|
|
122
|
+
|
|
123
|
+
if env is None:
|
|
124
|
+
env = os.environ # same-var, non-None rebinding
|
|
125
|
+
|
|
126
|
+
if counter is None:
|
|
127
|
+
counter += 1 # AugAssign to same var (rare but valid)
|
|
128
|
+
|
|
129
|
+
if config is None:
|
|
130
|
+
config: Config = build_default_config() # AnnAssign to same var
|
|
131
|
+
|
|
132
|
+
NOT matched (returns False -- still potential fail-open)::
|
|
133
|
+
|
|
134
|
+
if result is None:
|
|
135
|
+
logger.warning("missing") # no rebinding at all
|
|
136
|
+
|
|
137
|
+
if env is None:
|
|
138
|
+
source = os.environ # rebinding DIFFERENT var, not guarded_var
|
|
139
|
+
|
|
140
|
+
if x is None:
|
|
141
|
+
x = None # rebinding to None (no-op, suspicious)
|
|
142
|
+
|
|
143
|
+
Scans only direct children of ``if_node.body`` (no nested walks) so
|
|
144
|
+
rebinds hidden inside an inner block do not count.
|
|
145
|
+
"""
|
|
146
|
+
for stmt in if_node.body:
|
|
147
|
+
# Plain assignment: ``env = <value>`` (including tuple-target variants
|
|
148
|
+
# that happen to include guarded_var).
|
|
149
|
+
if isinstance(stmt, ast.Assign):
|
|
150
|
+
for target in stmt.targets:
|
|
151
|
+
if isinstance(target, ast.Name) and target.id == guarded_var:
|
|
152
|
+
if not (
|
|
153
|
+
isinstance(stmt.value, ast.Constant)
|
|
154
|
+
and stmt.value.value is None
|
|
155
|
+
):
|
|
156
|
+
return True
|
|
157
|
+
# Annotated assignment: ``env: Mapping[str, str] = os.environ``.
|
|
158
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
159
|
+
if (
|
|
160
|
+
isinstance(stmt.target, ast.Name)
|
|
161
|
+
and stmt.target.id == guarded_var
|
|
162
|
+
and stmt.value is not None
|
|
163
|
+
and not (
|
|
164
|
+
isinstance(stmt.value, ast.Constant)
|
|
165
|
+
and stmt.value.value is None
|
|
166
|
+
)
|
|
167
|
+
):
|
|
168
|
+
return True
|
|
169
|
+
# Augmented assignment: ``counter += 1`` to same var. Value is not
|
|
170
|
+
# a plain None here (AugAssign requires an rvalue expression); still
|
|
171
|
+
# counts as a rebinding to a non-None value.
|
|
172
|
+
elif isinstance(stmt, ast.AugAssign):
|
|
173
|
+
if isinstance(stmt.target, ast.Name) and stmt.target.id == guarded_var:
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _has_actionable_stmt(stmts: list[ast.stmt]) -> bool:
|
|
179
|
+
"""Return True if stmts contain at least one Assign/AugAssign/Return/Raise/Expr(Call)."""
|
|
180
|
+
for stmt in stmts:
|
|
181
|
+
if isinstance(stmt, (ast.Assign, ast.AugAssign, ast.Return, ast.Raise)):
|
|
182
|
+
return True
|
|
183
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
184
|
+
return True
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_trivial_rhs(value: ast.expr | None) -> bool:
|
|
189
|
+
"""Return True if *value* is a TRIVIAL RHS (None / empty collection /
|
|
190
|
+
empty scalar / bare empty-collection constructor call).
|
|
191
|
+
|
|
192
|
+
Trivial shapes:
|
|
193
|
+
- ``Constant(value=None|True|False|0|"")``
|
|
194
|
+
- ``List([])`` / ``Tuple(())`` / ``Set()`` / ``Dict({})`` (no elements)
|
|
195
|
+
- ``tuple()`` / ``list()`` / ``dict()`` / ``set()`` / ``frozenset()`` --
|
|
196
|
+
bare calls with no args / keywords
|
|
197
|
+
- ``None`` (missing rvalue, defensive)
|
|
198
|
+
"""
|
|
199
|
+
if value is None:
|
|
200
|
+
return True
|
|
201
|
+
if isinstance(value, ast.Constant):
|
|
202
|
+
v = value.value
|
|
203
|
+
if v is None:
|
|
204
|
+
return True
|
|
205
|
+
if isinstance(v, bool):
|
|
206
|
+
# bool is subclass of int; handle before int
|
|
207
|
+
return True
|
|
208
|
+
if v == "" or v == 0:
|
|
209
|
+
return True
|
|
210
|
+
return False
|
|
211
|
+
if isinstance(value, ast.List) and not value.elts:
|
|
212
|
+
return True
|
|
213
|
+
if isinstance(value, ast.Tuple) and not value.elts:
|
|
214
|
+
return True
|
|
215
|
+
if isinstance(value, ast.Set) and not value.elts:
|
|
216
|
+
return True
|
|
217
|
+
if isinstance(value, ast.Dict) and not value.keys:
|
|
218
|
+
return True
|
|
219
|
+
if (
|
|
220
|
+
isinstance(value, ast.Call)
|
|
221
|
+
and isinstance(value.func, ast.Name)
|
|
222
|
+
and value.func.id in {"tuple", "list", "dict", "set", "frozenset"}
|
|
223
|
+
and not value.args
|
|
224
|
+
and not value.keywords
|
|
225
|
+
):
|
|
226
|
+
return True
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _none_branch_rhs_is_trivial(stmt: ast.If, shared_var: str) -> bool:
|
|
231
|
+
"""True if the None-branch (``if X is None:``) assigns a TRIVIAL value
|
|
232
|
+
(None / empty collection / empty scalar) to ``shared_var``.
|
|
233
|
+
|
|
234
|
+
Trivial values make the branch explicit Optional-style handling, NOT a
|
|
235
|
+
silent fallback -- the caller can distinguish None/empty from real data.
|
|
236
|
+
|
|
237
|
+
Returns True for these RHS shapes (on ANY assignment to ``shared_var``
|
|
238
|
+
in the direct children of ``stmt.body``):
|
|
239
|
+
|
|
240
|
+
- ``ast.Constant(value=None|True|False|0|"")``
|
|
241
|
+
- ``ast.Tuple/List/Dict/Set`` with zero elements
|
|
242
|
+
- ``ast.Call`` to ``tuple()`` / ``list()`` / ``dict()`` / ``set()`` /
|
|
243
|
+
``frozenset()`` with no args
|
|
244
|
+
|
|
245
|
+
This is the F17-B FP-tightening (2026-04-23). If ANY assignment to
|
|
246
|
+
``shared_var`` in the None-branch uses a trivial RHS the pattern is
|
|
247
|
+
classified as Optional-coercion and the two-branch criterion is
|
|
248
|
+
skipped. If NO assignment to ``shared_var`` uses a trivial RHS we
|
|
249
|
+
assume the None-branch holds a real sentinel (DEFAULT_CONFIG, loader
|
|
250
|
+
call, non-empty literal, etc.) and keep flagging.
|
|
251
|
+
"""
|
|
252
|
+
for node in stmt.body:
|
|
253
|
+
if isinstance(node, ast.Assign):
|
|
254
|
+
for target in node.targets:
|
|
255
|
+
if isinstance(target, ast.Name) and target.id == shared_var:
|
|
256
|
+
if _is_trivial_rhs(node.value):
|
|
257
|
+
return True
|
|
258
|
+
elif isinstance(node, ast.AnnAssign):
|
|
259
|
+
if (
|
|
260
|
+
isinstance(node.target, ast.Name)
|
|
261
|
+
and node.target.id == shared_var
|
|
262
|
+
and _is_trivial_rhs(node.value)
|
|
263
|
+
):
|
|
264
|
+
return True
|
|
265
|
+
elif isinstance(node, ast.AugAssign):
|
|
266
|
+
if isinstance(node.target, ast.Name) and node.target.id == shared_var:
|
|
267
|
+
if _is_trivial_rhs(node.value):
|
|
268
|
+
return True
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
_STRUCTURED_RESULT_NAME_RE = re.compile(
|
|
273
|
+
r"^(?:"
|
|
274
|
+
r"build_[A-Za-z_][A-Za-z0-9_]*_result" # build_check_result, build_gate_result, ...
|
|
275
|
+
r"|build_check_result" # explicit match (covered above, kept for clarity)
|
|
276
|
+
r"|make_[A-Za-z_][A-Za-z0-9_]*" # make_empty_map, make_default_ctx, ...
|
|
277
|
+
r"|create_[A-Za-z_][A-Za-z0-9_]*" # create_empty_report, create_default_result, ...
|
|
278
|
+
r"|empty_[A-Za-z_][A-Za-z0-9_]*" # empty_map, empty_result, ...
|
|
279
|
+
r"|[A-Za-z_][A-Za-z0-9_]*_result" # gate_check_result, fallback_result, ...
|
|
280
|
+
r"|GateCheckResult" # explicit well-known result class
|
|
281
|
+
r"|[A-Z][A-Za-z0-9_]*Result" # PascalCase *Result classes (VerificationResult, CheckResult, ...)
|
|
282
|
+
r")$"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_structured_result_call(value: ast.expr) -> bool:
|
|
287
|
+
"""Return True if *value* is a call / attribute access that propagates a
|
|
288
|
+
structured result object (error-propagation, not silent fallback).
|
|
289
|
+
|
|
290
|
+
Recognised shapes:
|
|
291
|
+
|
|
292
|
+
* ``build_check_result(...)`` / ``build_gate_result(...)`` -- builder funcs
|
|
293
|
+
* ``make_empty_map()`` / ``make_default_ctx(...)`` -- constructor helpers
|
|
294
|
+
* ``create_empty_report(...)`` / ``create_default_result()`` -- creators
|
|
295
|
+
* ``empty_map()`` / ``empty_result(...)`` -- empty-result factories
|
|
296
|
+
* ``fallback_result(...)`` / ``gate_check_result(...)`` -- *_result funcs
|
|
297
|
+
* ``GateCheckResult(check_id=..., findings=[])`` -- dataclass/class call
|
|
298
|
+
* ``VerificationResult(...)`` -- any PascalCase ``*Result`` class call
|
|
299
|
+
* ``obj.empty()`` / ``Klass.empty_for_X(...)`` -- ``.empty*`` methods
|
|
300
|
+
* ``self.empty_result`` / ``obj.default_result`` -- attribute propagating
|
|
301
|
+
an already-constructed structured-result (``Attribute`` access, not Call)
|
|
302
|
+
|
|
303
|
+
All of these are explicit structured-error propagation: the function is
|
|
304
|
+
saying "here's a well-typed empty/default result for this no-op branch"
|
|
305
|
+
rather than silently substituting a sentinel. The result is actionable
|
|
306
|
+
for the caller (they can inspect ``findings``, ``errors``, ``notes``) --
|
|
307
|
+
it is NOT a silent fallback.
|
|
308
|
+
|
|
309
|
+
This is the F16b whitelist (2026-04-23) and runs BEFORE the generic
|
|
310
|
+
"Call/Attribute is non-trivial" check inside ``_is_trivial_return_value``.
|
|
311
|
+
"""
|
|
312
|
+
# Shape 1: direct call to named function / class -- ``f(...)``
|
|
313
|
+
if isinstance(value, ast.Call):
|
|
314
|
+
func = value.func
|
|
315
|
+
# ``name(...)``
|
|
316
|
+
if isinstance(func, ast.Name):
|
|
317
|
+
return bool(_STRUCTURED_RESULT_NAME_RE.match(func.id))
|
|
318
|
+
# ``obj.name(...)`` / ``Klass.name(...)``
|
|
319
|
+
if isinstance(func, ast.Attribute):
|
|
320
|
+
attr = func.attr
|
|
321
|
+
if _STRUCTURED_RESULT_NAME_RE.match(attr):
|
|
322
|
+
return True
|
|
323
|
+
# ``.empty()`` / ``.empty_for_X()`` / ``.default()`` family
|
|
324
|
+
if attr == "empty" or attr.startswith("empty_") or attr.startswith("default_for_"):
|
|
325
|
+
return True
|
|
326
|
+
return False
|
|
327
|
+
return False
|
|
328
|
+
# Shape 2: bare attribute propagating an already-built structured result --
|
|
329
|
+
# ``return self.empty_result`` / ``return obj.default_result``. Only match
|
|
330
|
+
# attribute NAMES that look like structured results to avoid leaking the
|
|
331
|
+
# whitelist to arbitrary ``return self.value`` returns (which may well be
|
|
332
|
+
# real silent fallbacks).
|
|
333
|
+
if isinstance(value, ast.Attribute):
|
|
334
|
+
attr = value.attr
|
|
335
|
+
if _STRUCTURED_RESULT_NAME_RE.match(attr):
|
|
336
|
+
return True
|
|
337
|
+
if attr.startswith("empty_") or attr == "empty":
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _is_trivial_return_value(value: ast.expr) -> bool:
|
|
344
|
+
"""Return True if *value* is a trivial literal that does NOT qualify as an
|
|
345
|
+
actionable fallback.
|
|
346
|
+
|
|
347
|
+
Trivial literals (excluded from the ``fallback_without_else`` criterion to
|
|
348
|
+
reduce FPs from pure classifier / defensive-input-handling functions):
|
|
349
|
+
|
|
350
|
+
* ``Constant(value=True)`` / ``Constant(value=False)`` -- boolean literals
|
|
351
|
+
(pure classifier ``return False``)
|
|
352
|
+
* ``Constant(value=None)`` -- explicit None bail-out
|
|
353
|
+
* ``Constant(value="")`` / ``Constant(value=0)`` -- empty-scalar bail-outs
|
|
354
|
+
* ``List([])`` / ``Dict({})`` / ``Tuple(())`` / ``Set()`` -- empty-collection
|
|
355
|
+
bail-outs (``return []`` is defensive, not a sentinel-fallback)
|
|
356
|
+
|
|
357
|
+
F16b (2026-04-23): structured-result constructors (``build_check_result(...)``
|
|
358
|
+
/ ``make_empty_map()`` / ``GateCheckResult(...)`` / ``self.empty_result``)
|
|
359
|
+
are ALSO treated as trivial for the purpose of this gate -- they are
|
|
360
|
+
explicit error-propagation, not silent fallback. See
|
|
361
|
+
``_is_structured_result_call``.
|
|
362
|
+
|
|
363
|
+
Non-trivial (still flagged): ``Name``, non-whitelisted ``Call``,
|
|
364
|
+
non-whitelisted ``Attribute``, ``BinOp``, non-empty collection literals,
|
|
365
|
+
non-empty/non-zero scalars.
|
|
366
|
+
"""
|
|
367
|
+
if isinstance(value, ast.Constant):
|
|
368
|
+
v = value.value
|
|
369
|
+
if v is None:
|
|
370
|
+
return True
|
|
371
|
+
if isinstance(v, bool):
|
|
372
|
+
# bool is subclass of int; check before int
|
|
373
|
+
return True
|
|
374
|
+
if v == "" or v == 0:
|
|
375
|
+
# empty string / zero
|
|
376
|
+
return True
|
|
377
|
+
return False
|
|
378
|
+
if isinstance(value, ast.List) and not value.elts:
|
|
379
|
+
return True
|
|
380
|
+
if isinstance(value, ast.Tuple) and not value.elts:
|
|
381
|
+
return True
|
|
382
|
+
if isinstance(value, ast.Set) and not value.elts:
|
|
383
|
+
return True
|
|
384
|
+
if isinstance(value, ast.Dict) and not value.keys:
|
|
385
|
+
return True
|
|
386
|
+
# Empty built-in collection constructors: ``return set()`` / ``return list()``
|
|
387
|
+
# / ``return dict()`` / ``return tuple()`` / ``return frozenset()`` with no
|
|
388
|
+
# args are semantically identical to their literal form and equally trivial.
|
|
389
|
+
if (
|
|
390
|
+
isinstance(value, ast.Call)
|
|
391
|
+
and isinstance(value.func, ast.Name)
|
|
392
|
+
and value.func.id in {"set", "list", "dict", "tuple", "frozenset"}
|
|
393
|
+
and not value.args
|
|
394
|
+
and not value.keywords
|
|
395
|
+
):
|
|
396
|
+
return True
|
|
397
|
+
# F16b (2026-04-23): structured-result constructor calls / attribute
|
|
398
|
+
# propagation of pre-built structured results are explicit error propagation,
|
|
399
|
+
# not silent fallback. Treat as trivial -> not flagged.
|
|
400
|
+
if _is_structured_result_call(value):
|
|
401
|
+
return True
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _is_nontrivial_return(stmt: ast.stmt) -> bool:
|
|
406
|
+
"""Return True if *stmt* is ``return <expr>`` where expr is non-trivial.
|
|
407
|
+
|
|
408
|
+
Bare ``return`` (value is None) and trivial literal returns (``return None``,
|
|
409
|
+
``return False``, ``return []``, etc.) are considered trivial bail-outs and
|
|
410
|
+
deliberately excluded so we don't inflate finding count with benign
|
|
411
|
+
defensive guards / pure-classifier returns. See ``_is_trivial_return_value``
|
|
412
|
+
for the full exclusion list.
|
|
413
|
+
"""
|
|
414
|
+
if not isinstance(stmt, ast.Return):
|
|
415
|
+
return False
|
|
416
|
+
value = stmt.value
|
|
417
|
+
if value is None:
|
|
418
|
+
return False
|
|
419
|
+
if _is_trivial_return_value(value):
|
|
420
|
+
return False
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _is_module_or_self_assign(stmt: ast.stmt) -> bool:
|
|
425
|
+
"""Return True if *stmt* assigns to ``self.X`` / ``cls.X`` / module-level attribute.
|
|
426
|
+
|
|
427
|
+
We treat attribute-style assignments as "module/self state" because inside
|
|
428
|
+
a function body they signal mutation of persistent state (object field,
|
|
429
|
+
class attribute) rather than a pure local fallback. This is the 3rd
|
|
430
|
+
actionable shape for the ``fallback_without_else`` criterion.
|
|
431
|
+
"""
|
|
432
|
+
if not isinstance(stmt, (ast.Assign, ast.AugAssign)):
|
|
433
|
+
return False
|
|
434
|
+
targets: list[ast.expr]
|
|
435
|
+
if isinstance(stmt, ast.Assign):
|
|
436
|
+
targets = list(stmt.targets)
|
|
437
|
+
else: # AugAssign
|
|
438
|
+
targets = [stmt.target]
|
|
439
|
+
for target in targets:
|
|
440
|
+
if isinstance(target, ast.Attribute):
|
|
441
|
+
return True
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _actionable_without_else_kind(body: list[ast.stmt]) -> str | None:
|
|
446
|
+
"""Classify an if-body as an actionable fallback without-else.
|
|
447
|
+
|
|
448
|
+
Returns the finding subtype label (``"return_value"`` / ``"state_assign"``)
|
|
449
|
+
or None if the body is not actionable under this criterion. A body may
|
|
450
|
+
contain any number of statements -- we scan for the first matching shape.
|
|
451
|
+
Pure ``return`` / ``return None`` / logging calls followed by bare return
|
|
452
|
+
do NOT match.
|
|
453
|
+
|
|
454
|
+
**``raise`` is NOT an actionable fallback** (F15-A, 2026-04-23): a raise
|
|
455
|
+
is fail-LOUD (explicit error), the OPPOSITE of a silent fallback. The
|
|
456
|
+
gate's purpose is to find silent/implicit fallbacks; a raise can never be
|
|
457
|
+
one. Before this change 15/15 ``action=raise`` findings were genuine
|
|
458
|
+
fail-loud defensive patterns (~100% FP rate) -- emission removed.
|
|
459
|
+
"""
|
|
460
|
+
for stmt in body:
|
|
461
|
+
if _is_nontrivial_return(stmt):
|
|
462
|
+
return "return_value"
|
|
463
|
+
if _is_module_or_self_assign(stmt):
|
|
464
|
+
return "state_assign"
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _detect_context_fallback_in_function(
|
|
469
|
+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
470
|
+
file_path: str,
|
|
471
|
+
) -> list[dict]:
|
|
472
|
+
"""Walk top-level statements of a function; return raw finding dicts.
|
|
473
|
+
|
|
474
|
+
Each hit dict carries a ``subtype`` key:
|
|
475
|
+
|
|
476
|
+
* ``"two_branch"`` -- ``if X is None: <action> else: <action>`` where both
|
|
477
|
+
branches contain actionable statements AND share at least one common
|
|
478
|
+
assignment target (sentinel fallback, F6 criterion).
|
|
479
|
+
* ``"fallback_without_else"`` -- ``if X is None:`` with no else, where the
|
|
480
|
+
body contains an actionable fallback shape (non-trivial return /
|
|
481
|
+
module-or-self assignment) (F7 criterion, 2026-04-23). ``raise`` is
|
|
482
|
+
explicitly EXCLUDED as of F15-A (2026-04-23) -- fail-loud is not a
|
|
483
|
+
silent fallback and has no place in this gate.
|
|
484
|
+
|
|
485
|
+
Priority: if an ``if`` node has an else branch, only the two-branch
|
|
486
|
+
criterion is considered -- we never double-flag the same node.
|
|
487
|
+
|
|
488
|
+
Short-circuit guards (``if X is not None and X.attr ...``) are NOT flagged.
|
|
489
|
+
Bare ``return`` / ``return None`` bail-outs are NOT flagged.
|
|
490
|
+
"""
|
|
491
|
+
raw: list[dict] = []
|
|
492
|
+
body = func_node.body
|
|
493
|
+
for stmt in body:
|
|
494
|
+
if not isinstance(stmt, ast.If):
|
|
495
|
+
continue
|
|
496
|
+
|
|
497
|
+
# Require test to be exactly ``X is None``
|
|
498
|
+
var = _extract_is_none_var(stmt.test)
|
|
499
|
+
if var is None:
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
# --- FP tightening 2026-04-23 (F14b): default-parameter-rebinding ---
|
|
503
|
+
# ``if X is None: X = default`` (same-var rebinding to non-None) is the
|
|
504
|
+
# idiomatic default-argument pattern, NOT a fail-open. Skip both
|
|
505
|
+
# two-branch and fallback_without_else criteria for this shape.
|
|
506
|
+
if _is_default_rebinding(stmt, var):
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
if stmt.orelse:
|
|
510
|
+
# --- Criterion 1: two-branch sentinel fallback ---
|
|
511
|
+
# Both branches must contain at least one actionable statement
|
|
512
|
+
if not _has_actionable_stmt(stmt.body):
|
|
513
|
+
continue
|
|
514
|
+
if not _has_actionable_stmt(stmt.orelse):
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
# Both branches must assign to at least one common target name
|
|
518
|
+
body_targets = _assigned_names(stmt.body)
|
|
519
|
+
else_targets = _assigned_names(stmt.orelse)
|
|
520
|
+
shared_targets = body_targets & else_targets
|
|
521
|
+
if not shared_targets:
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
# --- FP tightening 2026-04-23 (F17-B): Optional-coercion skip ---
|
|
525
|
+
# When the None-branch assigns a TRIVIAL value (None, empty
|
|
526
|
+
# collection, empty scalar) to every shared target, the pattern
|
|
527
|
+
# is explicit Optional handling (caller can distinguish None /
|
|
528
|
+
# empty from real data) rather than a silent sentinel fallback.
|
|
529
|
+
# Only skip if ALL shared targets are trivial; if at least one
|
|
530
|
+
# target binds a real sentinel (DEFAULT_CONFIG, loader call,
|
|
531
|
+
# non-empty literal) we still flag the real silent fallback.
|
|
532
|
+
trivial_shared = {
|
|
533
|
+
var_name for var_name in shared_targets
|
|
534
|
+
if _none_branch_rhs_is_trivial(stmt, var_name)
|
|
535
|
+
}
|
|
536
|
+
if trivial_shared == shared_targets:
|
|
537
|
+
continue # ALL shared targets use trivial None-branch → Optional idiom
|
|
538
|
+
|
|
539
|
+
raw.append({
|
|
540
|
+
"file": file_path,
|
|
541
|
+
"var": var,
|
|
542
|
+
"line": stmt.lineno,
|
|
543
|
+
"func": func_node.name,
|
|
544
|
+
"subtype": "two_branch",
|
|
545
|
+
"action_kind": "shared_assign",
|
|
546
|
+
})
|
|
547
|
+
else:
|
|
548
|
+
# --- Criterion 2: actionable body without else ---
|
|
549
|
+
kind = _actionable_without_else_kind(stmt.body)
|
|
550
|
+
if kind is None:
|
|
551
|
+
continue
|
|
552
|
+
raw.append({
|
|
553
|
+
"file": file_path,
|
|
554
|
+
"var": var,
|
|
555
|
+
"line": stmt.lineno,
|
|
556
|
+
"func": func_node.name,
|
|
557
|
+
"subtype": "fallback_without_else",
|
|
558
|
+
"action_kind": kind,
|
|
559
|
+
})
|
|
560
|
+
return raw
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
# Public gate entry-point
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
_TWO_BRANCH_CHECK_ID = "context_fallback_save.fail_open_none_guard"
|
|
568
|
+
_WITHOUT_ELSE_CHECK_ID = "context_fallback_save.fallback_without_else"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _build_two_branch_finding(hit: dict):
|
|
572
|
+
return build_finding(
|
|
573
|
+
check_id=_TWO_BRANCH_CHECK_ID,
|
|
574
|
+
category=GateCategory.CONTRACT,
|
|
575
|
+
title="Fail-open None guard: X is not None and X.attr short-circuits to wrong branch",
|
|
576
|
+
severity=GateSeverity.MEDIUM,
|
|
577
|
+
impact=GateImpact.REVISE,
|
|
578
|
+
summary=(
|
|
579
|
+
f"{hit['file']}:{hit['line']} in {hit['func']}(): "
|
|
580
|
+
f"``if {hit['var']} is None: <fallback> else: <main>`` -- "
|
|
581
|
+
f"both branches assign to a shared target; when {hit['var']} is None "
|
|
582
|
+
"the sentinel silently replaces real data (F-004 pattern)."
|
|
583
|
+
),
|
|
584
|
+
recommendation=(
|
|
585
|
+
f"Add an explicit guard before the compound condition: "
|
|
586
|
+
f"``if {hit['var']} is None: raise ValueError(...)`` or "
|
|
587
|
+
f"``if {hit['var']} is None: return``. "
|
|
588
|
+
"Do not rely on short-circuit evaluation to handle None silently."
|
|
589
|
+
),
|
|
590
|
+
evidence=[
|
|
591
|
+
EvidenceReference(
|
|
592
|
+
kind="file",
|
|
593
|
+
path=hit["file"],
|
|
594
|
+
detail=(
|
|
595
|
+
f"func={hit['func']} var={hit['var']} "
|
|
596
|
+
f"line={hit['line']} subtype=two_branch"
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
],
|
|
600
|
+
repair_kind="remove_fallback",
|
|
601
|
+
executor_action="Remove/narrow fallback pattern",
|
|
602
|
+
proof_required="No fallback in file",
|
|
603
|
+
allowlist_allowed=False,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _build_without_else_finding(hit: dict):
|
|
608
|
+
kind = hit.get("action_kind", "unknown")
|
|
609
|
+
# Note: ``raise`` is NOT emitted (F15-A, 2026-04-23) -- fail-loud is not
|
|
610
|
+
# a silent fallback. Only ``return_value`` and ``state_assign`` reach here.
|
|
611
|
+
kind_desc = {
|
|
612
|
+
"return_value": "returns a non-trivial value",
|
|
613
|
+
"state_assign": "assigns to module/self state",
|
|
614
|
+
}.get(kind, "performs an actionable fallback")
|
|
615
|
+
return build_finding(
|
|
616
|
+
check_id=_WITHOUT_ELSE_CHECK_ID,
|
|
617
|
+
category=GateCategory.CONTRACT,
|
|
618
|
+
title="Actionable None-guard without else: fallback side-effect without paired main branch",
|
|
619
|
+
severity=GateSeverity.MEDIUM,
|
|
620
|
+
impact=GateImpact.REVISE,
|
|
621
|
+
summary=(
|
|
622
|
+
f"{hit['file']}:{hit['line']} in {hit['func']}(): "
|
|
623
|
+
f"``if {hit['var']} is None:`` body {kind_desc} (no else branch). "
|
|
624
|
+
"This is an actionable fallback decision -- a reviewer must confirm "
|
|
625
|
+
"the chosen alternative (raise / default return / state mutation) is "
|
|
626
|
+
"intentional, not an accidental silent-swallow."
|
|
627
|
+
),
|
|
628
|
+
recommendation=(
|
|
629
|
+
"Confirm the fallback is intentional and documented. If it is, add "
|
|
630
|
+
"``# noqa: context_fallback_save`` on the if-line. If it is not, "
|
|
631
|
+
"either remove the guard (let the caller handle None) or replace the "
|
|
632
|
+
"sentinel with an explicit ``raise`` so the failure is loud."
|
|
633
|
+
),
|
|
634
|
+
evidence=[
|
|
635
|
+
EvidenceReference(
|
|
636
|
+
kind="file",
|
|
637
|
+
path=hit["file"],
|
|
638
|
+
detail=(
|
|
639
|
+
f"func={hit['func']} var={hit['var']} "
|
|
640
|
+
f"line={hit['line']} subtype=fallback_without_else "
|
|
641
|
+
f"action={kind}"
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
],
|
|
645
|
+
repair_kind="remove_fallback",
|
|
646
|
+
executor_action="Confirm intentional or remove fallback pattern",
|
|
647
|
+
proof_required="Allowlist comment or no fallback in file",
|
|
648
|
+
allowlist_allowed=True,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def run_context_fallback_save_checks(ctx: PostExecGateContext):
|
|
653
|
+
"""Detect fail-open None-guard patterns in changed Python files.
|
|
654
|
+
|
|
655
|
+
For each .py file in ctx.changed_files_observed:
|
|
656
|
+
1. Parse AST.
|
|
657
|
+
2. Walk all function defs (including nested).
|
|
658
|
+
3. Apply two detection criteria (see module docstring):
|
|
659
|
+
- two-branch sentinel fallback
|
|
660
|
+
- actionable fallback without else
|
|
661
|
+
4. Suppress per-line via ``# noqa: context_fallback_save``.
|
|
662
|
+
|
|
663
|
+
Fail-open: parse errors / missing files -> DEBUG log, skip, never raise.
|
|
664
|
+
"""
|
|
665
|
+
findings = []
|
|
666
|
+
|
|
667
|
+
for raw_path in ctx.changed_files_observed:
|
|
668
|
+
normalized = normalize_path(raw_path)
|
|
669
|
+
if not is_source_file(normalized):
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
abs_path = ctx.project_dir / normalized
|
|
673
|
+
try:
|
|
674
|
+
src = abs_path.read_text(encoding="utf-8")
|
|
675
|
+
tree = ast.parse(src)
|
|
676
|
+
except (OSError, SyntaxError, UnicodeDecodeError) as exc:
|
|
677
|
+
_log.debug("context_fallback: failed to parse %s: %s", normalized, exc)
|
|
678
|
+
continue
|
|
679
|
+
|
|
680
|
+
for node in ast.walk(tree):
|
|
681
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
682
|
+
continue
|
|
683
|
+
raw_hits = _detect_context_fallback_in_function(node, normalized)
|
|
684
|
+
for hit in raw_hits:
|
|
685
|
+
# Allowlist: respect per-line ``# noqa: context_fallback_save``.
|
|
686
|
+
if has_allowlist_for(src, "context_fallback_save", hit["line"]):
|
|
687
|
+
continue
|
|
688
|
+
if hit.get("subtype") == "fallback_without_else":
|
|
689
|
+
findings.append(_build_without_else_finding(hit))
|
|
690
|
+
else:
|
|
691
|
+
findings.append(_build_two_branch_finding(hit))
|
|
692
|
+
|
|
693
|
+
return build_check_result(
|
|
694
|
+
check_id="context_fallback_save",
|
|
695
|
+
category=GateCategory.CONTRACT,
|
|
696
|
+
findings=findings,
|
|
697
|
+
)
|