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,556 @@
1
+ """C53: Legacy Compatibility Debt.
2
+
3
+ Detects obsolete compatibility/shim layers via structure-based analysis
4
+ (not relying on naming conventions like "legacy/shim/compat").
5
+
6
+ Sub-checks:
7
+ forwarding_wrapper -- module is >70% re-export lines, no domain logic
8
+ unused_shim_module -- module exports have zero non-test callers in repo
9
+ stale_migration_marker -- comment contains stale migration TODO/DEPRECATED marker
10
+ shape_adapter_without_producer -- dict key transform with no active producer
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ from ...gate_models import (
18
+ EvidenceReference,
19
+ GateCategory,
20
+ GateFinding,
21
+ GateImpact,
22
+ GateSeverity,
23
+ RepairKind,
24
+ )
25
+ from ..common import (
26
+ build_finding,
27
+ collect_constant_container_literal_lines,
28
+ is_section_header_comment,
29
+ )
30
+ from .._ast_helpers import collect_string_constant_line_ranges
31
+ import logging
32
+ _log = logging.getLogger(__name__)
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _REEXPORT_PATTERN = re.compile(
39
+ r"^\s*(?:from\s+[\w.]+\s+import\s+\S|(\w+)\s*=\s*[\w.]+\.\w+)",
40
+ re.MULTILINE,
41
+ )
42
+
43
+ _FUNCTION_DEF_PATTERN = re.compile(
44
+ r"^\s*(?:async\s+)?def\s+\w+\s*\(",
45
+ re.MULTILINE,
46
+ )
47
+
48
+ _MIGRATION_COMMENT_PATTERN = re.compile(
49
+ r"#.*?(?:TODO\s*:\s*migrate|legacy|old\s+path|DEPRECATED)",
50
+ re.IGNORECASE,
51
+ )
52
+
53
+ _SHAPE_ADAPTER_PATTERN = re.compile(
54
+ r"""if\s+["'](\w+)["']\s+in\s+\w+\s*:\s*\n\s*\w+\[["']\w+["']\]\s*=\s*\w+\.pop\(["'](\w+)["']\)""",
55
+ re.MULTILINE,
56
+ )
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Sanctioned forwarding hubs (per CLAUDE.md — do NOT flag these)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ _SANCTIONED_FORWARDING_HUBS: frozenset[str] = frozenset({
63
+ "INTERFACE/cli/cli.py",
64
+ "SYSTEM/runtime/app.py",
65
+ "SYSTEM/runtime/pocketcoder_adapter.py",
66
+ "INTERFACE/operator/operator_assets.py",
67
+ "SYSTEM/execution/pocketcoder_executor.py",
68
+ "BRAIN/autoforensics/gate_checks/forensic_clusters/__init__.py",
69
+ "BRAIN/autoforensics/gate_models.py",
70
+ })
71
+
72
+ _NOQA_LEGACY_COMPAT_PATTERN = re.compile(
73
+ r"#\s*noqa:\s*legacy-compat",
74
+ re.IGNORECASE,
75
+ )
76
+
77
+
78
+ def _is_sanctioned_hub(file_path: str) -> bool:
79
+ """Return True if file_path is a CLAUDE.md-sanctioned re-export hub."""
80
+ normalized = file_path.replace("\\", "/")
81
+ # Strip leading drive / absolute prefix to match the relative hub paths
82
+ for hub in _SANCTIONED_FORWARDING_HUBS:
83
+ if normalized == hub or normalized.endswith("/" + hub):
84
+ return True
85
+ return False
86
+
87
+
88
+ def _has_noqa_legacy_compat(content: str) -> bool:
89
+ """Return True if file contains '# noqa: legacy-compat' near the top (first 30 lines)."""
90
+ head = "\n".join(content.splitlines()[:30])
91
+ return bool(_NOQA_LEGACY_COMPAT_PATTERN.search(head))
92
+
93
+
94
+ # Threshold: if >=70% of non-blank, non-comment lines are re-export lines
95
+ _REEXPORT_RATIO_THRESHOLD = 0.70
96
+ # Maximum real function bodies (>5 body lines) to still qualify as a pure wrapper
97
+ _MAX_REAL_FUNCTIONS = 2
98
+ # Maximum total code lines for a forwarding wrapper
99
+ _MAX_TOTAL_CODE_LINES = 30
100
+
101
+
102
+ def _count_reexport_lines(content: str) -> tuple[int, int]:
103
+ """Return (reexport_line_count, total_code_line_count)."""
104
+ lines = content.splitlines()
105
+ code_lines = [
106
+ line for line in lines
107
+ if line.strip() and not line.strip().startswith("#")
108
+ and not line.strip().startswith('"""')
109
+ and not line.strip().startswith("'''")
110
+ and not line.strip() in ("from __future__ import annotations", "")
111
+ ]
112
+ reexport_lines = [
113
+ line for line in code_lines
114
+ if re.match(r"^\s*from\s+[\w.]+\s+import\s+", line)
115
+ or re.match(r"^\s*\w+\s*=\s*[\w.]+\.\w+\s*$", line)
116
+ ]
117
+ return len(reexport_lines), len(code_lines)
118
+
119
+
120
+ def _count_substantial_functions(content: str) -> int:
121
+ """Count function defs with body > 5 lines (excluding docstring-only)."""
122
+ try:
123
+ import ast
124
+ tree = ast.parse(content)
125
+ except SyntaxError:
126
+ return 0
127
+
128
+ count = 0
129
+ for node in ast.walk(tree):
130
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
131
+ continue
132
+ body_lines = getattr(node, "end_lineno", 0) - getattr(node, "lineno", 0)
133
+ if body_lines > 5:
134
+ # Exclude pure docstring functions
135
+ body = node.body
136
+ if len(body) == 1 and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
137
+ continue
138
+ count += 1
139
+ return count
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Caller index — built ONCE per audit, O(N), replaces per-module O(N) rescans.
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def _extract_imported_stems(content: str) -> set[str]:
148
+ """Return the set of module stems / top-level names this file imports.
149
+
150
+ AST-based (precise); falls back to empty on SyntaxError. Used to invert
151
+ the corpus into stem -> importers so unused_shim is an O(1) lookup instead
152
+ of an O(N) substring rescan of every other file.
153
+ """
154
+ import ast
155
+ stems: set[str] = set()
156
+ try:
157
+ tree = ast.parse(content)
158
+ except SyntaxError:
159
+ return stems
160
+ for node in ast.walk(tree):
161
+ if isinstance(node, ast.Import):
162
+ for alias in node.names:
163
+ dotted = alias.name
164
+ stems.add(dotted.split(".")[0])
165
+ stems.add(dotted.split(".")[-1])
166
+ elif isinstance(node, ast.ImportFrom):
167
+ if node.module:
168
+ stems.add(node.module.split(".")[0])
169
+ stems.add(node.module.split(".")[-1])
170
+ for alias in node.names:
171
+ stems.add(alias.name)
172
+ return stems
173
+
174
+
175
+ def build_import_index(all_content: dict[str, str]) -> dict[str, frozenset[str]]:
176
+ """Invert the corpus into ``stem -> frozenset(importer_paths)`` in ONE O(N) pass.
177
+
178
+ Replaces the previous O(N^2) pattern where every shim candidate rescanned
179
+ the entire corpus with a substring search. Test files are kept in the index
180
+ (callers filter them out) so the index is reusable.
181
+ """
182
+ from collections import defaultdict
183
+ importers: dict[str, set[str]] = defaultdict(set)
184
+ for path, content in all_content.items():
185
+ for stem in _extract_imported_stems(content):
186
+ importers[stem].add(path)
187
+ return {stem: frozenset(paths) for stem, paths in importers.items()}
188
+
189
+
190
+ def _is_test_path(norm_path: str) -> bool:
191
+ return (
192
+ "/test" in norm_path
193
+ or "\\test" in norm_path
194
+ or norm_path.startswith("test")
195
+ )
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Sub-check 1: forwarding_wrapper
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ def check_forwarding_wrapper(
204
+ file_path: str,
205
+ content: str,
206
+ ) -> list[GateFinding]:
207
+ """Detect modules that are >70% re-export lines with no domain logic."""
208
+ if not content.strip():
209
+ return []
210
+
211
+ # Skip CLAUDE.md-sanctioned re-export hubs.
212
+ if _is_sanctioned_hub(file_path):
213
+ return []
214
+
215
+ # Skip files that opt-out with an inline noqa comment.
216
+ if _has_noqa_legacy_compat(content):
217
+ return []
218
+
219
+ reexport_count, total_code_lines = _count_reexport_lines(content)
220
+ if total_code_lines == 0:
221
+ return []
222
+
223
+ ratio = reexport_count / total_code_lines
224
+ if ratio < _REEXPORT_RATIO_THRESHOLD:
225
+ return []
226
+
227
+ substantial_funcs = _count_substantial_functions(content)
228
+ if substantial_funcs > _MAX_REAL_FUNCTIONS:
229
+ return []
230
+
231
+ if total_code_lines > _MAX_TOTAL_CODE_LINES:
232
+ return []
233
+
234
+ detail = (
235
+ f"{reexport_count}/{total_code_lines} code lines are re-exports "
236
+ f"({ratio:.0%}); {substantial_funcs} substantial function bodies found"
237
+ )
238
+ return [build_finding(
239
+ check_id="legacy_compat_debt.forwarding_wrapper",
240
+ category=GateCategory.DRIFT,
241
+ title=f"[legacy_compat_debt.forwarding_wrapper] {file_path}",
242
+ severity=GateSeverity.MEDIUM,
243
+ impact=GateImpact.REVISE,
244
+ summary=(
245
+ f"{file_path} is a forwarding wrapper: {detail}. "
246
+ "Callers should import directly from the canonical module."
247
+ ),
248
+ recommendation=(
249
+ "Remove the forwarding wrapper. Update all callers to import "
250
+ "from the canonical module directly."
251
+ ),
252
+ evidence=(EvidenceReference(kind="probe", path=file_path, detail=detail, ok=False),),
253
+ repair_kind=RepairKind.REMOVE_DEAD_SURFACE.value,
254
+ executor_action=(
255
+ "Remove forwarding wrapper and update callers to import from canonical module"
256
+ ),
257
+ proof_required="no callers reference the wrapper after fix; grep confirms",
258
+ allowlist_allowed=True,
259
+ preferred_fix_shape="delete file; update all importers to point to canonical module",
260
+ )]
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Sub-check 2: unused_shim_module
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def _module_is_pure_reexport_shim(content: str) -> bool:
269
+ """F9e: return True IFF the module has *no* substantive AST content at
270
+ module level. A canonical owner is any module containing at least one of:
271
+
272
+ - FunctionDef / AsyncFunctionDef
273
+ - ClassDef
274
+ - If / While / Try / With (domain logic control flow)
275
+
276
+ Permitted pure-reexport nodes (shim-only):
277
+ - Import / ImportFrom
278
+ - Assign(targets=[Name], value=Name) -- plain re-export alias
279
+ - Assign targeting __all__ -- list/tuple of names
280
+ - Expr(Constant(str)) -- docstring
281
+ - AnnAssign for __all__ (rare)
282
+ """
283
+ import ast
284
+
285
+ try:
286
+ tree = ast.parse(content)
287
+ except SyntaxError:
288
+ return False
289
+
290
+ for node in ast.iter_child_nodes(tree):
291
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
292
+ return False
293
+ if isinstance(node, (ast.If, ast.While, ast.Try, ast.With, ast.For, ast.AsyncFor, ast.AsyncWith)):
294
+ return False
295
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
296
+ continue
297
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
298
+ # Module docstring (or other bare constant) — non-substantive.
299
+ continue
300
+ if isinstance(node, ast.Assign):
301
+ # Shape permitted: targets are Name(s); value is a simple Name /
302
+ # Attribute / List/Tuple of string constants (for __all__).
303
+ is_all_assignment = (
304
+ len(node.targets) == 1
305
+ and isinstance(node.targets[0], ast.Name)
306
+ and node.targets[0].id == "__all__"
307
+ )
308
+ if is_all_assignment:
309
+ continue
310
+ # Plain alias: `Foo = SomeName` or `Foo = other.attr`
311
+ if (
312
+ all(isinstance(t, ast.Name) for t in node.targets)
313
+ and isinstance(node.value, (ast.Name, ast.Attribute))
314
+ ):
315
+ continue
316
+ # Any other Assign (call, complex expression, dict, etc.) is
317
+ # substantive.
318
+ return False
319
+ if isinstance(node, ast.AnnAssign):
320
+ # Permit only __all__ annotation
321
+ if isinstance(node.target, ast.Name) and node.target.id == "__all__":
322
+ continue
323
+ return False
324
+ # Any other node type (e.g., Raise, Global, Nonlocal, Delete) is
325
+ # substantive — real logic.
326
+ return False
327
+ return True
328
+
329
+
330
+ def check_unused_shim_module(
331
+ file_path: str,
332
+ content: str,
333
+ import_index: dict[str, frozenset[str]] | None = None,
334
+ ) -> list[GateFinding]:
335
+ """Detect modules whose exports have zero non-test callers in the repo.
336
+
337
+ F9e: a module is considered a shim ONLY if its top-level AST is limited
338
+ to imports, re-export assignments, `__all__`, and a docstring. Modules
339
+ containing `def`, `class`, `if`/`while`/`try`/`with`/`for` blocks are
340
+ canonical owners and skipped regardless of caller count.
341
+
342
+ Caller detection uses a prebuilt ``import_index`` (``stem -> importer
343
+ paths``) — an O(1) lookup built once per audit by ``build_import_index``,
344
+ replacing the previous O(N) per-module substring rescan of the whole
345
+ corpus (which made the whole gate O(N^2) and hung on large monorepos).
346
+ """
347
+ if not content.strip():
348
+ return []
349
+ if import_index is None:
350
+ return []
351
+
352
+ # P4: a package __init__.py that re-exports is legitimate — real callers
353
+ # write ``from package import X``, never ``from package.__init__``, so an
354
+ # __init__ shim always *looks* like it has 0 callers. Never flag it.
355
+ if Path(file_path).name == "__init__.py":
356
+ return []
357
+
358
+ # F9e: skip canonical owners (anything with real module-level logic).
359
+ if not _module_is_pure_reexport_shim(content):
360
+ return []
361
+
362
+ stem = Path(file_path).stem
363
+
364
+ # Find symbols exported from this file (top-level defs and imports)
365
+ exported_names: list[str] = []
366
+ try:
367
+ import ast
368
+ tree = ast.parse(content)
369
+ for node in ast.iter_child_nodes(tree):
370
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
371
+ exported_names.append(node.name)
372
+ elif isinstance(node, ast.ImportFrom):
373
+ for alias in node.names:
374
+ exported_names.append(alias.asname or alias.name)
375
+ except SyntaxError:
376
+ return []
377
+
378
+ if not exported_names:
379
+ return []
380
+
381
+ # O(1) caller lookup: which files import this module's stem? Exclude self
382
+ # and test files (a shim used only by tests is still effectively dead).
383
+ norm_self = file_path.replace("\\", "/")
384
+ importers = import_index.get(stem, frozenset())
385
+ caller_count = sum(
386
+ 1 for imp in importers
387
+ if imp.replace("\\", "/") != norm_self
388
+ and not _is_test_path(imp.replace("\\", "/"))
389
+ )
390
+
391
+ if caller_count > 0:
392
+ return []
393
+
394
+ detail = (
395
+ f"Module {file_path!r} (stem={stem!r}) has {len(exported_names)} exports "
396
+ f"but 0 non-test callers found in repo"
397
+ )
398
+ return [build_finding(
399
+ check_id="legacy_compat_debt.unused_shim_module",
400
+ category=GateCategory.DRIFT,
401
+ title=f"[legacy_compat_debt.unused_shim_module] {file_path}",
402
+ severity=GateSeverity.MEDIUM,
403
+ impact=GateImpact.REVISE,
404
+ summary=(
405
+ f"{detail}. The module may be a dead shim that was never cleaned up."
406
+ ),
407
+ recommendation=(
408
+ "Verify no dynamic imports exist, then remove the module. "
409
+ "If still needed, add a caller or document why it exists."
410
+ ),
411
+ evidence=(EvidenceReference(kind="probe", path=file_path, detail=detail, ok=False),),
412
+ repair_kind=RepairKind.REMOVE_DEAD_SURFACE.value,
413
+ executor_action=(
414
+ "Delete unused shim module after verifying no dynamic import callers"
415
+ ),
416
+ proof_required="grep confirms 0 import references to this module; module deleted",
417
+ allowlist_allowed=True,
418
+ preferred_fix_shape="delete module; confirm with grep",
419
+ )]
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Sub-check 3: stale_migration_marker
424
+ # ---------------------------------------------------------------------------
425
+
426
+
427
+ def check_stale_migration_marker(
428
+ file_path: str,
429
+ content: str,
430
+ ) -> list[GateFinding]:
431
+ """Detect stale migration markers (TODO: migrate, legacy, DEPRECATED, etc.)."""
432
+ if not content.strip():
433
+ return []
434
+
435
+ # F14c sub-fix 1: skip string literals inside UPPER_CASE module-level
436
+ # container assignments (e.g. the regex-literal inside
437
+ # ``_MIGRATION_COMMENT_PATTERN = re.compile(r"...legacy...")`` won't
438
+ # appear as a Name target here, but for regex-based scans we also skip
439
+ # lines that belong to such containers in case future refactor moves
440
+ # markers into a list).
441
+ skip_lines = set(collect_constant_container_literal_lines(content))
442
+ # F14c extra: also skip interior lines of multi-line string constants
443
+ # (docstrings that talk about migration/legacy patterns).
444
+ skip_lines |= set(collect_string_constant_line_ranges(content))
445
+
446
+ findings: list[GateFinding] = []
447
+ lines = content.splitlines()
448
+ for line_num, line in enumerate(lines, 1):
449
+ if line_num in skip_lines:
450
+ continue
451
+ # F14c sub-fix 2: skip visual section-header separator comments such
452
+ # as ``# -- legacy_debt (C53) --`` so the gate doesn't flag its own
453
+ # section markers.
454
+ if is_section_header_comment(line):
455
+ continue
456
+ if not re.search(_MIGRATION_COMMENT_PATTERN, line):
457
+ continue
458
+ # Extract the matched comment snippet
459
+ snippet = line.strip()[:120]
460
+ detail = f"Stale migration marker at line {line_num}: {snippet!r}"
461
+ findings.append(build_finding(
462
+ check_id="legacy_compat_debt.stale_migration_marker",
463
+ category=GateCategory.DRIFT,
464
+ title=f"[legacy_compat_debt.stale_migration_marker] {file_path}:{line_num}",
465
+ severity=GateSeverity.MEDIUM,
466
+ impact=GateImpact.REVISE,
467
+ summary=(
468
+ f"{file_path}:{line_num} contains a stale migration marker. {detail}. "
469
+ "Either complete the migration or remove the obsolete marker."
470
+ ),
471
+ recommendation=(
472
+ "Complete the migration referenced by this comment, or remove the stale marker "
473
+ "if the migration was already done."
474
+ ),
475
+ evidence=(EvidenceReference(
476
+ kind="probe", path=file_path, detail=detail, ok=False,
477
+ ),),
478
+ repair_kind=RepairKind.REMOVE_DEAD_SURFACE.value,
479
+ executor_action=(
480
+ "Either complete the migration or remove the stale marker"
481
+ ),
482
+ proof_required="marker removed or migration completed; grep confirms no reference to old path",
483
+ allowlist_allowed=True,
484
+ preferred_fix_shape="remove comment and complete or discard the migration",
485
+ ))
486
+ if len(findings) >= 10:
487
+ break
488
+ return findings
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # Sub-check 4: shape_adapter_without_producer
493
+ # ---------------------------------------------------------------------------
494
+
495
+
496
+ def check_shape_adapter_without_producer(
497
+ file_path: str,
498
+ content: str,
499
+ all_project_files_content: dict[str, str] | None = None,
500
+ ) -> list[GateFinding]:
501
+ """Detect dict-shape adapters whose old-shape key has no active producer."""
502
+ if not content.strip():
503
+ return []
504
+
505
+ matches = list(_SHAPE_ADAPTER_PATTERN.finditer(content))
506
+ if not matches:
507
+ return []
508
+
509
+ findings: list[GateFinding] = []
510
+ for m in matches:
511
+ old_key = m.group(1)
512
+ line_num = content[: m.start()].count("\n") + 1
513
+
514
+ # Check if the old key has any producer outside this file
515
+ producer_count = 0
516
+ if all_project_files_content is not None:
517
+ for other_path, other_content in all_project_files_content.items():
518
+ norm_other = other_path.replace("\\", "/")
519
+ if norm_other == file_path.replace("\\", "/"):
520
+ continue
521
+ # Look for writes of old_key as dict key
522
+ if f'"{old_key}"' in other_content or f"'{old_key}'" in other_content:
523
+ producer_count += 1
524
+
525
+ if producer_count > 0:
526
+ continue
527
+
528
+ detail = (
529
+ f"Shape adapter at line {line_num} converts key {old_key!r} to new form, "
530
+ f"but grep finds 0 producers of {old_key!r} outside this file"
531
+ )
532
+ findings.append(build_finding(
533
+ check_id="legacy_compat_debt.shape_adapter_without_producer",
534
+ category=GateCategory.DRIFT,
535
+ title=f"[legacy_compat_debt.shape_adapter_without_producer] {file_path}:{line_num}",
536
+ severity=GateSeverity.MEDIUM,
537
+ impact=GateImpact.REVISE,
538
+ summary=(
539
+ f"{detail}. The adapter is dead code: nothing produces the old shape it handles."
540
+ ),
541
+ recommendation=(
542
+ f"Remove the shape adapter for key {old_key!r}. "
543
+ "If the old shape is still produced by a dynamic path, document it explicitly."
544
+ ),
545
+ evidence=(EvidenceReference(
546
+ kind="probe", path=file_path, detail=detail, ok=False,
547
+ ),),
548
+ repair_kind=RepairKind.REMOVE_DEAD_SURFACE.value,
549
+ executor_action=(
550
+ f"Remove shape adapter for old key {old_key!r}; verify no producer exists with grep"
551
+ ),
552
+ proof_required="grep confirms no producer of old shape; adapter code removed",
553
+ allowlist_allowed=True,
554
+ preferred_fix_shape="delete adapter block; confirm with grep",
555
+ ))
556
+ return findings