deadpush 0.2.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.
- deadpush/__init__.py +1 -0
- deadpush/churn.py +189 -0
- deadpush/cli.py +1584 -0
- deadpush/comments.py +265 -0
- deadpush/complexity.py +254 -0
- deadpush/config.py +284 -0
- deadpush/crawler.py +133 -0
- deadpush/deadness.py +477 -0
- deadpush/debris.py +729 -0
- deadpush/deps.py +323 -0
- deadpush/deps_guard.py +382 -0
- deadpush/entrypoints.py +193 -0
- deadpush/graph.py +401 -0
- deadpush/guard.py +1386 -0
- deadpush/hooks.py +369 -0
- deadpush/importgraph.py +122 -0
- deadpush/imports.py +239 -0
- deadpush/intercept.py +995 -0
- deadpush/languages/__init__.py +143 -0
- deadpush/languages/base.py +70 -0
- deadpush/languages/cpp.py +150 -0
- deadpush/languages/go_.py +177 -0
- deadpush/languages/java.py +185 -0
- deadpush/languages/javascript.py +202 -0
- deadpush/languages/python_.py +278 -0
- deadpush/languages/rust.py +147 -0
- deadpush/languages/typescript.py +192 -0
- deadpush/layers.py +197 -0
- deadpush/mcp_server.py +1061 -0
- deadpush/reachability.py +183 -0
- deadpush/registration.py +280 -0
- deadpush/report.py +113 -0
- deadpush/rules.py +190 -0
- deadpush/sarif.py +123 -0
- deadpush/scorer.py +151 -0
- deadpush/security.py +187 -0
- deadpush/session.py +224 -0
- deadpush/tests.py +333 -0
- deadpush/ui.py +156 -0
- deadpush/verifier.py +168 -0
- deadpush/watch.py +103 -0
- deadpush-0.2.0.dist-info/METADATA +230 -0
- deadpush-0.2.0.dist-info/RECORD +46 -0
- deadpush-0.2.0.dist-info/WHEEL +4 -0
- deadpush-0.2.0.dist-info/entry_points.txt +2 -0
- deadpush-0.2.0.dist-info/licenses/LICENSE +21 -0
deadpush/hooks.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extension Hooks for deadpush AI Agent Guardian.
|
|
3
|
+
|
|
4
|
+
This module provides clean extension points for future integrations with
|
|
5
|
+
Cursor, Claude Code, Windsurf, and other AI coding environments.
|
|
6
|
+
|
|
7
|
+
Currently a foundation. Future versions can expose:
|
|
8
|
+
- Pre-file-creation hooks
|
|
9
|
+
- Pre-git-commit hooks (beyond current pre-push)
|
|
10
|
+
- Custom rule engines
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HookRegistry:
|
|
23
|
+
"""Simple hook registry for extensibility."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self._hooks: dict[str, list[Callable]] = {}
|
|
27
|
+
|
|
28
|
+
def register(self, event: str, func: Callable):
|
|
29
|
+
if event not in self._hooks:
|
|
30
|
+
self._hooks[event] = []
|
|
31
|
+
self._hooks[event].append(func)
|
|
32
|
+
|
|
33
|
+
def trigger(self, event: str, *args, **kwargs) -> list[Any]:
|
|
34
|
+
results = []
|
|
35
|
+
for func in self._hooks.get(event, []):
|
|
36
|
+
try:
|
|
37
|
+
results.append(func(*args, **kwargs))
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"Hook {event} failed: {e}")
|
|
40
|
+
return results
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Global registry instance
|
|
44
|
+
hooks = HookRegistry()
|
|
45
|
+
|
|
46
|
+
# Example usage in future:
|
|
47
|
+
# hooks.register("before_create_file", my_custom_check)
|
|
48
|
+
# hooks.trigger("before_create_file", filepath)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_precommit_guardrails(repo_root: Path) -> tuple[bool, list[dict[str, Any]]]:
|
|
52
|
+
"""Run guardrails on staged files. Returns (passed, violations)."""
|
|
53
|
+
import subprocess
|
|
54
|
+
from .intercept import (
|
|
55
|
+
_check_prompt_injection,
|
|
56
|
+
_check_hardcoded_secrets,
|
|
57
|
+
_check_security,
|
|
58
|
+
_check_debris_patterns,
|
|
59
|
+
_check_layer_violations,
|
|
60
|
+
_check_dependency_integrity,
|
|
61
|
+
)
|
|
62
|
+
from .config import load_config
|
|
63
|
+
from .rules import RuntimeConfig
|
|
64
|
+
|
|
65
|
+
config = load_config(explicit_root=repo_root)
|
|
66
|
+
runtime = RuntimeConfig(repo_root)
|
|
67
|
+
violations: list[dict[str, Any]] = []
|
|
68
|
+
|
|
69
|
+
# Get staged files
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
73
|
+
capture_output=True, text=True, check=False, timeout=10,
|
|
74
|
+
cwd=repo_root,
|
|
75
|
+
)
|
|
76
|
+
if result.returncode != 0:
|
|
77
|
+
return True, []
|
|
78
|
+
staged = [l.strip() for l in result.stdout.splitlines() if l.strip()]
|
|
79
|
+
except Exception:
|
|
80
|
+
return True, []
|
|
81
|
+
|
|
82
|
+
for rel_path in staged:
|
|
83
|
+
# Only check text files we understand
|
|
84
|
+
ext = Path(rel_path).suffix.lower()
|
|
85
|
+
if ext not in (".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".rb", ".php", ".sh", ".bash", ".yaml", ".yml", ".json", ".toml", ".md"):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Get staged content
|
|
89
|
+
try:
|
|
90
|
+
content = subprocess.run(
|
|
91
|
+
["git", "show", f":{rel_path}"],
|
|
92
|
+
capture_output=True, text=True, check=False, timeout=5,
|
|
93
|
+
cwd=repo_root,
|
|
94
|
+
)
|
|
95
|
+
if content.returncode != 0:
|
|
96
|
+
continue
|
|
97
|
+
source = content.stdout
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Run guardrails
|
|
102
|
+
for check_fn, category in [
|
|
103
|
+
(_check_prompt_injection, "prompt_injection"),
|
|
104
|
+
(_check_hardcoded_secrets, "secret"),
|
|
105
|
+
(_check_security, "security"),
|
|
106
|
+
]:
|
|
107
|
+
for v in check_fn(source, runtime):
|
|
108
|
+
violations.append({
|
|
109
|
+
"file": rel_path,
|
|
110
|
+
"line": v.line,
|
|
111
|
+
"category": category,
|
|
112
|
+
"description": v.description,
|
|
113
|
+
"severity": v.severity,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
for v in _check_debris_patterns(source, ext, runtime):
|
|
117
|
+
violations.append({
|
|
118
|
+
"file": rel_path,
|
|
119
|
+
"line": v.line,
|
|
120
|
+
"category": "debris",
|
|
121
|
+
"description": v.description,
|
|
122
|
+
"severity": v.severity,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
for v in _check_layer_violations(source, rel_path, config, runtime):
|
|
127
|
+
violations.append({
|
|
128
|
+
"file": rel_path,
|
|
129
|
+
"line": v.line,
|
|
130
|
+
"category": "layer",
|
|
131
|
+
"description": v.description,
|
|
132
|
+
"severity": v.severity,
|
|
133
|
+
})
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
for v in _check_dependency_integrity(source, rel_path, repo_root, runtime):
|
|
139
|
+
violations.append({
|
|
140
|
+
"file": rel_path,
|
|
141
|
+
"line": v.line,
|
|
142
|
+
"category": "dependency",
|
|
143
|
+
"description": v.description,
|
|
144
|
+
"severity": v.severity,
|
|
145
|
+
})
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
if violations:
|
|
150
|
+
print(f"\ndeadpush — Pre-commit guardrails found {len(violations)} violation(s):\n")
|
|
151
|
+
for v in violations:
|
|
152
|
+
icon = {"critical": "🔴", "high": "⚠️", "medium": "⚡", "low": "ℹ️"}.get(v["severity"], "⚡")
|
|
153
|
+
print(f" {icon} {v['file']}:{v['line']} [{v['category']}] {v['description'][:100]}")
|
|
154
|
+
print("")
|
|
155
|
+
print("Commit blocked. Fix the violations above or use `git commit --no-verify` to skip.")
|
|
156
|
+
return False, violations
|
|
157
|
+
|
|
158
|
+
return True, []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def install_hook(repo_root: Path) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Install a cross-platform pre-push git hook.
|
|
164
|
+
|
|
165
|
+
The hook runs `deadpush scan --format summary` (via the current Python
|
|
166
|
+
to avoid PATH/venv issues) and blocks the push if blocking debris or
|
|
167
|
+
dead symbols are found.
|
|
168
|
+
|
|
169
|
+
This version uses a Python script instead of Bash so it works on:
|
|
170
|
+
- Windows (PowerShell, CMD, Git for Windows without Git Bash)
|
|
171
|
+
- macOS / Linux
|
|
172
|
+
- Any environment where Python can run the deadpush module.
|
|
173
|
+
|
|
174
|
+
Idempotent.
|
|
175
|
+
"""
|
|
176
|
+
hooks_dir = repo_root / ".git" / "hooks"
|
|
177
|
+
if not hooks_dir.exists():
|
|
178
|
+
# Not a git repo or hooks dir missing
|
|
179
|
+
raise RuntimeError(f"No .git/hooks directory in {repo_root}")
|
|
180
|
+
|
|
181
|
+
hook_path = hooks_dir / "pre-push"
|
|
182
|
+
|
|
183
|
+
# Capture the exact Python at install time. This is crucial for venvs on
|
|
184
|
+
# Windows (PowerShell/CMD) where the "deadpush" entrypoint may not be in
|
|
185
|
+
# PATH for the shell that Git uses to invoke hooks.
|
|
186
|
+
python_exe = sys.executable
|
|
187
|
+
|
|
188
|
+
# Cross-platform Python hook.
|
|
189
|
+
# We hardcode the python we were installed with + -m deadpush.cli.
|
|
190
|
+
script = f'''#!/usr/bin/env python3
|
|
191
|
+
"""
|
|
192
|
+
deadpush pre-push git hook (installed by deadpush protect).
|
|
193
|
+
|
|
194
|
+
Cross-platform (Windows PowerShell/CMD + Git for Windows, macOS, Linux).
|
|
195
|
+
Runs the scan via the Python that was used to run "deadpush protect".
|
|
196
|
+
"""
|
|
197
|
+
import subprocess
|
|
198
|
+
import sys
|
|
199
|
+
|
|
200
|
+
def main():
|
|
201
|
+
try:
|
|
202
|
+
# Hardcoded at install time for maximum reliability across shells
|
|
203
|
+
cmd = [r"{python_exe}", "-m", "deadpush.cli", "scan", "--format", "summary"]
|
|
204
|
+
result = subprocess.run(
|
|
205
|
+
cmd,
|
|
206
|
+
capture_output=True,
|
|
207
|
+
text=True,
|
|
208
|
+
check=False
|
|
209
|
+
)
|
|
210
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
211
|
+
print(output)
|
|
212
|
+
|
|
213
|
+
# Block if we see debris or dead symbols (unless the count is explicitly 0)
|
|
214
|
+
has_debris = "debris" in output and "0 debris" not in output
|
|
215
|
+
has_dead = "dead symbols" in output and "0 dead symbols" not in output
|
|
216
|
+
|
|
217
|
+
if has_debris or has_dead:
|
|
218
|
+
print("deadpush check found blocking issues.")
|
|
219
|
+
print("Run 'deadpush scan' for details. Use --force to override (not recommended).")
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
|
|
222
|
+
except FileNotFoundError:
|
|
223
|
+
print("deadpush not available (Python module could not be found).")
|
|
224
|
+
print("Skipping hook (install with pip install -e . in the deadpush source).")
|
|
225
|
+
sys.exit(0) # Do not block the push on hook setup problems
|
|
226
|
+
except Exception as e:
|
|
227
|
+
print(f"deadpush hook encountered an error: {{e}}")
|
|
228
|
+
sys.exit(0) # Never block the push because the hook itself is broken
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|
|
232
|
+
'''
|
|
233
|
+
|
|
234
|
+
hook_path.write_text(script, encoding="utf-8")
|
|
235
|
+
|
|
236
|
+
# Make it executable on Unix-like systems (harmless on Windows)
|
|
237
|
+
try:
|
|
238
|
+
hook_path.chmod(0o755)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
print(f"Installed cross-platform pre-push hook at {hook_path}")
|
|
243
|
+
print(" (Works in PowerShell, CMD, Git Bash, macOS, Linux, etc. — uses the exact Python from when you ran 'deadpush protect')")
|
|
244
|
+
|
|
245
|
+
# On Windows, also install a tiny .cmd shim.
|
|
246
|
+
# Git for Windows will often execute .cmd hooks preferentially when
|
|
247
|
+
# the user is in PowerShell or CMD, avoiding any shebang/execution issues.
|
|
248
|
+
if os.name == "nt":
|
|
249
|
+
cmd_shim = hooks_dir / "pre-push.cmd"
|
|
250
|
+
# Use the exact python that was used to install deadpush (works great with venvs)
|
|
251
|
+
shim_content = f'''@echo off
|
|
252
|
+
"{python_exe}" "{hook_path}" %*
|
|
253
|
+
'''
|
|
254
|
+
cmd_shim.write_text(shim_content, encoding="utf-8")
|
|
255
|
+
print(f" Also installed Windows shim at {cmd_shim}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def install_precommit_hook(repo_root: Path) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Install a pre-commit git hook that runs guardrails on staged files.
|
|
261
|
+
|
|
262
|
+
Blocks commits containing:
|
|
263
|
+
- Prompt injection / AI override attempts
|
|
264
|
+
- Hardcoded secrets (API keys, tokens, passwords)
|
|
265
|
+
- Security violations (eval, exec, subprocess)
|
|
266
|
+
- Architecture layer violations
|
|
267
|
+
|
|
268
|
+
Uses a Python script for cross-platform support.
|
|
269
|
+
"""
|
|
270
|
+
hooks_dir = repo_root / ".git" / "hooks"
|
|
271
|
+
if not hooks_dir.exists():
|
|
272
|
+
raise RuntimeError(f"No .git/hooks directory in {repo_root}")
|
|
273
|
+
|
|
274
|
+
hook_path = hooks_dir / "pre-commit"
|
|
275
|
+
python_exe = sys.executable
|
|
276
|
+
|
|
277
|
+
script = f'''#!/usr/bin/env python3
|
|
278
|
+
"""
|
|
279
|
+
deadpush pre-commit guardrails (installed by deadpush hook install-precommit).
|
|
280
|
+
|
|
281
|
+
Blocks commits with prompt injection, hardcoded secrets,
|
|
282
|
+
security violations, and architecture layer violations.
|
|
283
|
+
"""
|
|
284
|
+
import subprocess
|
|
285
|
+
import sys
|
|
286
|
+
|
|
287
|
+
def main():
|
|
288
|
+
try:
|
|
289
|
+
cmd = [r"{python_exe}", "-m", "deadpush.cli", "hooks", "run-precommit"]
|
|
290
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
291
|
+
if result.stdout:
|
|
292
|
+
print(result.stdout)
|
|
293
|
+
if result.stderr:
|
|
294
|
+
print(result.stderr, file=sys.stderr)
|
|
295
|
+
if result.returncode != 0:
|
|
296
|
+
print("deadpush guardrails blocked this commit.")
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
except FileNotFoundError:
|
|
299
|
+
print("deadpush not available (Python module could not be found).")
|
|
300
|
+
sys.exit(0)
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
main()
|
|
304
|
+
'''
|
|
305
|
+
|
|
306
|
+
hook_path.write_text(script, encoding="utf-8")
|
|
307
|
+
try:
|
|
308
|
+
hook_path.chmod(0o755)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
print(f"Installed pre-commit guardrail hook at {hook_path}")
|
|
313
|
+
|
|
314
|
+
if os.name == "nt":
|
|
315
|
+
cmd_shim = hooks_dir / "pre-commit.cmd"
|
|
316
|
+
shim_content = f'''@echo off
|
|
317
|
+
"{python_exe}" "{hook_path}" %*
|
|
318
|
+
'''
|
|
319
|
+
cmd_shim.write_text(shim_content, encoding="utf-8")
|
|
320
|
+
print(f" Also installed Windows shim at {cmd_shim}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def setup_mcp_discovery(repo_root: Path) -> None:
|
|
324
|
+
"""Create MCP config files so agents discover deadpush automatically.
|
|
325
|
+
|
|
326
|
+
Creates .cursor/mcp.json and .vscode/mcp.json so Cursor, VS Code,
|
|
327
|
+
and other MCP-compatible agents discover deadpush automatically.
|
|
328
|
+
"""
|
|
329
|
+
deadpush_cmd = str(Path(sys.executable).parent / "deadpush")
|
|
330
|
+
if not Path(deadpush_cmd).exists():
|
|
331
|
+
deadpush_cmd = sys.executable.replace("python3", "deadpush").replace("python", "deadpush")
|
|
332
|
+
if not Path(deadpush_cmd).exists():
|
|
333
|
+
deadpush_cmd = "deadpush"
|
|
334
|
+
|
|
335
|
+
mcp_config = {
|
|
336
|
+
"mcpServers": {
|
|
337
|
+
"deadpush": {
|
|
338
|
+
"command": deadpush_cmd,
|
|
339
|
+
"args": ["mcp"],
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
cursor_dir = repo_root / ".cursor"
|
|
345
|
+
cursor_dir.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
cursor_path = cursor_dir / "mcp.json"
|
|
347
|
+
existing = {}
|
|
348
|
+
if cursor_path.exists():
|
|
349
|
+
try:
|
|
350
|
+
existing = json.loads(cursor_path.read_text(encoding="utf-8"))
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
existing.update(mcp_config)
|
|
354
|
+
cursor_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
355
|
+
print(f" Created {cursor_path}")
|
|
356
|
+
|
|
357
|
+
vscode_dir = repo_root / ".vscode"
|
|
358
|
+
vscode_dir.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
vscode_path = vscode_dir / "mcp.json"
|
|
360
|
+
vscode_config = {"servers": {"deadpush": {"command": deadpush_cmd, "args": ["mcp"]}}}
|
|
361
|
+
existing_vs = {}
|
|
362
|
+
if vscode_path.exists():
|
|
363
|
+
try:
|
|
364
|
+
existing_vs = json.loads(vscode_path.read_text(encoding="utf-8"))
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
existing_vs.update(vscode_config)
|
|
368
|
+
vscode_path.write_text(json.dumps(existing_vs, indent=2), encoding="utf-8")
|
|
369
|
+
print(f" Created {vscode_path}")
|
deadpush/importgraph.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Cross-file import analysis for dead code detection.
|
|
2
|
+
|
|
3
|
+
Builds an import map across all Python source files and provides
|
|
4
|
+
efficient queries for import counts and string references.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImportAnalyzer:
|
|
17
|
+
"""Analyze cross-file imports and string references for dead code scoring."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, file_paths: list[Path], repo_root: Path):
|
|
20
|
+
self.repo_root = repo_root
|
|
21
|
+
self._imports: dict[str, list[str]] = defaultdict(
|
|
22
|
+
list
|
|
23
|
+
) # imported_name -> [file_paths]
|
|
24
|
+
self._string_refs: dict[str, dict[str, list[int]]] = defaultdict(
|
|
25
|
+
lambda: defaultdict(list)
|
|
26
|
+
) # name -> file_path -> [line_numbers]
|
|
27
|
+
self._exclude_files: set[str] = set()
|
|
28
|
+
self._build(file_paths)
|
|
29
|
+
|
|
30
|
+
def _rel(self, path: Path | str) -> str:
|
|
31
|
+
p = Path(path) if isinstance(path, str) else path
|
|
32
|
+
try:
|
|
33
|
+
return str(p.relative_to(self.repo_root))
|
|
34
|
+
except ValueError:
|
|
35
|
+
return str(p)
|
|
36
|
+
|
|
37
|
+
def _build(self, file_paths: list[Path]) -> None:
|
|
38
|
+
for fp in file_paths:
|
|
39
|
+
if fp.suffix != ".py":
|
|
40
|
+
continue
|
|
41
|
+
rel = self._rel(fp)
|
|
42
|
+
try:
|
|
43
|
+
text = fp.read_text(encoding="utf-8", errors="ignore")
|
|
44
|
+
except Exception:
|
|
45
|
+
continue
|
|
46
|
+
self._extract_imports(text, rel)
|
|
47
|
+
self._extract_string_refs(text, rel, fp)
|
|
48
|
+
|
|
49
|
+
def _extract_imports(self, text: str, rel: str) -> None:
|
|
50
|
+
try:
|
|
51
|
+
tree = ast.parse(text)
|
|
52
|
+
except SyntaxError:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
for node in ast.walk(tree):
|
|
56
|
+
if isinstance(node, ast.Import):
|
|
57
|
+
for alias in node.names:
|
|
58
|
+
name = alias.asname or alias.name
|
|
59
|
+
self._imports[name].append(rel)
|
|
60
|
+
elif isinstance(node, ast.ImportFrom):
|
|
61
|
+
for alias in node.names:
|
|
62
|
+
name = alias.asname or alias.name
|
|
63
|
+
self._imports[name].append(rel)
|
|
64
|
+
|
|
65
|
+
def _extract_string_refs(self, text: str, rel: str, fp: Path) -> None:
|
|
66
|
+
try:
|
|
67
|
+
tree = ast.parse(text)
|
|
68
|
+
except SyntaxError:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Attach parent references for docstring detection
|
|
72
|
+
for node in ast.walk(tree):
|
|
73
|
+
for child in ast.iter_child_nodes(node):
|
|
74
|
+
child.parent = node # type: ignore[attr-defined]
|
|
75
|
+
|
|
76
|
+
for node in ast.walk(tree):
|
|
77
|
+
if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
|
|
78
|
+
continue
|
|
79
|
+
val = node.value.strip()
|
|
80
|
+
if not val.isidentifier() or len(val) <= 1:
|
|
81
|
+
continue
|
|
82
|
+
# Exclude docstrings
|
|
83
|
+
if isinstance(node.parent, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
84
|
+
if isinstance(node.parent, ast.Module):
|
|
85
|
+
if node.parent.body and node.parent.body[0] is node:
|
|
86
|
+
continue
|
|
87
|
+
elif node.parent.body and node.parent.body[0] is node:
|
|
88
|
+
continue
|
|
89
|
+
# Exclude logging/print calls (strings as arguments to call keywords)
|
|
90
|
+
if isinstance(node.parent, ast.Call):
|
|
91
|
+
if isinstance(node.parent.func, ast.Attribute):
|
|
92
|
+
if node.parent.func.attr.lower() in ("info", "debug", "warning", "error", "critical", "log", "exception"):
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(node.parent.func, ast.Name) and node.parent.func.id.lower() == "print":
|
|
95
|
+
continue
|
|
96
|
+
# Exclude strings in attribute access (obj.name) — those are attribute lookups, not string refs
|
|
97
|
+
if isinstance(node.parent, ast.Attribute):
|
|
98
|
+
if node.parent.attr == node.value:
|
|
99
|
+
continue
|
|
100
|
+
self._string_refs[val][rel].append(node.lineno)
|
|
101
|
+
|
|
102
|
+
def count_external_imports(self, name: str, exclude_path: str) -> int:
|
|
103
|
+
"""Count files (excluding the definition file) that import this name."""
|
|
104
|
+
exclude_rel = self._rel(exclude_path)
|
|
105
|
+
files = self._imports.get(name, [])
|
|
106
|
+
return len([f for f in files if f != exclude_rel])
|
|
107
|
+
|
|
108
|
+
def count_string_references(self, name: str, exclude_path: str) -> int:
|
|
109
|
+
"""Count string literal occurrences of name outside its own file."""
|
|
110
|
+
exclude_rel = self._rel(exclude_path)
|
|
111
|
+
total = 0
|
|
112
|
+
for file_path, line_nums in self._string_refs.get(name, {}).items():
|
|
113
|
+
if file_path != exclude_rel:
|
|
114
|
+
total += len(line_nums)
|
|
115
|
+
return total
|
|
116
|
+
|
|
117
|
+
def get_importing_files(self, name: str) -> list[str]:
|
|
118
|
+
"""Get list of files (relative paths) that import the given name."""
|
|
119
|
+
return list(self._imports.get(name, []))
|
|
120
|
+
|
|
121
|
+
def get_all_imported_names(self) -> set[str]:
|
|
122
|
+
return set(self._imports.keys())
|