vigil-codeintel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
- vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
- vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
- vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
- vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
- vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
- vigil_forensic/__init__.py +224 -0
- vigil_forensic/_git_utils.py +178 -0
- vigil_forensic/_shared.py +510 -0
- vigil_forensic/_stubs.py +156 -0
- vigil_forensic/gate_checks/__init__.py +1 -0
- vigil_forensic/gate_checks/_ast_helpers.py +629 -0
- vigil_forensic/gate_checks/_deployment_detector.py +573 -0
- vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
- vigil_forensic/gate_checks/authority_checks.py +95 -0
- vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
- vigil_forensic/gate_checks/broad_except_checks.py +301 -0
- vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
- vigil_forensic/gate_checks/common.py +253 -0
- vigil_forensic/gate_checks/config_safety_checks.py +704 -0
- vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
- vigil_forensic/gate_checks/conflict_checks.py +193 -0
- vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
- vigil_forensic/gate_checks/context_health_checks.py +289 -0
- vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
- vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
- vigil_forensic/gate_checks/duplication_checks.py +387 -0
- vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
- vigil_forensic/gate_checks/empty_output_checks.py +87 -0
- vigil_forensic/gate_checks/encoding_checks.py +847 -0
- vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
- vigil_forensic/gate_checks/fallback_checks.py +41 -0
- vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
- vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
- vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
- vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
- vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
- vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
- vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
- vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
- vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
- vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
- vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
- vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
- vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
- vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
- vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
- vigil_forensic/gate_checks/hallucination_checks.py +566 -0
- vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
- vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
- vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
- vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
- vigil_forensic/gate_checks/ml_checks.py +318 -0
- vigil_forensic/gate_checks/performance_checks.py +106 -0
- vigil_forensic/gate_checks/project_specific_runner.py +691 -0
- vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
- vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
- vigil_forensic/gate_checks/reliability_checks.py +389 -0
- vigil_forensic/gate_checks/reporting_checks.py +55 -0
- vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
- vigil_forensic/gate_checks/security_injection_checks.py +332 -0
- vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
- vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
- vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
- vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
- vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
- vigil_forensic/gate_checks/test_quality_checks.py +946 -0
- vigil_forensic/gate_checks/testing_checks.py +149 -0
- vigil_forensic/gate_checks/toctou_checks.py +367 -0
- vigil_forensic/gate_checks/type_checking_checks.py +316 -0
- vigil_forensic/gate_models.py +392 -0
- vigil_forensic/gate_packs/__init__.py +1 -0
- vigil_forensic/gate_packs/universal.py +179 -0
- vigil_forensic/gate_profile.json +31 -0
- vigil_forensic/gate_registry.py +21 -0
- vigil_forensic/language_profiles.py +219 -0
- vigil_forensic/meta_findings.py +207 -0
- vigil_forensic/self_audit.py +725 -0
- vigil_forensic/source_analysis.py +175 -0
- vigil_mapper/__init__.py +103 -0
- vigil_mapper/_ast_helpers_minimal.py +229 -0
- vigil_mapper/_extract_imports_impl.py +123 -0
- vigil_mapper/_file_count_guard.py +129 -0
- vigil_mapper/_git_utils.py +178 -0
- vigil_mapper/_runtime_ast.py +438 -0
- vigil_mapper/_runtime_dispatch.py +137 -0
- vigil_mapper/_seed_helpers.py +82 -0
- vigil_mapper/authority_builder.py +1102 -0
- vigil_mapper/cli_entry.py +731 -0
- vigil_mapper/conflict_builder.py +818 -0
- vigil_mapper/data_contract_builder.py +446 -0
- vigil_mapper/findings_builder.py +716 -0
- vigil_mapper/fingerprint.py +53 -0
- vigil_mapper/hotspot_builder.py +539 -0
- vigil_mapper/map_common.py +449 -0
- vigil_mapper/map_errors.py +55 -0
- vigil_mapper/map_models.py +431 -0
- vigil_mapper/map_models_ext.py +206 -0
- vigil_mapper/map_models_findings.py +130 -0
- vigil_mapper/map_storage.py +455 -0
- vigil_mapper/parse_cache.py +795 -0
- vigil_mapper/refactor_boundary_builder.py +266 -0
- vigil_mapper/runtime_builder.py +527 -0
- vigil_mapper/runtime_tracer.py +243 -0
- vigil_mapper/runtime_tracer_entry.py +199 -0
- vigil_mapper/semantic_diff.py +71 -0
- vigil_mapper/source_adapters/__init__.py +109 -0
- vigil_mapper/source_adapters/_base.py +264 -0
- vigil_mapper/source_adapters/_ir.py +156 -0
- vigil_mapper/source_adapters/_lexer.py +309 -0
- vigil_mapper/source_adapters/_patterns.py +212 -0
- vigil_mapper/source_adapters/_treesitter.py +182 -0
- vigil_mapper/source_adapters/go.py +553 -0
- vigil_mapper/source_adapters/java.py +541 -0
- vigil_mapper/source_adapters/javascript.py +626 -0
- vigil_mapper/source_adapters/python.py +325 -0
- vigil_mapper/source_adapters/typescript.py +749 -0
- vigil_mapper/structural_builder.py +586 -0
- vigil_mcp/__init__.py +1 -0
- vigil_mcp/_jobs.py +587 -0
- vigil_mcp/_paths.py +93 -0
- vigil_mcp/forensic_server.py +419 -0
- vigil_mcp/map_server.py +452 -0
|
@@ -0,0 +1,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
|