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,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