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,95 @@
1
+ """Authority-related forensic checks (Finding 6.1).
2
+
3
+ illegal_authority_writer: flag new writer functions appearing in modules that
4
+ previously had none -- signals unauthorized authority expansion.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity, RepairKind
11
+ from vigil_forensic.gate_models import PostExecGateContext
12
+ from ..source_analysis import is_source_file
13
+ from .common import build_check_result, build_finding, normalize_path
14
+ from vigil_forensic._git_utils import git_show as _git_show
15
+
16
+ _log = logging.getLogger(__name__)
17
+
18
+ WRITER_PREFIXES = ("def write_", "def save_", "def commit_", "def delete_", "def persist_")
19
+
20
+
21
+ def _count_writers(content: str) -> int:
22
+ """Count writer-named function definitions in source content."""
23
+ return sum(content.count(prefix) for prefix in WRITER_PREFIXES)
24
+
25
+
26
+ def run_authority_checks(ctx: PostExecGateContext):
27
+ """Emit findings for NEW writer functions in previously-read-only modules.
28
+
29
+ For each changed .py file observed in the gate context:
30
+ - If prior content (HEAD~1) has ZERO writer-named defs
31
+ - And current content has >=1 writer-named def
32
+ - Then emit a warning finding.
33
+
34
+ Missing files (newly added) are NOT flagged -- new files are new authority
35
+ by definition and should be reviewed separately.
36
+
37
+ Fails open: git unavailable or any I/O error -> skip that file, never crash.
38
+ """
39
+ findings = []
40
+
41
+ for raw_path in ctx.changed_files_observed:
42
+ normalized = normalize_path(raw_path)
43
+ if not is_source_file(normalized):
44
+ continue
45
+
46
+ prior = _git_show(normalized)
47
+ if prior is None:
48
+ # File didn't exist before OR git unavailable — skip, not a violation
49
+ continue
50
+
51
+ abs_path = ctx.project_dir / normalized
52
+ try:
53
+ current = abs_path.read_text(encoding="utf-8")
54
+ except (OSError, UnicodeDecodeError) as exc:
55
+ _log.debug("authority_checks: cannot read current file %s: %s", normalized, exc)
56
+ continue
57
+
58
+ if _count_writers(prior) == 0 and _count_writers(current) > 0:
59
+ findings.append(
60
+ build_finding(
61
+ check_id="illegal_authority_writer.new_writer_in_readonly_module",
62
+ category=GateCategory.CONTRACT,
63
+ title="Illegal authority expansion: new writer function in previously read-only module",
64
+ severity=GateSeverity.MEDIUM,
65
+ impact=GateImpact.REVISE,
66
+ summary=(
67
+ f"{normalized} introduced new writer function(s) "
68
+ f"(write_/save_/commit_/delete_/persist_) where none existed "
69
+ f"previously. Verify authority expansion is intentional and "
70
+ f"covered by authority map."
71
+ ),
72
+ recommendation=(
73
+ "If the writer function is intentional, update the project authority map "
74
+ "to document the new write authority. "
75
+ "If unintentional, remove or relocate the function."
76
+ ),
77
+ evidence=[
78
+ EvidenceReference(
79
+ kind="file",
80
+ path=normalized,
81
+ detail="new_writer_in_previously_readonly_module",
82
+ )
83
+ ],
84
+ repair_kind=RepairKind.VALIDATE_BOUNDARY.value,
85
+ executor_action="Validate authority boundaries",
86
+ proof_required="Authority respected",
87
+ allowlist_allowed=False,
88
+ )
89
+ )
90
+
91
+ return build_check_result(
92
+ check_id="authority_checks",
93
+ category=GateCategory.CONTRACT,
94
+ findings=findings,
95
+ )
@@ -0,0 +1,202 @@
1
+ """Boundary breach forensic gate (Finding 6.5).
2
+
3
+ boundary_breach: detect when a PR touches files that fall outside every
4
+ declared refactor boundary listed in .cortex/maps/70_refactor_boundaries.json.
5
+
6
+ Primary path: use ctx.maps.refactor_boundary when hydrated.
7
+ Fallback: read JSON file directly (legacy callers / maps not loaded).
8
+
9
+ Fail-open: map missing, unreadable, or malformed -> return empty findings.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import fnmatch
14
+ import json
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateCheckResult, GateImpact, GateSeverity, RepairKind
20
+ from vigil_forensic.gate_models import PostExecGateContext
21
+
22
+ _CATEGORY = GateCategory.CONTRACT
23
+ from .common import build_check_result, build_finding, normalize_path
24
+
25
+ _log = logging.getLogger(__name__)
26
+
27
+ _MAP_REL_PATH = ".cortex/maps/70_refactor_boundaries.json"
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Internal helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def _file_in_boundary(file_path: str, includes: list[str]) -> bool:
36
+ """Return True if *file_path* matches any include glob."""
37
+ for pattern in includes:
38
+ if fnmatch.fnmatch(file_path, pattern):
39
+ return True
40
+ return False
41
+
42
+
43
+ def _load_boundaries(project_dir: Path) -> list[dict[str, Any]] | None:
44
+ """Load boundary entries from the map file.
45
+
46
+ Returns a list of entry dicts on success, or None on any failure
47
+ (missing file, JSON error, unexpected top-level shape).
48
+ """
49
+ map_path = project_dir / _MAP_REL_PATH
50
+ try:
51
+ raw = map_path.read_text(encoding="utf-8")
52
+ except OSError as exc:
53
+ _log.debug("boundary_breach: map not found at %s: %s", map_path, exc)
54
+ return None
55
+
56
+ try:
57
+ data = json.loads(raw)
58
+ except json.JSONDecodeError as exc:
59
+ _log.debug("boundary_breach: map JSON malformed: %s", exc)
60
+ return None
61
+
62
+ if not isinstance(data, dict):
63
+ _log.debug("boundary_breach: map root is not a dict")
64
+ return None
65
+
66
+ # Support both top-level keys used across spec versions.
67
+ entries_raw = data.get("entries") or data.get("boundaries")
68
+ if not isinstance(entries_raw, list):
69
+ _log.debug("boundary_breach: map has no 'entries' or 'boundaries' list")
70
+ return None
71
+
72
+ return [e for e in entries_raw if isinstance(e, dict)]
73
+
74
+
75
+ def _build_includes(entry: dict[str, Any]) -> list[str]:
76
+ """Extract glob patterns from an entry regardless of which key is used.
77
+
78
+ Supported keys (in order of preference):
79
+ - ``includes`` (spec-documented key)
80
+ - ``allowed_files`` (map_builder key, exact paths treated as globs)
81
+ """
82
+ includes_raw = entry.get("includes") or entry.get("allowed_files")
83
+ if isinstance(includes_raw, list):
84
+ return [str(p) for p in includes_raw if p]
85
+ return []
86
+
87
+
88
+ def _entry_from_boundary(b: Any) -> dict[str, Any]:
89
+ """Convert a RefactorBoundary dataclass instance to the dict format used internally."""
90
+ return {
91
+ "boundary_id": b.boundary_id,
92
+ "allowed_files": list(b.allowed_files),
93
+ "watch_files": list(b.watch_files),
94
+ "forbidden_files": list(b.forbidden_files),
95
+ # no "active" key → _is_boundary_active returns True (all are active)
96
+ }
97
+
98
+
99
+ def _is_boundary_active(entry: dict[str, Any]) -> bool:
100
+ """Return True if the boundary should be checked.
101
+
102
+ When no ``active`` flag is present all boundaries are considered active.
103
+ When the flag is present it must be truthy to be active.
104
+ """
105
+ if "active" not in entry:
106
+ return True
107
+ return bool(entry["active"])
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Public gate function
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def run_boundary_breach_checks(ctx: PostExecGateContext) -> GateCheckResult:
116
+ """Emit a finding for every changed file that matches no active boundary.
117
+
118
+ Primary path: use ctx.maps.refactor_boundary when hydrated and not missing.
119
+ Fallback: read map JSON file from disk (legacy callers / maps not loaded).
120
+
121
+ Fail-open: if the map is missing or malformed, return empty findings.
122
+ """
123
+ # --- Primary: ctx.maps ---------------------------------------------------
124
+ boundaries: list[dict[str, Any]] | None = None
125
+ if ctx.maps is not None and not getattr(ctx.maps, "missing", False):
126
+ rb = getattr(ctx.maps, "refactor_boundary", None)
127
+ if rb:
128
+ boundaries = [_entry_from_boundary(b) for b in rb]
129
+ _log.debug("boundary_breach: using %d entries from ctx.maps", len(boundaries))
130
+
131
+ # --- Fallback: file read --------------------------------------------------
132
+ if boundaries is None:
133
+ boundaries = _load_boundaries(ctx.project_dir)
134
+
135
+ if boundaries is None:
136
+ return build_check_result(
137
+ check_id="boundary_breach",
138
+ category=_CATEGORY,
139
+ notes=["boundary_breach: map missing or malformed -- skipped (fail-open)"],
140
+ )
141
+
142
+ active_include_lists: list[tuple[str, list[str]]] = []
143
+ for entry in boundaries:
144
+ if not _is_boundary_active(entry):
145
+ continue
146
+ includes = _build_includes(entry)
147
+ if not includes:
148
+ continue
149
+ name = str(entry.get("boundary_id") or entry.get("name") or "unnamed")
150
+ active_include_lists.append((name, includes))
151
+
152
+ if not active_include_lists:
153
+ return build_check_result(
154
+ check_id="boundary_breach",
155
+ category=_CATEGORY,
156
+ notes=["boundary_breach: no active boundaries with include patterns -- skipped"],
157
+ )
158
+
159
+ findings = []
160
+ for raw_path in ctx.changed_files_observed:
161
+ normalized = normalize_path(raw_path)
162
+ in_any = any(
163
+ _file_in_boundary(normalized, includes)
164
+ for _name, includes in active_include_lists
165
+ )
166
+ if not in_any:
167
+ boundary_names = [name for name, _ in active_include_lists]
168
+ findings.append(
169
+ build_finding(
170
+ check_id="boundary_breach.file_outside_all_boundaries",
171
+ category=_CATEGORY,
172
+ title="Changed file is outside all declared refactor boundaries",
173
+ severity=GateSeverity.MEDIUM,
174
+ impact=GateImpact.REVISE,
175
+ summary=(
176
+ f"File '{normalized}' was changed but does not match any active "
177
+ f"boundary ({', '.join(boundary_names)})."
178
+ ),
179
+ recommendation=(
180
+ "Confirm that this change is intentional. If it is part of the "
181
+ "current refactor, update the boundary definition in "
182
+ f"{_MAP_REL_PATH} to include this file."
183
+ ),
184
+ evidence=[
185
+ EvidenceReference(
186
+ kind="changed_file",
187
+ path=normalized,
188
+ detail=f"not matched by boundaries: {boundary_names}",
189
+ )
190
+ ],
191
+ repair_kind=RepairKind.VALIDATE_BOUNDARY.value,
192
+ executor_action="Address finding details",
193
+ proof_required="Authority respected",
194
+ allowlist_allowed=False,
195
+ )
196
+ )
197
+
198
+ return build_check_result(
199
+ check_id="boundary_breach",
200
+ category=_CATEGORY,
201
+ findings=findings,
202
+ )
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+
6
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity, RepairKind
7
+ from vigil_forensic.gate_models import PostExecGateContext
8
+ from ..source_analysis import is_source_file
9
+ from .common import build_check_result, build_finding, iter_touched_snapshots
10
+ import logging
11
+ _log = logging.getLogger(__name__)
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Pattern: except Exception: \n pass (also except Exception as e: \n pass)
15
+ # ---------------------------------------------------------------------------
16
+ _BROAD_EXCEPT_RE = re.compile(
17
+ r'^(\s*)except\s+Exception\s*(?:as\s+\w+)?\s*:\s*\n\1\s+pass\b',
18
+ re.MULTILINE,
19
+ )
20
+
21
+ # NOTE: bare ``except:`` and ``except BaseException:`` are detected via AST in
22
+ # _check_bare_and_base_handlers (not regex) so the handler BODY can be inspected
23
+ # for a re-raise. A line-only regex cannot tell a swallow from the correct
24
+ # cancel-cleanup idiom (``except BaseException: <cleanup>; raise``).
25
+
26
+ # _EXCEPT_RETURN_SENTINEL_RE was removed (fix 3): the regex matched ANY exception
27
+ # type including narrow ones (OSError, ValueError) causing false positives.
28
+ # Replaced by AST-based _check_broad_return_sentinel below.
29
+
30
+ _BROAD_EXCEPTION_NAMES: frozenset[str] = frozenset({"Exception", "BaseException"})
31
+ _SENTINEL_CONSTS: frozenset[object] = frozenset({None})
32
+
33
+
34
+ def _is_broad_handler(handler: ast.ExceptHandler) -> bool:
35
+ """Return True for bare except: or except Exception/BaseException: — mirrors _is_narrow_catch."""
36
+ if handler.type is None:
37
+ return True
38
+ if isinstance(handler.type, ast.Name) and handler.type.id in _BROAD_EXCEPTION_NAMES:
39
+ return True
40
+ if isinstance(handler.type, ast.Tuple):
41
+ for elt in handler.type.elts:
42
+ if isinstance(elt, ast.Name) and elt.id in _BROAD_EXCEPTION_NAMES:
43
+ return True
44
+ return False
45
+
46
+
47
+ def _reraises(handler: ast.ExceptHandler) -> bool:
48
+ """Return True if the handler body re-raises the exception.
49
+
50
+ A ``raise`` statement at the TOP LEVEL of the handler body — either a bare
51
+ ``raise`` (re-raise current exception) or ``raise <something>`` (chain /
52
+ translate) — means the error propagates. This is the cancel-cleanup idiom::
53
+
54
+ except BaseException:
55
+ <cleanup>
56
+ raise
57
+
58
+ which is correct and must NOT be flagged as a swallow. Verified against
59
+ filelock/_api.py:513-517.
60
+
61
+ Only top-level statements of the handler body are considered: a ``raise``
62
+ nested inside an inner ``try``/``if`` that the broad handler could still
63
+ swallow does not count as a guaranteed re-raise.
64
+ """
65
+ return any(isinstance(stmt, ast.Raise) for stmt in handler.body)
66
+
67
+
68
+ def _check_bare_and_base_handlers(text: str, snapshot_path: str, findings: list) -> None:
69
+ """AST-based bare-except / except-BaseException detector.
70
+
71
+ Replaces the line-only regex checks (_BARE_EXCEPT_RE / _BASE_EXCEPTION_RE)
72
+ so the handler BODY can be inspected: a handler that re-raises
73
+ (``except BaseException: ...; raise``) is the correct cancel-cleanup idiom
74
+ and is NOT flagged. Genuine swallows (bare/BaseException with no re-raise)
75
+ are still flagged.
76
+ """
77
+ try:
78
+ tree = ast.parse(text)
79
+ except SyntaxError:
80
+ return
81
+ for node in ast.walk(tree):
82
+ if not isinstance(node, ast.Try):
83
+ continue
84
+ for handler in node.handlers:
85
+ is_bare = handler.type is None
86
+ is_base = (
87
+ isinstance(handler.type, ast.Name)
88
+ and handler.type.id == "BaseException"
89
+ )
90
+ if not (is_bare or is_base):
91
+ continue
92
+ if _reraises(handler):
93
+ # Cancel-cleanup idiom — re-raises, does not swallow.
94
+ continue
95
+ line_no = getattr(handler, "lineno", 0) or 0
96
+ if is_bare:
97
+ _emit(
98
+ findings,
99
+ check_id="broad_except.bare",
100
+ snapshot_path=snapshot_path,
101
+ line_no=line_no,
102
+ title=f"Bare 'except:' in {snapshot_path}:{line_no}",
103
+ summary=(
104
+ f"File {snapshot_path} line {line_no} uses a bare 'except:' clause which "
105
+ "catches all exceptions including SystemExit and KeyboardInterrupt. "
106
+ "This prevents clean process shutdown and hides all errors."
107
+ ),
108
+ repair_kind=RepairKind.REMOVE_FALLBACK.value,
109
+ executor_action="Narrow exception to specific type",
110
+ proof_required="No broad except in file",
111
+ allowlist_allowed=False,
112
+ )
113
+ else:
114
+ _emit(
115
+ findings,
116
+ check_id="broad_except.base_exception",
117
+ snapshot_path=snapshot_path,
118
+ line_no=line_no,
119
+ title=f"'except BaseException' in {snapshot_path}:{line_no}",
120
+ summary=(
121
+ f"File {snapshot_path} line {line_no} catches BaseException, which includes "
122
+ "SystemExit and KeyboardInterrupt. This is equivalent to a bare except and "
123
+ "prevents clean process shutdown."
124
+ ),
125
+ repair_kind=RepairKind.REMOVE_FALLBACK.value,
126
+ executor_action="Narrow exception to specific type",
127
+ proof_required="No broad except in file",
128
+ allowlist_allowed=False,
129
+ )
130
+
131
+
132
+ def _handler_returns_sentinel(handler: ast.ExceptHandler) -> tuple[bool, str]:
133
+ """Return (True, sentinel_str) if handler body contains a return of None/{}/[].
134
+
135
+ Checks ALL return statements in the body (not just the last line), so this
136
+ catches both single-line and multi-line handler bodies.
137
+ """
138
+ _SENTINEL_STRS = {"None": "None", "{}": "{}", "[]": "[]"}
139
+ for node in ast.walk(ast.Module(body=handler.body, type_ignores=[])):
140
+ if isinstance(node, ast.Return):
141
+ val = node.value
142
+ if val is None:
143
+ return True, "None"
144
+ if isinstance(val, ast.Constant) and val.value is None:
145
+ return True, "None"
146
+ if isinstance(val, ast.Dict) and not val.keys:
147
+ return True, "{}"
148
+ if isinstance(val, ast.List) and not val.elts:
149
+ return True, "[]"
150
+ return False, ""
151
+
152
+
153
+ def _check_broad_return_sentinel(text: str, snapshot_path: str, findings: list) -> None:
154
+ """AST-based replacement for the old _EXCEPT_RETURN_SENTINEL_RE check.
155
+
156
+ Only flags broad catches (bare except / except Exception / except BaseException)
157
+ that return a sentinel (None, {}, []). Narrow types (OSError, ValueError, etc.)
158
+ are explicitly excluded — they represent intentional, type-scoped fallbacks.
159
+ """
160
+ try:
161
+ tree = ast.parse(text)
162
+ except SyntaxError:
163
+ return
164
+ for node in ast.walk(tree):
165
+ if not isinstance(node, (ast.Try,)):
166
+ continue
167
+ for handler in getattr(node, "handlers", []):
168
+ if not _is_broad_handler(handler):
169
+ continue
170
+ is_sentinel, sentinel_str = _handler_returns_sentinel(handler)
171
+ if not is_sentinel:
172
+ continue
173
+ line_no = getattr(handler, "lineno", 0) or 0
174
+ findings.append(
175
+ build_finding(
176
+ check_id="broad_except.return_none",
177
+ category=GateCategory.FALLBACK,
178
+ title=f"Silent sentinel return '{sentinel_str}' after broad except in {snapshot_path}:{line_no}",
179
+ severity=GateSeverity.MEDIUM,
180
+ impact=GateImpact.REVISE,
181
+ summary=(
182
+ f"File {snapshot_path} line {line_no} catches an exception and returns a "
183
+ f"sentinel value ({sentinel_str}). The caller cannot distinguish success from "
184
+ "failure — errors are silently swallowed."
185
+ ),
186
+ recommendation=(
187
+ "Narrow the exception to specific types (e.g. OSError, ValueError) "
188
+ "and surface the error via logging or re-raise. "
189
+ "Never silently drop exceptions that cross a function boundary."
190
+ ),
191
+ evidence=[EvidenceReference(kind="file", path=snapshot_path, detail=f"line:{line_no}")],
192
+ repair_kind=RepairKind.REMOVE_FALLBACK.value,
193
+ executor_action="Narrow exception to specific type",
194
+ proof_required="No broad except in file",
195
+ allowlist_allowed=False,
196
+ )
197
+ )
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Pattern: except <anything>: \n log.warning(...) \n pass (log-then-swallow)
201
+ # ---------------------------------------------------------------------------
202
+ _EXCEPT_LOG_SWALLOW_RE = re.compile(
203
+ r'except\s+\w[^:]*:\s*\n(\s+)(?:_log|log|logger|logging)\.\w+\([^)]*\)\s*\n\1pass\b',
204
+ re.MULTILINE,
205
+ )
206
+
207
+
208
+ def _emit(
209
+ findings: list,
210
+ *,
211
+ check_id: str,
212
+ snapshot_path: str,
213
+ line_no: int,
214
+ title: str,
215
+ summary: str,
216
+ repair_kind: str = "",
217
+ executor_action: str = "",
218
+ proof_required: str = "",
219
+ allowlist_allowed: bool = False,
220
+ ) -> None:
221
+ findings.append(
222
+ build_finding(
223
+ check_id=check_id,
224
+ category=GateCategory.FALLBACK,
225
+ title=title,
226
+ severity=GateSeverity.MEDIUM,
227
+ impact=GateImpact.REVISE,
228
+ summary=summary,
229
+ recommendation=(
230
+ "Narrow the exception to specific types (e.g. OSError, ValueError) "
231
+ "and surface the error via logging or re-raise. "
232
+ "Never silently drop exceptions that cross a function boundary."
233
+ ),
234
+ evidence=[EvidenceReference(kind="file", path=snapshot_path, detail=f"line:{line_no}")],
235
+ repair_kind=repair_kind,
236
+ executor_action=executor_action,
237
+ proof_required=proof_required,
238
+ allowlist_allowed=allowlist_allowed,
239
+ )
240
+ )
241
+
242
+
243
+ def run_broad_except_checks(ctx: PostExecGateContext):
244
+ findings: list = []
245
+
246
+ for snapshot in iter_touched_snapshots(ctx):
247
+ if not snapshot.exists or not is_source_file(snapshot.path):
248
+ continue
249
+
250
+ text = snapshot.text
251
+ normalized = snapshot.path
252
+
253
+ # --- broad_except.swallow: except Exception: pass ---
254
+ for match in _BROAD_EXCEPT_RE.finditer(text):
255
+ line_no = text[:match.start()].count("\n") + 1
256
+ _emit(
257
+ findings,
258
+ check_id="broad_except.swallow",
259
+ snapshot_path=normalized,
260
+ line_no=line_no,
261
+ title=f"Broad 'except Exception: pass' in {normalized}:{line_no}",
262
+ summary=(
263
+ f"File {normalized} line {line_no} catches all exceptions and "
264
+ "silently passes. This can hide real errors including filesystem "
265
+ "failures, type errors, and logic bugs."
266
+ ),
267
+ repair_kind=RepairKind.REMOVE_FALLBACK.value,
268
+ executor_action="Narrow exception to specific type",
269
+ proof_required="No broad except in file",
270
+ allowlist_allowed=False,
271
+ )
272
+
273
+ # --- broad_except.bare + broad_except.base_exception: AST-based ---
274
+ # Re-raising handlers (cancel-cleanup idiom) are skipped — see
275
+ # _check_bare_and_base_handlers / _reraises.
276
+ _check_bare_and_base_handlers(text, normalized, findings)
277
+
278
+ # --- broad_except.return_none: AST-based check (only broad catches) ---
279
+ _check_broad_return_sentinel(text, normalized, findings)
280
+
281
+ # --- broad_except.log_swallow: except ...: log.X(...); pass ---
282
+ for match in _EXCEPT_LOG_SWALLOW_RE.finditer(text):
283
+ line_no = text[:match.start()].count("\n") + 1
284
+ _emit(
285
+ findings,
286
+ check_id="broad_except.log_swallow",
287
+ snapshot_path=normalized,
288
+ line_no=line_no,
289
+ title=f"Log-then-swallow pattern in {normalized}:{line_no}",
290
+ summary=(
291
+ f"File {normalized} line {line_no} logs the exception then passes. "
292
+ "The error is silently consumed — callers receive no signal that the "
293
+ "operation failed."
294
+ ),
295
+ repair_kind=RepairKind.REMOVE_FALLBACK.value,
296
+ executor_action="Narrow exception to specific type",
297
+ proof_required="No broad except in file",
298
+ allowlist_allowed=False,
299
+ )
300
+
301
+ return build_check_result(check_id="broad_except", category=GateCategory.FALLBACK, findings=findings)