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,336 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity, RepairKind
6
+ from vigil_forensic.gate_models import PostExecGateContext
7
+ from ..source_analysis import extract_functions, get_language_id, is_source_file
8
+ from .common import build_check_result, build_finding, is_generated_file, iter_touched_snapshots, max_nesting_depth, normalize_path
9
+
10
+ _log = logging.getLogger(__name__)
11
+
12
+
13
+ def _suppress_for_data_module(
14
+ snapshot,
15
+ file_warn: int,
16
+ file_revise: int,
17
+ fn_warn: int,
18
+ fn_revise: int,
19
+ nest_warn: int,
20
+ nest_revise: int,
21
+ ) -> bool:
22
+ """Return True iff legacy thresholds would emit ANY size_complexity finding.
23
+
24
+ Used by the Sprint B4 data_module branch to decide whether a single
25
+ ``applicability=not_applicable`` summary finding should be surfaced.
26
+ If legacy thresholds would not flag the file anyway, skip silently
27
+ (no NA finding inflation).
28
+ """
29
+ if snapshot.line_count >= file_warn:
30
+ return True
31
+ if is_source_file(snapshot.path):
32
+ try:
33
+ for fi in extract_functions(snapshot.path, snapshot.text):
34
+ if fi.line_count >= fn_warn:
35
+ return True
36
+ except Exception: # pragma: no cover -- fail-open
37
+ return False
38
+ if get_language_id(snapshot.path) == "python":
39
+ if max_nesting_depth(snapshot.text) >= nest_warn:
40
+ return True
41
+ return False
42
+
43
+ # NOTE: the size_complexity.zone_overload sub-check was REMOVED (FP fix).
44
+ # It inferred "responsibility zones" from function-name prefixes — the exact
45
+ # same name-prefix heuristic as god_object_zones — and double-reported every
46
+ # file that god_object_zones already flagged (e.g. 7 + 5 findings on the same
47
+ # filelock files). The zone heuristic now has a single home in the opt-in
48
+ # god_object_zones gate (see self_audit._NOISY_OPT_IN_GATES). size_complexity
49
+ # keeps only its objective size / function-length / nesting budget checks.
50
+
51
+
52
+ def run_size_complexity_checks(ctx: PostExecGateContext):
53
+ findings = []
54
+ profile = ctx.repo_profile
55
+ thresholds = (profile.size_thresholds if profile is not None else {}) or {}
56
+ file_warn = int(thresholds.get("file_warn", 600))
57
+ file_revise = int(thresholds.get("file_revise", 800))
58
+ fn_warn = int(thresholds.get("function_warn", 80))
59
+ fn_revise = int(thresholds.get("function_revise", 120))
60
+ nest_warn = int(thresholds.get("nesting_warn", 4))
61
+ nest_revise = int(thresholds.get("nesting_revise", 6))
62
+ # Sprint B4: optional per-file role map (ProjectContext.file_roles).
63
+ # None when ctx.project_context is absent (legacy path) or when the
64
+ # light-tier build is in use. Gate behavior degrades gracefully: when
65
+ # file_roles is None or role.kind is in {"code_module", "generated",
66
+ # "unknown", "test_module"}, legacy threshold + F16d marker + allowlist
67
+ # logic applies unchanged. Only role.kind == "data_module" takes the
68
+ # new suppression branch.
69
+ file_roles = getattr(getattr(ctx, "project_context", None), "file_roles", None)
70
+
71
+ # Sprint C2 (2026-04-23): prefer TestTopology.is_test_path for test-path
72
+ # skip. Legacy basename check preserved as fallback.
73
+ topology = getattr(getattr(ctx, "project_context", None), "test_topology", None)
74
+
75
+ for snapshot in iter_touched_snapshots(ctx):
76
+ if not snapshot.exists:
77
+ continue
78
+ norm_path = snapshot.path.replace("\\", "/")
79
+ if topology is not None:
80
+ if topology.is_test_path(norm_path):
81
+ continue
82
+ elif norm_path.split("/")[-1].startswith("test_"):
83
+ continue
84
+ if profile and snapshot.path in profile.allowlisted_large_files:
85
+ continue
86
+ # F16d: skip auto-generated files and sanctioned asset bundles.
87
+ if is_generated_file(snapshot.text):
88
+ _log.debug(
89
+ "size_complexity: skipping generated/sanctioned file %s",
90
+ snapshot.path,
91
+ )
92
+ continue
93
+
94
+ # Sprint B4: density-based suppression for data-dominant modules.
95
+ # Catalog/registry files (e.g. GATE_SPECS = (...)) are measured by
96
+ # thresholds designed for code; surface a single not_applicable
97
+ # finding when legacy thresholds would have flagged, then skip the
98
+ # rest of the per-file checks.
99
+ if file_roles is not None:
100
+ role = file_roles.role(snapshot.path)
101
+ if role.kind == "data_module":
102
+ if _suppress_for_data_module(
103
+ snapshot,
104
+ file_warn,
105
+ file_revise,
106
+ fn_warn,
107
+ fn_revise,
108
+ nest_warn,
109
+ nest_revise,
110
+ ):
111
+ pct = int(round(role.metrics.data_density_ratio * 100))
112
+ findings.append(
113
+ build_finding(
114
+ check_id="size.applicability_suppressed",
115
+ category=GateCategory.SIZE_COMPLEXITY,
116
+ title="Size/complexity thresholds not applicable to data module",
117
+ severity=GateSeverity.LOW,
118
+ impact=GateImpact.WARN,
119
+ summary=(
120
+ f"{snapshot.path} is a data-dominant module "
121
+ f"({pct}% literal content); size thresholds "
122
+ f"designed for code modules do not apply."
123
+ ),
124
+ recommendation=(
125
+ "Treat catalog/registry files as data, not "
126
+ "code: exempt from LOC/function/nesting "
127
+ "budgets."
128
+ ),
129
+ evidence=[EvidenceReference(kind="file", path=snapshot.path)],
130
+ repair_kind="",
131
+ executor_action="",
132
+ proof_required="",
133
+ allowlist_allowed=True,
134
+ applicability="not_applicable",
135
+ applicability_reason=role.reason,
136
+ analysis_mode="role_map",
137
+ confidence=0.9,
138
+ )
139
+ )
140
+ _log.debug(
141
+ "size_complexity: skipping data_module %s (data_density=%.2f code_density=%.2f)",
142
+ snapshot.path,
143
+ role.metrics.data_density_ratio,
144
+ role.metrics.code_density_ratio,
145
+ )
146
+ continue
147
+ # Other role kinds fall through to legacy path.
148
+ if snapshot.line_count >= file_revise:
149
+ findings.append(
150
+ build_finding(
151
+ check_id="size.file_too_large",
152
+ category=GateCategory.SIZE_COMPLEXITY,
153
+ title="Touched file exceeds the revise threshold",
154
+ severity=GateSeverity.HIGH,
155
+ impact=GateImpact.REVISE,
156
+ summary=f"{snapshot.path} is {snapshot.line_count} lines; profile revise threshold is {file_revise}.",
157
+ recommendation="Split responsibilities or move new logic into smaller modules.",
158
+ evidence=[EvidenceReference(kind="file", path=snapshot.path)],
159
+ repair_kind=RepairKind.SPLIT_MODULE.value,
160
+ executor_action=f"Split {snapshot.path} — {snapshot.line_count} lines exceeds {file_revise}-line threshold; extract each responsibility into a focused module",
161
+ proof_required="file below threshold after split; grep confirms no logic removed",
162
+ allowlist_allowed=False,
163
+ )
164
+ )
165
+ elif snapshot.line_count >= file_warn:
166
+ findings.append(
167
+ build_finding(
168
+ check_id="size.file_warn",
169
+ category=GateCategory.SIZE_COMPLEXITY,
170
+ title="Touched file exceeds the warning threshold",
171
+ severity=GateSeverity.MEDIUM,
172
+ impact=GateImpact.REVISE,
173
+ summary=f"{snapshot.path} is {snapshot.line_count} lines; profile warning threshold is {file_warn}.",
174
+ recommendation="Keep the file from becoming a new god-file.",
175
+ evidence=[EvidenceReference(kind="file", path=snapshot.path)],
176
+ repair_kind=RepairKind.SPLIT_MODULE.value,
177
+ executor_action=f"Watch {snapshot.path} — {snapshot.line_count} lines approaching {file_revise}-line revise threshold",
178
+ )
179
+ )
180
+ if is_source_file(snapshot.path):
181
+ for fi in extract_functions(snapshot.path, snapshot.text):
182
+ if fi.line_count >= fn_revise:
183
+ findings.append(
184
+ build_finding(
185
+ check_id="size.function_too_large",
186
+ category=GateCategory.SIZE_COMPLEXITY,
187
+ title="Touched function exceeds the revise threshold",
188
+ severity=GateSeverity.HIGH,
189
+ impact=GateImpact.REVISE,
190
+ summary=f"{snapshot.path}::{fi.name} is {fi.line_count} lines; profile revise threshold is {fn_revise}.",
191
+ recommendation="Split orchestration, logic, and rendering into smaller helpers.",
192
+ evidence=[EvidenceReference(kind="file", path=snapshot.path, detail=fi.name)],
193
+ repair_kind=RepairKind.REFACTOR.value,
194
+ executor_action=f"Refactor {snapshot.path}::{fi.name} — {fi.line_count} lines; extract sub-steps into named helpers",
195
+ proof_required="function below threshold; tests still pass",
196
+ allowlist_allowed=False,
197
+ )
198
+ )
199
+ break
200
+ if fi.line_count >= fn_warn:
201
+ findings.append(
202
+ build_finding(
203
+ check_id="size.function_warn",
204
+ category=GateCategory.SIZE_COMPLEXITY,
205
+ title="Touched function exceeds the warning threshold",
206
+ severity=GateSeverity.MEDIUM,
207
+ impact=GateImpact.REVISE,
208
+ summary=f"{snapshot.path}::{fi.name} is {fi.line_count} lines; profile warning threshold is {fn_warn}.",
209
+ recommendation="Watch for mixed responsibility growth.",
210
+ evidence=[EvidenceReference(kind="file", path=snapshot.path, detail=fi.name)],
211
+ repair_kind=RepairKind.REFACTOR.value,
212
+ executor_action=f"Watch {snapshot.path}::{fi.name} — {fi.line_count} lines approaching revise threshold",
213
+ )
214
+ )
215
+ break
216
+ if get_language_id(snapshot.path) == "python":
217
+ nesting = max_nesting_depth(snapshot.text)
218
+ if nesting >= nest_revise:
219
+ findings.append(
220
+ build_finding(
221
+ check_id="size.nesting_too_high",
222
+ category=GateCategory.SIZE_COMPLEXITY,
223
+ title="Touched code exceeds nesting threshold",
224
+ severity=GateSeverity.HIGH,
225
+ impact=GateImpact.REVISE,
226
+ summary=f"{snapshot.path} reaches nesting depth {nesting}; profile revise threshold is {nest_revise}.",
227
+ recommendation="Flatten control flow or extract helpers.",
228
+ evidence=[EvidenceReference(kind="file", path=snapshot.path)],
229
+ repair_kind=RepairKind.REFACTOR.value,
230
+ executor_action=f"Flatten {snapshot.path} — nesting depth {nesting} exceeds {nest_revise}; use early returns",
231
+ proof_required="nesting depth below threshold; tests still pass",
232
+ allowlist_allowed=False,
233
+ )
234
+ )
235
+ elif nesting >= nest_warn:
236
+ findings.append(
237
+ build_finding(
238
+ check_id="size.nesting_warn",
239
+ category=GateCategory.SIZE_COMPLEXITY,
240
+ title="Touched code exceeds warning nesting threshold",
241
+ severity=GateSeverity.MEDIUM,
242
+ impact=GateImpact.REVISE,
243
+ summary=f"{snapshot.path} reaches nesting depth {nesting}; profile warning threshold is {nest_warn}.",
244
+ recommendation="Prefer early exits and smaller helpers.",
245
+ evidence=[EvidenceReference(kind="file", path=snapshot.path)],
246
+ repair_kind=RepairKind.REFACTOR.value,
247
+ executor_action=f"Watch {snapshot.path} — nesting depth {nesting} approaching {nest_revise} revise threshold",
248
+ )
249
+ )
250
+ # size_complexity.zone_overload removed (FP fix): name-prefix zone
251
+ # inference now lives only in the opt-in god_object_zones gate.
252
+ return build_check_result(check_id="size_complexity", category=GateCategory.SIZE_COMPLEXITY, findings=findings)
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # hotspot_inflation gate
257
+ # ---------------------------------------------------------------------------
258
+
259
+ _MODE_DO_NOT_TOUCH = "do_not_touch_without_runtime_trace"
260
+ _MODE_FORENSIC_FIRST = "forensic_first"
261
+
262
+
263
+ def run_hotspot_inflation_checks(ctx: PostExecGateContext):
264
+ """Emit a finding for every touched file that is a high-risk hotspot in ctx.maps.
265
+
266
+ Modes that trigger findings:
267
+ - do_not_touch_without_runtime_trace -> HIGH severity
268
+ - forensic_first -> MEDIUM severity
269
+
270
+ No file-fallback: hotspot data is only available via ctx.maps.
271
+ When ctx.maps is absent or missing, return empty findings (fail-open).
272
+ """
273
+ if ctx.maps is None or getattr(ctx.maps, "missing", False):
274
+ _log.debug("hotspot_inflation: ctx.maps not available -- skipping")
275
+ return build_check_result(
276
+ check_id="hotspot_inflation",
277
+ category=GateCategory.SIZE_COMPLEXITY,
278
+ notes=("maps not available -- skipping",),
279
+ )
280
+
281
+ hotspots = getattr(ctx.maps, "hotspot", ()) or ()
282
+ hotspot_by_target: dict[str, object] = {
283
+ normalize_path(h.target): h for h in hotspots
284
+ }
285
+
286
+ findings = []
287
+ for raw_path in (ctx.touched_files or ()):
288
+ normalized = normalize_path(raw_path)
289
+ h = hotspot_by_target.get(normalized)
290
+ if h is None:
291
+ continue
292
+ mode = (getattr(h, "recommended_mode", "") or "").lower()
293
+ if mode == _MODE_DO_NOT_TOUCH:
294
+ severity = GateSeverity.HIGH
295
+ impact = GateImpact.REVISE
296
+ recommendation = (
297
+ "Capture startup trace + regression tests before merging. "
298
+ "This file must not be modified without a runtime trace per map_builder policy."
299
+ )
300
+ elif mode == _MODE_FORENSIC_FIRST:
301
+ severity = GateSeverity.MEDIUM
302
+ impact = GateImpact.REVISE
303
+ recommendation = (
304
+ "Run forensic gates + authority check before refactoring. "
305
+ "This file is flagged forensic_first in the hotspot map."
306
+ )
307
+ else:
308
+ _log.debug("hotspot_inflation: %s mode=%r -- not actionable, skipping", normalized, mode)
309
+ continue
310
+
311
+ hotspot_score = getattr(h, "hotspot_score", 0)
312
+ findings.append(
313
+ build_finding(
314
+ check_id="hotspot_inflation",
315
+ category=GateCategory.SIZE_COMPLEXITY,
316
+ title=f"Touched high-risk hotspot ({mode})",
317
+ severity=severity,
318
+ impact=impact,
319
+ summary=(
320
+ f"{normalized}: hotspot_score={hotspot_score}, mode={mode}. "
321
+ f"Modifying this file requires pre-change forensic trace per map_builder policy."
322
+ ),
323
+ recommendation=recommendation,
324
+ evidence=(EvidenceReference(kind="file", path=normalized),),
325
+ repair_kind=RepairKind.REFACTOR.value,
326
+ executor_action="Address finding details",
327
+ proof_required="Performance acceptable",
328
+ allowlist_allowed=False,
329
+ )
330
+ )
331
+
332
+ return build_check_result(
333
+ check_id="hotspot_inflation",
334
+ category=GateCategory.SIZE_COMPLEXITY,
335
+ findings=findings,
336
+ )
@@ -0,0 +1,354 @@
1
+ """Stuck feature flag forensic gate.
2
+
3
+ Detects module-level UPPER_SNAKE_CASE constants assigned to ``False`` that:
4
+ 1. are referenced inside an ``if`` test somewhere in the touched files,
5
+ 2. are never reassigned to a non-False value anywhere in the touched files,
6
+ 3. are not part of a re-export chain (only False-default literals count).
7
+
8
+ The result is a "stuck flag": code is permanently gated off and forgotten.
9
+ Real-world example from this codebase: ``plan_review_ran = False`` hardcoded
10
+ with the actual review code never running.
11
+
12
+ Project-agnostic — works for any Python codebase. Operates strictly on
13
+ ``ctx.touched_files`` (only ``.py``); files outside ``ctx.project_dir`` are
14
+ skipped.
15
+
16
+ Algorithm:
17
+ Pass 1: collect module-level ``Assign(target=Name(UPPER), value=Constant(False))``
18
+ (excluding TypeVar-style and dunder names).
19
+ Pass 2: across ALL touched .py files, locate any other assignment to the
20
+ same name whose RHS is NOT ``Constant(False)`` — anything else
21
+ (True, function call, attribute, name reference) marks the name as
22
+ "dynamic" and disqualifies it.
23
+ Pass 3: across ALL touched .py files, scan ``If.test`` for usage of the
24
+ candidate name (as bare ``Name``, ``not NAME``, inside ``BoolOp``,
25
+ or as left side of ``Compare``).
26
+
27
+ A finding is emitted for each name with at least one If usage AND no dynamic
28
+ assignment.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import ast
33
+ import logging
34
+ import re
35
+ from pathlib import Path
36
+ from typing import Iterable
37
+
38
+ from vigil_forensic.gate_models import PostExecGateContext
39
+ from vigil_forensic.meta_findings import emit_meta_finding
40
+ from vigil_forensic._shared import (
41
+ EvidenceReference,
42
+ GateCategory,
43
+ GateCheckResult,
44
+ GateImpact,
45
+ GateSeverity,
46
+ RepairKind,
47
+ )
48
+
49
+ from .common import build_check_result, build_finding, normalize_path
50
+
51
+ _log = logging.getLogger(__name__)
52
+
53
+ _CATEGORY = GateCategory.DRIFT
54
+ _CHECK_ID = "stuck_feature_flag"
55
+
56
+ # UPPER_SNAKE_CASE: leading underscore allowed (private module constants).
57
+ # At least one alpha char and one underscore-or-alphanum to avoid lone-letter
58
+ # matches like ``X = False`` (which is almost never a feature flag).
59
+ _UPPER_SNAKE_RE = re.compile(r"^_?[A-Z][A-Z0-9_]*[A-Z0-9]$")
60
+
61
+
62
+ def _is_false_constant(node: ast.AST) -> bool:
63
+ return (
64
+ isinstance(node, ast.Constant)
65
+ and isinstance(node.value, bool)
66
+ and node.value is False
67
+ )
68
+
69
+
70
+ def _module_level_false_assign_target(node: ast.AST) -> str | None:
71
+ """Return the constant name iff *node* is ``NAME = False`` at module top.
72
+
73
+ Caller is responsible for ensuring *node* is a direct child of the module
74
+ (not inside a function/class body).
75
+ """
76
+ if not isinstance(node, ast.Assign):
77
+ return None
78
+ if len(node.targets) != 1:
79
+ return None
80
+ target = node.targets[0]
81
+ if not isinstance(target, ast.Name):
82
+ return None
83
+ name = target.id
84
+ if not _UPPER_SNAKE_RE.match(name):
85
+ return None
86
+ if name.startswith("__") and name.endswith("__"):
87
+ return None
88
+ if not _is_false_constant(node.value):
89
+ return None
90
+ return name
91
+
92
+
93
+ def _module_level_assign_targets(node: ast.AST) -> list[tuple[str, ast.AST]]:
94
+ """Return (name, value) pairs for any module-level ``NAME = <expr>``
95
+ assignment whose target is a single Name. Used by the dynamic-assignment
96
+ pass to detect "assigned to non-False elsewhere".
97
+ """
98
+ out: list[tuple[str, ast.AST]] = []
99
+ if isinstance(node, ast.Assign):
100
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
101
+ out.append((node.targets[0].id, node.value))
102
+ elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
103
+ # ``X |= True`` etc. — counts as dynamic.
104
+ out.append((node.target.id, node.value))
105
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
106
+ if node.value is not None:
107
+ out.append((node.target.id, node.value))
108
+ return out
109
+
110
+
111
+ def _walk_assigns_anywhere(tree: ast.AST) -> Iterable[tuple[str, ast.AST]]:
112
+ """Yield (name, value) for every ``Name = <expr>`` assignment found
113
+ anywhere in the tree (module-level OR inside functions/classes). Used
114
+ to detect cross-scope dynamic reassignment of a candidate flag.
115
+ """
116
+ for node in ast.walk(tree):
117
+ if isinstance(node, ast.Assign):
118
+ for tgt in node.targets:
119
+ if isinstance(tgt, ast.Name):
120
+ yield (tgt.id, node.value)
121
+ elif isinstance(tgt, (ast.Tuple, ast.List)):
122
+ for elt in tgt.elts:
123
+ if isinstance(elt, ast.Name):
124
+ yield (elt.id, node.value)
125
+ elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
126
+ yield (node.target.id, node.value)
127
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
128
+ if node.value is not None:
129
+ yield (node.target.id, node.value)
130
+
131
+
132
+ def _name_is_used_in_if_test(test: ast.AST, name: str) -> bool:
133
+ """Return True if *test* references *name* in any of the supported forms.
134
+
135
+ Supported:
136
+ * ``Name(id=NAME)`` — bare reference
137
+ * ``UnaryOp(op=Not, operand=Name(id=NAME))`` — ``if not NAME``
138
+ * ``BoolOp(values=[..., Name(id=NAME), ...])`` — ``if NAME and X``
139
+ * ``Compare(left=Name(id=NAME), ...)`` — ``if NAME == X``
140
+ """
141
+ if isinstance(test, ast.Name) and test.id == name:
142
+ return True
143
+ if (
144
+ isinstance(test, ast.UnaryOp)
145
+ and isinstance(test.op, ast.Not)
146
+ and isinstance(test.operand, ast.Name)
147
+ and test.operand.id == name
148
+ ):
149
+ return True
150
+ if isinstance(test, ast.BoolOp):
151
+ for v in test.values:
152
+ if _name_is_used_in_if_test(v, name):
153
+ return True
154
+ if isinstance(test, ast.Compare):
155
+ if isinstance(test.left, ast.Name) and test.left.id == name:
156
+ return True
157
+ for cmp_node in test.comparators:
158
+ if isinstance(cmp_node, ast.Name) and cmp_node.id == name:
159
+ return True
160
+ return False
161
+
162
+
163
+ def _collect_if_usage_sites(tree: ast.AST, name: str) -> list[int]:
164
+ """Return 1-based line numbers of every ``If`` whose test references *name*."""
165
+ lines: list[int] = []
166
+ for node in ast.walk(tree):
167
+ if isinstance(node, ast.If) and _name_is_used_in_if_test(node.test, name):
168
+ line = getattr(node, "lineno", 0) or 0
169
+ if line:
170
+ lines.append(int(line))
171
+ return lines
172
+
173
+
174
+ def _resolve_target_path(project_dir: Path, raw_path: str) -> Path | None:
175
+ """Resolve *raw_path* to an absolute Path inside *project_dir*. Return
176
+ ``None`` for files outside the project, missing files, or non-``.py``
177
+ files.
178
+ """
179
+ rel = normalize_path(raw_path)
180
+ if not rel.lower().endswith(".py"):
181
+ return None
182
+ candidate = (project_dir / rel).resolve()
183
+ try:
184
+ project_resolved = project_dir.resolve()
185
+ except OSError:
186
+ return None
187
+ try:
188
+ candidate.relative_to(project_resolved)
189
+ except ValueError:
190
+ return None
191
+ if not candidate.exists() or not candidate.is_file():
192
+ return None
193
+ return candidate
194
+
195
+
196
+ def run_stuck_feature_flag_checks(ctx: PostExecGateContext) -> GateCheckResult:
197
+ """Detect stuck feature flags in ``ctx.touched_files`` (Python only).
198
+
199
+ Returns a GateCheckResult with one finding per stuck flag (advisory WARN).
200
+ """
201
+ project_dir = ctx.project_dir
202
+ touched = tuple(ctx.touched_files or ())
203
+ if not touched:
204
+ return build_check_result(
205
+ check_id=_CHECK_ID,
206
+ category=_CATEGORY,
207
+ notes=[f"{_CHECK_ID}: no touched files"],
208
+ )
209
+
210
+ # Maps name -> (rel_path, line). Uses first-seen False assignment;
211
+ # subsequent False assignments to the same name are tolerated (they are
212
+ # still "False" — not dynamic). The recorded site is the canonical
213
+ # evidence pointer in the finding.
214
+ false_assignments: dict[str, tuple[str, int]] = {}
215
+ # Maps name -> list of (rel_path, line) where it is reassigned to non-False
216
+ # (anywhere — module level, function body, class body). Presence
217
+ # disqualifies the name.
218
+ dynamic_assignments: dict[str, list[tuple[str, int]]] = {}
219
+ # Maps name -> list of (rel_path, line) for if-test usage sites.
220
+ if_usage_sites: dict[str, list[tuple[str, int]]] = {}
221
+
222
+ for raw_path in touched:
223
+ abs_path = _resolve_target_path(project_dir, raw_path)
224
+ if abs_path is None:
225
+ continue
226
+ rel_path = normalize_path(raw_path)
227
+ try:
228
+ source = abs_path.read_text(encoding="utf-8", errors="replace")
229
+ except OSError as exc:
230
+ emit_meta_finding(
231
+ "meta.file_unreadable",
232
+ path=rel_path,
233
+ detail=f"{type(exc).__name__}: {exc}",
234
+ )
235
+ continue
236
+ try:
237
+ tree = ast.parse(source, filename=str(abs_path))
238
+ except SyntaxError as exc:
239
+ emit_meta_finding(
240
+ "meta.syntax_parse_error",
241
+ path=rel_path,
242
+ detail=f"line {exc.lineno}: {exc.msg}",
243
+ )
244
+ continue
245
+
246
+ # Pass 1 — module-level NAME = False
247
+ for node in ast.iter_child_nodes(tree):
248
+ name = _module_level_false_assign_target(node)
249
+ if name is None:
250
+ continue
251
+ line = int(getattr(node, "lineno", 0) or 0)
252
+ false_assignments.setdefault(name, (rel_path, line))
253
+
254
+ # Pass 2 — any assignment anywhere whose RHS is NOT Constant(False)
255
+ for name, value in _walk_assigns_anywhere(tree):
256
+ if not _UPPER_SNAKE_RE.match(name):
257
+ continue
258
+ if name.startswith("__") and name.endswith("__"):
259
+ continue
260
+ if _is_false_constant(value):
261
+ continue
262
+ line = int(getattr(value, "lineno", 0) or 0)
263
+ dynamic_assignments.setdefault(name, []).append((rel_path, line))
264
+
265
+ # Pass 3 — if-test usage scan. Only do this for names that survived
266
+ # pass 1 + pass 2 (cheap optimisation; correctness is unchanged).
267
+ candidates = {
268
+ name for name in false_assignments if name not in dynamic_assignments
269
+ }
270
+ if not candidates:
271
+ return build_check_result(
272
+ check_id=_CHECK_ID,
273
+ category=_CATEGORY,
274
+ notes=[f"{_CHECK_ID}: no candidate False-default constants found"],
275
+ )
276
+
277
+ for raw_path in touched:
278
+ abs_path = _resolve_target_path(project_dir, raw_path)
279
+ if abs_path is None:
280
+ continue
281
+ rel_path = normalize_path(raw_path)
282
+ try:
283
+ source = abs_path.read_text(encoding="utf-8", errors="replace")
284
+ except OSError:
285
+ continue
286
+ try:
287
+ tree = ast.parse(source, filename=str(abs_path))
288
+ except SyntaxError:
289
+ continue
290
+
291
+ for name in candidates:
292
+ for line in _collect_if_usage_sites(tree, name):
293
+ if_usage_sites.setdefault(name, []).append((rel_path, line))
294
+
295
+ # Emit findings.
296
+ findings = []
297
+ for name in sorted(candidates):
298
+ usages = if_usage_sites.get(name, [])
299
+ if not usages:
300
+ continue
301
+ decl_path, decl_line = false_assignments[name]
302
+ evidence: list[EvidenceReference] = [
303
+ EvidenceReference(
304
+ kind="false_default_assignment",
305
+ path=decl_path,
306
+ detail=f"{decl_path}:{decl_line}: {name} = False",
307
+ )
308
+ ]
309
+ for use_path, use_line in usages:
310
+ evidence.append(
311
+ EvidenceReference(
312
+ kind="if_test_usage",
313
+ path=use_path,
314
+ detail=f"{use_path}:{use_line}: if-test references {name}",
315
+ )
316
+ )
317
+ findings.append(
318
+ build_finding(
319
+ check_id=_CHECK_ID,
320
+ category=_CATEGORY,
321
+ title=f"Stuck feature flag: {name}",
322
+ severity=GateSeverity.MEDIUM,
323
+ impact=GateImpact.WARN,
324
+ summary=(
325
+ f"Module constant {name}=False at {decl_path}:{decl_line} "
326
+ f"is used in {len(usages)} conditional(s) but never "
327
+ f"reassigned anywhere in the project. Likely dead/disabled "
328
+ f"feature."
329
+ ),
330
+ recommendation=(
331
+ "Either remove the flag and the gated code, or wire up a "
332
+ "path that sets it to True."
333
+ ),
334
+ evidence=evidence,
335
+ repair_kind=RepairKind.REMOVE_DEAD_SURFACE.value,
336
+ executor_action="Resolve stuck feature flag (remove or wire)",
337
+ proof_required="Flag is either deleted (with its gated code) or set to True via a real code path.",
338
+ allowlist_allowed=True,
339
+ preferred_fix_shape="delete the flag + the dead branch, OR add the missing assignment path",
340
+ )
341
+ )
342
+
343
+ notes: list[str] = []
344
+ if not findings:
345
+ notes.append(
346
+ f"{_CHECK_ID}: {len(candidates)} False-default candidate(s) "
347
+ f"found, none with if-test usage"
348
+ )
349
+ return build_check_result(
350
+ check_id=_CHECK_ID,
351
+ category=_CATEGORY,
352
+ findings=findings,
353
+ notes=notes,
354
+ )