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,480 @@
|
|
|
1
|
+
"""Dead code and unused import clusters 20, 23.
|
|
2
|
+
|
|
3
|
+
Clusters:
|
|
4
|
+
20 - Dead Code
|
|
5
|
+
23 - Unused Imports
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .core import detect_language
|
|
12
|
+
from ...gate_models import (
|
|
13
|
+
EvidenceReference,
|
|
14
|
+
GateCategory,
|
|
15
|
+
GateFinding,
|
|
16
|
+
GateImpact,
|
|
17
|
+
GateSeverity,
|
|
18
|
+
RepairKind,
|
|
19
|
+
)
|
|
20
|
+
from ..common import build_finding
|
|
21
|
+
import logging
|
|
22
|
+
_log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Cluster 20: Dead Code
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class DeadCodeItem:
|
|
32
|
+
"""A potentially dead code item with classification."""
|
|
33
|
+
name: str
|
|
34
|
+
file_path: str
|
|
35
|
+
line: int
|
|
36
|
+
kind: str # "function" | "class" | "import"
|
|
37
|
+
classification: str # "dead_code" | "likely_forgotten_wiring" | "standalone_utility"
|
|
38
|
+
reason: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_STANDALONE_MARKERS = frozenset({
|
|
42
|
+
"main", "cli", "entry", "handler", "hook", "callback", "plugin",
|
|
43
|
+
"fixture", "setup", "teardown", "conftest", "register", "migrate",
|
|
44
|
+
"command", "task", "worker", "job", "cron", "schedule",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
_STANDALONE_DECORATORS = (
|
|
48
|
+
"@app.", "@click.", "@pytest.fixture", "@staticmethod",
|
|
49
|
+
"@classmethod", "@property", "@abstractmethod", "@override",
|
|
50
|
+
"@register", "@task", "@celery",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def assess_dead_code(
|
|
55
|
+
items: list[DeadCodeItem],
|
|
56
|
+
) -> list[GateFinding]:
|
|
57
|
+
"""Cluster 20: Classify dead code as truly dead, forgotten wiring, or standalone."""
|
|
58
|
+
if not items:
|
|
59
|
+
return [] # NOT_APPLICABLE
|
|
60
|
+
|
|
61
|
+
findings: list[GateFinding] = []
|
|
62
|
+
for item in items:
|
|
63
|
+
if item.classification == "standalone_utility":
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
is_fail = item.classification in ("dead_code", "likely_forgotten_wiring")
|
|
67
|
+
if not is_fail:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
severity_hint = "FORGOTTEN WIRING" if item.classification == "likely_forgotten_wiring" else "DEAD CODE"
|
|
71
|
+
detail = f"[{severity_hint}] {item.kind} '{item.name}': {item.reason}"
|
|
72
|
+
findings.append(build_finding(
|
|
73
|
+
check_id="dead_code_scan",
|
|
74
|
+
category=GateCategory.DRIFT,
|
|
75
|
+
title=f"[dead_code] {item.file_path}:{item.line}:{item.name}",
|
|
76
|
+
severity=GateSeverity.MEDIUM,
|
|
77
|
+
impact=GateImpact.REVISE,
|
|
78
|
+
summary=detail,
|
|
79
|
+
recommendation=f"Remove or wire up dead code: '{item.name}' in {item.file_path}",
|
|
80
|
+
evidence=(EvidenceReference(kind="probe", path=item.file_path, detail=detail, ok=False),),
|
|
81
|
+
repair_kind=RepairKind.REMOVE_DUPLICATE.value,
|
|
82
|
+
executor_action=f"Remove or wire up '{item.name}' at {item.file_path}:{item.line}",
|
|
83
|
+
))
|
|
84
|
+
return findings
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify_dead_code_item(
|
|
88
|
+
name: str,
|
|
89
|
+
file_path: str,
|
|
90
|
+
line: int,
|
|
91
|
+
kind: str,
|
|
92
|
+
is_referenced_anywhere: bool,
|
|
93
|
+
is_in_all: bool,
|
|
94
|
+
is_recent_commit: bool,
|
|
95
|
+
has_adjacent_caller_file: bool,
|
|
96
|
+
decorator_line: str = "",
|
|
97
|
+
) -> DeadCodeItem:
|
|
98
|
+
"""Classify a potentially unused code item into one of 3 categories."""
|
|
99
|
+
name_lower = name.lower()
|
|
100
|
+
if any(marker in name_lower for marker in _STANDALONE_MARKERS):
|
|
101
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
102
|
+
classification="standalone_utility", reason="Name contains standalone marker")
|
|
103
|
+
|
|
104
|
+
if is_in_all:
|
|
105
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
106
|
+
classification="standalone_utility", reason="Listed in __all__ -- public API")
|
|
107
|
+
|
|
108
|
+
if any(decorator_line.strip().startswith(d) for d in _STANDALONE_DECORATORS):
|
|
109
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
110
|
+
classification="standalone_utility", reason=f"Has framework decorator: {decorator_line.strip()[:40]}")
|
|
111
|
+
|
|
112
|
+
if is_referenced_anywhere:
|
|
113
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
114
|
+
classification="standalone_utility", reason="Referenced elsewhere in project")
|
|
115
|
+
|
|
116
|
+
if is_recent_commit and has_adjacent_caller_file:
|
|
117
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
118
|
+
classification="likely_forgotten_wiring",
|
|
119
|
+
reason="Added recently with adjacent caller file that doesn't use it")
|
|
120
|
+
|
|
121
|
+
if is_recent_commit:
|
|
122
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
123
|
+
classification="likely_forgotten_wiring",
|
|
124
|
+
reason="Added recently but not referenced anywhere")
|
|
125
|
+
|
|
126
|
+
# Precision guard (oracle FP fix): a PUBLIC (non-underscore) symbol that is
|
|
127
|
+
# merely unreferenced WITHIN the scanned set is NOT reliably dead -- it may
|
|
128
|
+
# be library / public API consumed by callers outside the scan scope. Only
|
|
129
|
+
# PRIVATE (underscore-prefixed) unreferenced symbols are treated as dead.
|
|
130
|
+
# Keeps recall on truly-private dead code (e.g. ``_never_called``) while
|
|
131
|
+
# eliminating false positives on public functions in partial/library scans.
|
|
132
|
+
if not name.startswith("_"):
|
|
133
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
134
|
+
classification="standalone_utility",
|
|
135
|
+
reason="Public symbol unreferenced in scan -- may be external API")
|
|
136
|
+
|
|
137
|
+
return DeadCodeItem(name=name, file_path=file_path, line=line, kind=kind,
|
|
138
|
+
classification="dead_code",
|
|
139
|
+
reason="Not referenced anywhere in the project")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Cluster 23: Unused Imports
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _collect_type_checking_import_line_nums(tree: "ast.Module") -> set[int]:
|
|
148
|
+
"""Return line numbers of every Import / ImportFrom node that appears inside
|
|
149
|
+
the BODY of an `if TYPE_CHECKING:` (or `if typing.TYPE_CHECKING:`) block.
|
|
150
|
+
|
|
151
|
+
Only the ``if`` body is scanned, NOT the ``else`` branch. Imports in the
|
|
152
|
+
``else:`` branch of a TYPE_CHECKING guard are RUNTIME imports (the common
|
|
153
|
+
``if TYPE_CHECKING: <type-import> else: <runtime-import-or-fallback>``
|
|
154
|
+
idiom); tagging them as TYPE_CHECKING imports produced false positives on
|
|
155
|
+
real projects (e.g. filelock ``__init__.py`` else-branch imports). Fix
|
|
156
|
+
2026-06-28 (FP-round2-A).
|
|
157
|
+
|
|
158
|
+
AST walk only — no regex on source text.
|
|
159
|
+
"""
|
|
160
|
+
import ast
|
|
161
|
+
|
|
162
|
+
tc_import_lines: set[int] = set()
|
|
163
|
+
for node in ast.walk(tree):
|
|
164
|
+
if not isinstance(node, ast.If):
|
|
165
|
+
continue
|
|
166
|
+
test = node.test
|
|
167
|
+
is_tc_guard = (
|
|
168
|
+
(isinstance(test, ast.Name) and test.id == "TYPE_CHECKING")
|
|
169
|
+
or (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING")
|
|
170
|
+
)
|
|
171
|
+
if not is_tc_guard:
|
|
172
|
+
continue
|
|
173
|
+
# Walk ONLY the body statements (not node.orelse). Nested If/imports
|
|
174
|
+
# inside the body are still collected via ast.walk over each stmt.
|
|
175
|
+
for stmt in node.body:
|
|
176
|
+
for child in ast.walk(stmt):
|
|
177
|
+
if isinstance(child, (ast.Import, ast.ImportFrom)):
|
|
178
|
+
tc_import_lines.add(child.lineno)
|
|
179
|
+
return tc_import_lines
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _collect_runtime_referenced_names(tree: "ast.Module") -> set[str]:
|
|
183
|
+
"""Collect every bare identifier referenced as a runtime VALUE.
|
|
184
|
+
|
|
185
|
+
This covers usages a TYPE_CHECKING import legitimately satisfies that are
|
|
186
|
+
NOT type annotations, and which ``_collect_forward_ref_strings`` misses:
|
|
187
|
+
|
|
188
|
+
* ``Name`` loads anywhere (``TypeVar(...)`` call name, ``X`` value use).
|
|
189
|
+
* ``Attribute`` base names (``te.ParamSpec`` -> ``te``, ``sys.version_info``
|
|
190
|
+
-> ``sys``).
|
|
191
|
+
* ``__all__`` string members (a TYPE_CHECKING import re-exported via
|
|
192
|
+
``__all__`` is a public re-export, not a dead import).
|
|
193
|
+
|
|
194
|
+
Fix 2026-06-28 (FP-round2-A): TYPE_CHECKING imports are treated as USED if
|
|
195
|
+
the name appears in this set, because such imports exist precisely to back
|
|
196
|
+
type-only references — which include runtime ``TypeVar(...)`` construction,
|
|
197
|
+
``sys.version_info`` version-gating, and ``__all__`` re-exports.
|
|
198
|
+
|
|
199
|
+
AST walk only.
|
|
200
|
+
"""
|
|
201
|
+
import ast
|
|
202
|
+
|
|
203
|
+
names: set[str] = set()
|
|
204
|
+
for node in ast.walk(tree):
|
|
205
|
+
if isinstance(node, ast.Name):
|
|
206
|
+
names.add(node.id)
|
|
207
|
+
elif isinstance(node, ast.Attribute):
|
|
208
|
+
# Walk down to the root Name of an attribute chain (a.b.c -> a).
|
|
209
|
+
base = node.value
|
|
210
|
+
while isinstance(base, ast.Attribute):
|
|
211
|
+
base = base.value
|
|
212
|
+
if isinstance(base, ast.Name):
|
|
213
|
+
names.add(base.id)
|
|
214
|
+
elif isinstance(node, ast.Assign):
|
|
215
|
+
# __all__ = ["Foo", "Bar"] — string members are re-exports.
|
|
216
|
+
for target in node.targets:
|
|
217
|
+
if isinstance(target, ast.Name) and target.id == "__all__":
|
|
218
|
+
for elt in ast.walk(node.value):
|
|
219
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
220
|
+
names.add(elt.value)
|
|
221
|
+
return names
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _collect_forward_ref_strings(tree: "ast.Module") -> set[str]:
|
|
225
|
+
"""Collect all bare identifiers referenced as type-expressions.
|
|
226
|
+
|
|
227
|
+
Sources (F9c + F9c-tighten 2026-04-23):
|
|
228
|
+
* AnnAssign annotations (``x: Foo``).
|
|
229
|
+
* Function argument / return annotations.
|
|
230
|
+
* String-quoted forward references inside the above.
|
|
231
|
+
* First argument of ``cast(...)`` / ``typing.cast(...)`` — blind-spot
|
|
232
|
+
C. The cast's first arg is a type expression that MUST keep its
|
|
233
|
+
TYPE_CHECKING import alive. Supports ``cast(Foo, v)`` and
|
|
234
|
+
``cast("Foo", v)``.
|
|
235
|
+
* Second argument of ``isinstance(v, X)`` / ``issubclass(c, X)`` —
|
|
236
|
+
parity case for string-quoted runtime type refs.
|
|
237
|
+
"""
|
|
238
|
+
import ast
|
|
239
|
+
|
|
240
|
+
names: set[str] = set()
|
|
241
|
+
|
|
242
|
+
def _extract_from_annotation(ann: ast.AST | None) -> None:
|
|
243
|
+
if ann is None:
|
|
244
|
+
return
|
|
245
|
+
for sub in ast.walk(ann):
|
|
246
|
+
if isinstance(sub, ast.Name):
|
|
247
|
+
names.add(sub.id)
|
|
248
|
+
elif isinstance(sub, ast.Constant) and isinstance(sub.value, str):
|
|
249
|
+
# Forward ref: "ForensicReport" | "Foo[Bar]" | "list[Foo]"
|
|
250
|
+
try:
|
|
251
|
+
inner = ast.parse(sub.value, mode="eval")
|
|
252
|
+
except SyntaxError:
|
|
253
|
+
# Add any identifier-like token to be safe
|
|
254
|
+
for tok in sub.value.replace("[", " ").replace("]", " ").replace(",", " ").split():
|
|
255
|
+
tok = tok.strip().strip("'\"")
|
|
256
|
+
if tok.isidentifier():
|
|
257
|
+
names.add(tok)
|
|
258
|
+
continue
|
|
259
|
+
for n in ast.walk(inner):
|
|
260
|
+
if isinstance(n, ast.Name):
|
|
261
|
+
names.add(n.id)
|
|
262
|
+
|
|
263
|
+
def _is_cast_call(call: ast.Call) -> bool:
|
|
264
|
+
"""True if *call* is ``cast(...)`` or ``<anything>.cast(...)``."""
|
|
265
|
+
f = call.func
|
|
266
|
+
if isinstance(f, ast.Name) and f.id == "cast":
|
|
267
|
+
return True
|
|
268
|
+
if isinstance(f, ast.Attribute) and f.attr == "cast":
|
|
269
|
+
return True
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
def _is_isinstance_call(call: ast.Call) -> bool:
|
|
273
|
+
f = call.func
|
|
274
|
+
return isinstance(f, ast.Name) and f.id in ("isinstance", "issubclass")
|
|
275
|
+
|
|
276
|
+
for node in ast.walk(tree):
|
|
277
|
+
if isinstance(node, ast.AnnAssign):
|
|
278
|
+
_extract_from_annotation(node.annotation)
|
|
279
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
280
|
+
_extract_from_annotation(node.returns)
|
|
281
|
+
for arg in (
|
|
282
|
+
list(node.args.args)
|
|
283
|
+
+ list(node.args.kwonlyargs)
|
|
284
|
+
+ list(node.args.posonlyargs)
|
|
285
|
+
+ ([node.args.vararg] if node.args.vararg else [])
|
|
286
|
+
+ ([node.args.kwarg] if node.args.kwarg else [])
|
|
287
|
+
):
|
|
288
|
+
if arg is not None:
|
|
289
|
+
_extract_from_annotation(arg.annotation)
|
|
290
|
+
elif isinstance(node, ast.arg):
|
|
291
|
+
_extract_from_annotation(node.annotation)
|
|
292
|
+
# F9c-tighten: cast(TypeName, v) / cast("TypeName", v).
|
|
293
|
+
elif isinstance(node, ast.Call) and _is_cast_call(node) and node.args:
|
|
294
|
+
_extract_from_annotation(node.args[0])
|
|
295
|
+
# F9c-tighten parity: isinstance(v, TypeName) — second-arg type ref.
|
|
296
|
+
elif isinstance(node, ast.Call) and _is_isinstance_call(node) and len(node.args) >= 2:
|
|
297
|
+
_extract_from_annotation(node.args[1])
|
|
298
|
+
return names
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def assess_unused_imports(
|
|
302
|
+
file_path: str,
|
|
303
|
+
content: str,
|
|
304
|
+
project_files_content: dict[str, str] | None = None,
|
|
305
|
+
) -> list[GateFinding]:
|
|
306
|
+
"""Cluster 23: Detect unused imports.
|
|
307
|
+
|
|
308
|
+
TYPE_CHECKING honoring (F9c): imports inside `if TYPE_CHECKING:` blocks are
|
|
309
|
+
treated as forward-reference imports. They are flagged ONLY if the imported
|
|
310
|
+
symbol is never referenced as a type annotation (direct Name, string
|
|
311
|
+
forward-ref, or AnnAssign target). Truly dead TYPE_CHECKING imports still
|
|
312
|
+
raise a finding.
|
|
313
|
+
"""
|
|
314
|
+
import ast
|
|
315
|
+
import re
|
|
316
|
+
|
|
317
|
+
lang = detect_language(file_path)
|
|
318
|
+
if lang != "python":
|
|
319
|
+
return [] # NOT_APPLICABLE
|
|
320
|
+
|
|
321
|
+
if not content.strip():
|
|
322
|
+
return [] # NOT_APPLICABLE
|
|
323
|
+
|
|
324
|
+
# --- AST pre-pass for TYPE_CHECKING detection (F9c) ------------------
|
|
325
|
+
tc_import_line_nums: set[int] = set()
|
|
326
|
+
forward_ref_names: set[str] = set()
|
|
327
|
+
runtime_ref_names: set[str] = set()
|
|
328
|
+
try:
|
|
329
|
+
tree = ast.parse(content)
|
|
330
|
+
except SyntaxError:
|
|
331
|
+
tree = None
|
|
332
|
+
if tree is not None:
|
|
333
|
+
tc_import_line_nums = _collect_type_checking_import_line_nums(tree)
|
|
334
|
+
forward_ref_names = _collect_forward_ref_strings(tree)
|
|
335
|
+
# FP-round2-A (2026-06-28): names referenced as runtime values
|
|
336
|
+
# (TypeVar(...) call, te.ParamSpec attribute base, sys.version_info,
|
|
337
|
+
# __all__ re-export). A TYPE_CHECKING import satisfying any of these is
|
|
338
|
+
# USED, not dead.
|
|
339
|
+
runtime_ref_names = _collect_runtime_referenced_names(tree)
|
|
340
|
+
|
|
341
|
+
lines = content.splitlines()
|
|
342
|
+
|
|
343
|
+
imports: list[tuple[int, str, str]] = [] # (line_num, imported_name, full_line)
|
|
344
|
+
for i, line in enumerate(lines, 1):
|
|
345
|
+
stripped = line.strip()
|
|
346
|
+
if stripped.startswith("#"):
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
m = re.match(r"from\s+[\w.]+\s+import\s+(.+?)(?:\s+#.*)?$", stripped)
|
|
350
|
+
if m:
|
|
351
|
+
names_str = m.group(1)
|
|
352
|
+
if "(" in names_str:
|
|
353
|
+
continued = names_str
|
|
354
|
+
j = i
|
|
355
|
+
while ")" not in continued and j < len(lines):
|
|
356
|
+
j += 1
|
|
357
|
+
continued += " " + lines[j - 1].strip()
|
|
358
|
+
names_str = continued.replace("(", "").replace(")", "")
|
|
359
|
+
|
|
360
|
+
for part in names_str.split(","):
|
|
361
|
+
part = part.strip()
|
|
362
|
+
if not part:
|
|
363
|
+
continue
|
|
364
|
+
if " as " in part:
|
|
365
|
+
alias = part.split(" as ")[1].strip()
|
|
366
|
+
imports.append((i, alias, stripped))
|
|
367
|
+
else:
|
|
368
|
+
name = part.split(".")[0].strip()
|
|
369
|
+
if name and name.isidentifier():
|
|
370
|
+
imports.append((i, name, stripped))
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
m = re.match(r"import\s+([\w.]+)(?:\s+as\s+(\w+))?", stripped)
|
|
374
|
+
if m:
|
|
375
|
+
name = m.group(2) or m.group(1).split(".")[-1]
|
|
376
|
+
imports.append((i, name, stripped))
|
|
377
|
+
|
|
378
|
+
if not imports:
|
|
379
|
+
return [] # NOT_APPLICABLE
|
|
380
|
+
|
|
381
|
+
body_lines = []
|
|
382
|
+
import_line_nums = {line_num for line_num, _, _ in imports}
|
|
383
|
+
for i, line in enumerate(lines, 1):
|
|
384
|
+
if i not in import_line_nums:
|
|
385
|
+
body_lines.append(line)
|
|
386
|
+
body = "\n".join(body_lines)
|
|
387
|
+
|
|
388
|
+
findings: list[GateFinding] = []
|
|
389
|
+
for line_num, name, full_line in imports:
|
|
390
|
+
if name == "annotations" and "future" in full_line:
|
|
391
|
+
continue
|
|
392
|
+
# Bare `TYPE_CHECKING` import is always skipped (it's the guard itself).
|
|
393
|
+
if name == "TYPE_CHECKING":
|
|
394
|
+
continue
|
|
395
|
+
if name.startswith("_"):
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
# --- F9c: TYPE_CHECKING-guarded forward-ref import handling ---------
|
|
399
|
+
is_in_tc_block = line_num in tc_import_line_nums
|
|
400
|
+
if is_in_tc_block:
|
|
401
|
+
# The import is inside `if TYPE_CHECKING:`. Treat it as used if the
|
|
402
|
+
# name appears anywhere as an annotation (direct Name or string
|
|
403
|
+
# forward-ref). Otherwise flag it as a dead TYPE_CHECKING import.
|
|
404
|
+
if name in forward_ref_names:
|
|
405
|
+
continue
|
|
406
|
+
# FP-round2-A (2026-06-28): also treat as used if the name is
|
|
407
|
+
# referenced as a runtime value inside (or outside) the block —
|
|
408
|
+
# TypeVar(...) construction, `te.ParamSpec`/`sys.version_info`
|
|
409
|
+
# attribute base, or an `__all__` re-export. These are the
|
|
410
|
+
# legitimate non-annotation uses a TYPE_CHECKING import backs.
|
|
411
|
+
if name in runtime_ref_names:
|
|
412
|
+
continue
|
|
413
|
+
# Not referenced anywhere in annotations → still flag below with
|
|
414
|
+
# specialized wording.
|
|
415
|
+
findings.append(build_finding(
|
|
416
|
+
check_id="unused_import_scan",
|
|
417
|
+
category=GateCategory.DRIFT,
|
|
418
|
+
title=f"[unused_imports] {file_path}:{line_num}:{name}",
|
|
419
|
+
severity=GateSeverity.LOW,
|
|
420
|
+
impact=GateImpact.WARN,
|
|
421
|
+
summary=(
|
|
422
|
+
f"Import '{name}' at line {line_num} is inside `if TYPE_CHECKING:` "
|
|
423
|
+
f"but is never used as a type annotation"
|
|
424
|
+
),
|
|
425
|
+
recommendation=f"Remove unused TYPE_CHECKING import '{name}' from {file_path}.",
|
|
426
|
+
evidence=(EvidenceReference(
|
|
427
|
+
kind="probe",
|
|
428
|
+
path=file_path,
|
|
429
|
+
detail=(
|
|
430
|
+
f"Import '{name}' inside TYPE_CHECKING block at line {line_num} "
|
|
431
|
+
f"is not referenced by any annotation"
|
|
432
|
+
),
|
|
433
|
+
ok=False,
|
|
434
|
+
),),
|
|
435
|
+
repair_kind=RepairKind.REMOVE_DUPLICATE.value,
|
|
436
|
+
executor_action=f"Remove unused TYPE_CHECKING import '{name}' at line {line_num}",
|
|
437
|
+
))
|
|
438
|
+
if len(findings) >= 20:
|
|
439
|
+
break
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
if " as " in full_line:
|
|
443
|
+
parts = full_line.split(" as ")
|
|
444
|
+
if len(parts) >= 2:
|
|
445
|
+
original = parts[-2].strip().split()[-1].strip().rstrip(",")
|
|
446
|
+
alias = parts[-1].strip().split(",")[0].split("#")[0].strip()
|
|
447
|
+
if original == alias:
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
all_match = re.search(r"__all__\s*=\s*[\[\(](.*?)[\]\)]", content, re.DOTALL)
|
|
451
|
+
if all_match and f'"{name}"' in all_match.group(1) or all_match and f"'{name}'" in all_match.group(1):
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
pattern = rf"\b{re.escape(name)}\b"
|
|
455
|
+
used_in_body = bool(re.search(pattern, body))
|
|
456
|
+
|
|
457
|
+
if not used_in_body:
|
|
458
|
+
type_patterns = [
|
|
459
|
+
rf"(?::\s*|->\s*){re.escape(name)}\b",
|
|
460
|
+
rf"\b{re.escape(name)}\[",
|
|
461
|
+
rf",\s*{re.escape(name)}\]",
|
|
462
|
+
]
|
|
463
|
+
if any(re.search(tp, body) for tp in type_patterns):
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
findings.append(build_finding(
|
|
467
|
+
check_id="unused_import_scan",
|
|
468
|
+
category=GateCategory.DRIFT,
|
|
469
|
+
title=f"[unused_imports] {file_path}:{line_num}:{name}",
|
|
470
|
+
severity=GateSeverity.LOW,
|
|
471
|
+
impact=GateImpact.WARN,
|
|
472
|
+
summary=f"Import '{name}' at line {line_num} is not used in file body",
|
|
473
|
+
recommendation=f"Remove unused import '{name}' from {file_path}.",
|
|
474
|
+
evidence=(EvidenceReference(kind="probe", path=file_path, detail=f"Import '{name}' at line {line_num} is not used in file body", ok=False),),
|
|
475
|
+
repair_kind=RepairKind.REMOVE_DUPLICATE.value,
|
|
476
|
+
executor_action=f"Remove unused import '{name}' at line {line_num}",
|
|
477
|
+
))
|
|
478
|
+
if len(findings) >= 20:
|
|
479
|
+
break
|
|
480
|
+
return findings
|