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,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
+ )