debtscanner 0.2.0__tar.gz
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.
- debtscanner-0.2.0/PKG-INFO +15 -0
- debtscanner-0.2.0/README.md +44 -0
- debtscanner-0.2.0/debtscanner/__init__.py +2 -0
- debtscanner-0.2.0/debtscanner/ai.py +109 -0
- debtscanner-0.2.0/debtscanner/cli.py +516 -0
- debtscanner-0.2.0/debtscanner/prompts.py +17 -0
- debtscanner-0.2.0/debtscanner/reporter.py +564 -0
- debtscanner-0.2.0/debtscanner/scanner.py +199 -0
- debtscanner-0.2.0/debtscanner.egg-info/PKG-INFO +15 -0
- debtscanner-0.2.0/debtscanner.egg-info/SOURCES.txt +14 -0
- debtscanner-0.2.0/debtscanner.egg-info/dependency_links.txt +1 -0
- debtscanner-0.2.0/debtscanner.egg-info/entry_points.txt +2 -0
- debtscanner-0.2.0/debtscanner.egg-info/requires.txt +9 -0
- debtscanner-0.2.0/debtscanner.egg-info/top_level.txt +1 -0
- debtscanner-0.2.0/setup.cfg +4 -0
- debtscanner-0.2.0/setup.py +25 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: debtscanner
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: AI-powered technical debt scanner for codebases
|
|
5
|
+
Requires-Dist: click
|
|
6
|
+
Requires-Dist: openai
|
|
7
|
+
Requires-Dist: tqdm
|
|
8
|
+
Requires-Dist: colorama
|
|
9
|
+
Requires-Dist: rich
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
Provides-Extra: watch
|
|
12
|
+
Requires-Dist: watchdog; extra == "watch"
|
|
13
|
+
Dynamic: provides-extra
|
|
14
|
+
Dynamic: requires-dist
|
|
15
|
+
Dynamic: summary
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# debtscanner
|
|
2
|
+
|
|
3
|
+
`debtscanner` is a Python CLI that scans codebases for technical debt using AI and produces prioritized results in terminal and HTML.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configure
|
|
12
|
+
|
|
13
|
+
Create a `.env` file in the project root:
|
|
14
|
+
|
|
15
|
+
```env
|
|
16
|
+
GITHUB_TOKEN=ghp_your_token_here
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or export `GITHUB_TOKEN` in your shell.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
debtscanner scan .
|
|
25
|
+
debtscanner scan src/ --output report.html
|
|
26
|
+
debtscanner report
|
|
27
|
+
debtscanner fix auth.py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
- `--ignore`: skip specific folders (repeatable or comma-separated)
|
|
33
|
+
- `--severity`: minimum level to include (`LOW`, `MEDIUM`, `HIGH`, `CRITICAL`)
|
|
34
|
+
|
|
35
|
+
## Supported languages
|
|
36
|
+
|
|
37
|
+
- `.py`, `.js`, `.ts`, `.jsx`, `.tsx`, `.java`, `.go`, `.rb`
|
|
38
|
+
|
|
39
|
+
## Behavior
|
|
40
|
+
|
|
41
|
+
- Skips: `node_modules`, `.git`, `__pycache__`, `dist`, `build`, `.env`
|
|
42
|
+
- Skips files larger than 100KB with warning
|
|
43
|
+
- Skips binary files automatically
|
|
44
|
+
- Retries once on API rate limits with a 2-second pause
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
from openai import OpenAI
|
|
9
|
+
|
|
10
|
+
from .prompts import FIX_PROMPT, SCAN_PROMPT
|
|
11
|
+
|
|
12
|
+
PROVIDERS: dict[str, dict[str, Any]] = {
|
|
13
|
+
"github": {
|
|
14
|
+
"label": "GitHub Models (free with GitHub token)",
|
|
15
|
+
"base_url": "https://models.inference.ai.azure.com",
|
|
16
|
+
"default_model": "gpt-4o-mini",
|
|
17
|
+
"env_key": "GITHUB_TOKEN",
|
|
18
|
+
"env_hint": "ghp_xxxxxxxxxxxxxxxxxxxx",
|
|
19
|
+
},
|
|
20
|
+
"openai": {
|
|
21
|
+
"label": "OpenAI (requires API key)",
|
|
22
|
+
"base_url": "https://api.openai.com/v1",
|
|
23
|
+
"default_model": "gpt-4o-mini",
|
|
24
|
+
"env_key": "OPENAI_API_KEY",
|
|
25
|
+
"env_hint": "sk-xxxxxxxxxxxxxxxxxxxx",
|
|
26
|
+
},
|
|
27
|
+
"anthropic": {
|
|
28
|
+
"label": "Anthropic Claude (via OpenAI-compat proxy)",
|
|
29
|
+
"base_url": "https://api.anthropic.com/v1",
|
|
30
|
+
"default_model": "claude-sonnet-4-20250514",
|
|
31
|
+
"env_key": "ANTHROPIC_API_KEY",
|
|
32
|
+
"env_hint": "sk-ant-xxxxxxxxxxxxxxxxxxxx",
|
|
33
|
+
},
|
|
34
|
+
"ollama": {
|
|
35
|
+
"label": "Ollama (local, no key needed)",
|
|
36
|
+
"base_url": "http://localhost:11434/v1",
|
|
37
|
+
"default_model": "llama3",
|
|
38
|
+
"env_key": "",
|
|
39
|
+
"env_hint": "",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_provider() -> tuple[str, str, str]:
|
|
45
|
+
load_dotenv()
|
|
46
|
+
|
|
47
|
+
provider_name = os.getenv("DEBTSCANNER_PROVIDER", "github").lower()
|
|
48
|
+
info = PROVIDERS.get(provider_name)
|
|
49
|
+
|
|
50
|
+
if info is None:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Unknown provider '{provider_name}'. "
|
|
53
|
+
f"Valid choices: {', '.join(PROVIDERS)}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
model = os.getenv("DEBTSCANNER_MODEL", info["default_model"])
|
|
57
|
+
base_url = os.getenv("DEBTSCANNER_BASE_URL", info["base_url"])
|
|
58
|
+
|
|
59
|
+
if provider_name == "ollama":
|
|
60
|
+
return base_url, "ollama", model
|
|
61
|
+
|
|
62
|
+
env_key = info["env_key"]
|
|
63
|
+
token = os.getenv(env_key, "")
|
|
64
|
+
if not token:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"Missing {env_key} for provider '{provider_name}'. "
|
|
67
|
+
f"Run `debtscanner init` or add it to your .env file."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return base_url, token, model
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AIClient:
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
base_url, api_key, model = _resolve_provider()
|
|
76
|
+
self.model = model
|
|
77
|
+
self.client = OpenAI(base_url=base_url, api_key=api_key)
|
|
78
|
+
|
|
79
|
+
def _create_completion(self, user_content: str) -> str:
|
|
80
|
+
for attempt in range(2):
|
|
81
|
+
try:
|
|
82
|
+
response = self.client.chat.completions.create(
|
|
83
|
+
model=self.model,
|
|
84
|
+
messages=[
|
|
85
|
+
{
|
|
86
|
+
"role": "user",
|
|
87
|
+
"content": user_content,
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
temperature=0.2,
|
|
91
|
+
)
|
|
92
|
+
return (response.choices[0].message.content or "").strip()
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
error_text = str(exc).lower()
|
|
95
|
+
should_retry = "429" in error_text or "rate limit" in error_text
|
|
96
|
+
if should_retry and attempt == 0:
|
|
97
|
+
time.sleep(2)
|
|
98
|
+
continue
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
def analyze_code(self, filename: str, content: str) -> str:
|
|
104
|
+
prompt = f"{SCAN_PROMPT}\n\nFILE UNDER REVIEW: {filename}\n\nCODE:\n{content}"
|
|
105
|
+
return self._create_completion(prompt)
|
|
106
|
+
|
|
107
|
+
def suggest_fixes(self, filename: str, content: str) -> str:
|
|
108
|
+
prompt = f"{FIX_PROMPT}\n\nFILE UNDER REVIEW: {filename}\n\nCODE:\n{content}"
|
|
109
|
+
return self._create_completion(prompt)
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from colorama import Fore, Style
|
|
12
|
+
from colorama import init as colorama_init
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from tqdm import tqdm
|
|
15
|
+
|
|
16
|
+
from .ai import AIClient, PROVIDERS
|
|
17
|
+
from .reporter import (
|
|
18
|
+
render_file_scores_table,
|
|
19
|
+
render_json,
|
|
20
|
+
render_markdown,
|
|
21
|
+
render_terminal_table,
|
|
22
|
+
summarize,
|
|
23
|
+
summary_line,
|
|
24
|
+
write_html_report,
|
|
25
|
+
)
|
|
26
|
+
from .scanner import (
|
|
27
|
+
MAX_FILE_SIZE_BYTES,
|
|
28
|
+
filter_min_severity,
|
|
29
|
+
parse_ai_scan_response,
|
|
30
|
+
scan_files,
|
|
31
|
+
sort_debt_items,
|
|
32
|
+
top_critical_files,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
colorama_init(autoreset=True)
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
SEVERITY_CHOICES = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
|
39
|
+
FORMAT_CHOICES = ["table", "json", "markdown"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_file_for_fix(file_path: str) -> str:
|
|
43
|
+
if os.path.getsize(file_path) > MAX_FILE_SIZE_BYTES:
|
|
44
|
+
raise click.ClickException("File too large (>100KB). Please scan a smaller file.")
|
|
45
|
+
|
|
46
|
+
with open(file_path, "rb") as binary_file:
|
|
47
|
+
chunk = binary_file.read(2048)
|
|
48
|
+
if b"\x00" in chunk:
|
|
49
|
+
raise click.ClickException("Binary files are not supported for fix suggestions.")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as source_file:
|
|
53
|
+
return source_file.read()
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise click.ClickException(f"Unable to read file: {exc}") from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_scan(
|
|
59
|
+
target: str,
|
|
60
|
+
ignore: Sequence[str],
|
|
61
|
+
min_severity: str,
|
|
62
|
+
output: str | None = None,
|
|
63
|
+
fmt: str = "table",
|
|
64
|
+
) -> int:
|
|
65
|
+
target_path = Path(target).resolve()
|
|
66
|
+
if not target_path.exists() or not target_path.is_dir():
|
|
67
|
+
raise click.ClickException(f"Directory not found: {target}")
|
|
68
|
+
|
|
69
|
+
files, warnings = scan_files(str(target_path), ignore_dirs=ignore)
|
|
70
|
+
|
|
71
|
+
for warning in warnings:
|
|
72
|
+
click.echo(f"{Fore.YELLOW}Warning:{Style.RESET_ALL} {warning}")
|
|
73
|
+
|
|
74
|
+
if not files:
|
|
75
|
+
raise click.ClickException(
|
|
76
|
+
"No supported source files were found. Try a different path or adjust --ignore."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
ai_client = AIClient()
|
|
81
|
+
except RuntimeError as exc:
|
|
82
|
+
raise click.ClickException(str(exc)) from exc
|
|
83
|
+
|
|
84
|
+
all_items = []
|
|
85
|
+
for source_file in tqdm(files, desc="Scanning files", unit="file"):
|
|
86
|
+
relative_name = os.path.relpath(source_file.path, str(target_path))
|
|
87
|
+
response = ai_client.analyze_code(relative_name, source_file.content)
|
|
88
|
+
all_items.extend(parse_ai_scan_response(response, relative_name))
|
|
89
|
+
|
|
90
|
+
filtered = sort_debt_items(filter_min_severity(all_items, min_severity))
|
|
91
|
+
|
|
92
|
+
if fmt == "json":
|
|
93
|
+
click.echo(render_json(filtered, total_files=len(files)))
|
|
94
|
+
elif fmt == "markdown":
|
|
95
|
+
click.echo(render_markdown(filtered, total_files=len(files)))
|
|
96
|
+
else:
|
|
97
|
+
top5 = top_critical_files(filtered)
|
|
98
|
+
if top5:
|
|
99
|
+
render_file_scores_table(top5)
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
if filtered:
|
|
103
|
+
render_terminal_table(filtered)
|
|
104
|
+
else:
|
|
105
|
+
console.print(
|
|
106
|
+
"[yellow]No debt items found for the selected severity threshold.[/yellow]"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
console.print(summary_line(len(files), filtered))
|
|
110
|
+
console.print(summarize(filtered))
|
|
111
|
+
|
|
112
|
+
if output:
|
|
113
|
+
report_path = write_html_report(filtered, output, total_files=len(files))
|
|
114
|
+
click.echo(f"Saved HTML report to {report_path}")
|
|
115
|
+
|
|
116
|
+
return len(filtered)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@click.group()
|
|
120
|
+
def main() -> None:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@main.command()
|
|
125
|
+
def init() -> None:
|
|
126
|
+
click.echo(f"\n{Fore.CYAN}Welcome to debtscanner setup!{Style.RESET_ALL}\n")
|
|
127
|
+
|
|
128
|
+
click.echo("Choose an AI provider:\n")
|
|
129
|
+
provider_keys = list(PROVIDERS.keys())
|
|
130
|
+
for idx, key in enumerate(provider_keys, 1):
|
|
131
|
+
info = PROVIDERS[key]
|
|
132
|
+
click.echo(f" {Fore.GREEN}{idx}{Style.RESET_ALL}) {info['label']}")
|
|
133
|
+
|
|
134
|
+
click.echo()
|
|
135
|
+
choice = click.prompt(
|
|
136
|
+
"Enter provider number",
|
|
137
|
+
type=click.IntRange(1, len(provider_keys)),
|
|
138
|
+
default=1,
|
|
139
|
+
)
|
|
140
|
+
provider_name = provider_keys[choice - 1]
|
|
141
|
+
provider_info = PROVIDERS[provider_name]
|
|
142
|
+
|
|
143
|
+
env_key = provider_info["env_key"]
|
|
144
|
+
token = ""
|
|
145
|
+
if env_key:
|
|
146
|
+
from dotenv import dotenv_values
|
|
147
|
+
|
|
148
|
+
env_path = Path.cwd() / ".env"
|
|
149
|
+
env_example_path = Path.cwd() / ".env.example"
|
|
150
|
+
|
|
151
|
+
existing_env: dict[str, str | None] = {}
|
|
152
|
+
if env_path.exists():
|
|
153
|
+
existing_env = dotenv_values(env_path)
|
|
154
|
+
elif env_example_path.exists():
|
|
155
|
+
existing_env = dotenv_values(env_example_path)
|
|
156
|
+
|
|
157
|
+
existing_token = (
|
|
158
|
+
os.getenv(env_key, "")
|
|
159
|
+
or (existing_env.get(env_key) or "")
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
hint = provider_info["env_hint"]
|
|
163
|
+
is_placeholder = (
|
|
164
|
+
not existing_token
|
|
165
|
+
or existing_token == hint
|
|
166
|
+
or existing_token.startswith("ghp_your_")
|
|
167
|
+
or "xxxx" in existing_token
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if existing_token and not is_placeholder:
|
|
171
|
+
masked = existing_token[:6] + "..." + existing_token[-4:]
|
|
172
|
+
click.echo(
|
|
173
|
+
f"\n{Fore.GREEN}Found existing {env_key}{Style.RESET_ALL} ({masked})"
|
|
174
|
+
)
|
|
175
|
+
keep = click.confirm("Keep this token?", default=True)
|
|
176
|
+
if keep:
|
|
177
|
+
token = existing_token
|
|
178
|
+
else:
|
|
179
|
+
click.echo(f"\nPaste your new {env_key} (input is visible):")
|
|
180
|
+
token = click.prompt(
|
|
181
|
+
f"{env_key}",
|
|
182
|
+
default="",
|
|
183
|
+
show_default=False,
|
|
184
|
+
).strip()
|
|
185
|
+
if not token:
|
|
186
|
+
click.echo(
|
|
187
|
+
f"{Fore.YELLOW}No token provided - keeping the existing one.{Style.RESET_ALL}"
|
|
188
|
+
)
|
|
189
|
+
token = existing_token
|
|
190
|
+
else:
|
|
191
|
+
click.echo(
|
|
192
|
+
f"\n{provider_info['label']} requires the environment variable "
|
|
193
|
+
f"{Fore.YELLOW}{env_key}{Style.RESET_ALL}."
|
|
194
|
+
)
|
|
195
|
+
click.echo("Paste your token below (input is visible):")
|
|
196
|
+
token = click.prompt(
|
|
197
|
+
f"{env_key}",
|
|
198
|
+
default="",
|
|
199
|
+
show_default=False,
|
|
200
|
+
).strip()
|
|
201
|
+
if not token:
|
|
202
|
+
click.echo(
|
|
203
|
+
f"{Fore.YELLOW}No token provided - you can add it later "
|
|
204
|
+
f"by editing .env{Style.RESET_ALL}"
|
|
205
|
+
)
|
|
206
|
+
token = hint
|
|
207
|
+
else:
|
|
208
|
+
click.echo(f"\n{provider_info['label']} - no API key needed.")
|
|
209
|
+
|
|
210
|
+
default_model = provider_info["default_model"]
|
|
211
|
+
click.echo(f"\nDefault model: {Fore.CYAN}{default_model}{Style.RESET_ALL}")
|
|
212
|
+
change_model = click.confirm("Use a different model?", default=False)
|
|
213
|
+
if change_model:
|
|
214
|
+
model = click.prompt("Enter model name").strip()
|
|
215
|
+
if not model:
|
|
216
|
+
model = default_model
|
|
217
|
+
else:
|
|
218
|
+
model = default_model
|
|
219
|
+
|
|
220
|
+
env_lines = [
|
|
221
|
+
f"# debtscanner configuration (generated by `debtscanner init`)",
|
|
222
|
+
f"DEBTSCANNER_PROVIDER={provider_name}",
|
|
223
|
+
]
|
|
224
|
+
if env_key and token:
|
|
225
|
+
env_lines.append(f"{env_key}={token}")
|
|
226
|
+
if model != default_model:
|
|
227
|
+
env_lines.append(f"DEBTSCANNER_MODEL={model}")
|
|
228
|
+
|
|
229
|
+
env_path = Path.cwd() / ".env"
|
|
230
|
+
if env_path.exists():
|
|
231
|
+
overwrite = click.confirm(
|
|
232
|
+
f"\n{Fore.YELLOW}.env already exists. Overwrite?{Style.RESET_ALL}",
|
|
233
|
+
default=False,
|
|
234
|
+
)
|
|
235
|
+
if not overwrite:
|
|
236
|
+
click.echo("Aborted - .env was not modified.")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
env_path.write_text("\n".join(env_lines) + "\n", encoding="utf-8")
|
|
240
|
+
click.echo(f"\n{Fore.GREEN}Created .env{Style.RESET_ALL} with provider={provider_name}")
|
|
241
|
+
click.echo("Run `debtscanner scan .` to start scanning!\n")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@main.command()
|
|
245
|
+
@click.argument("target", default=".")
|
|
246
|
+
@click.option("--output", type=click.Path(dir_okay=False), default=None, help="Write HTML report")
|
|
247
|
+
@click.option(
|
|
248
|
+
"--ignore",
|
|
249
|
+
multiple=True,
|
|
250
|
+
help="Folder(s) to ignore. Can be repeated or comma-separated.",
|
|
251
|
+
)
|
|
252
|
+
@click.option(
|
|
253
|
+
"--severity",
|
|
254
|
+
"min_severity",
|
|
255
|
+
type=click.Choice(SEVERITY_CHOICES, case_sensitive=False),
|
|
256
|
+
default="LOW",
|
|
257
|
+
show_default=True,
|
|
258
|
+
help="Minimum severity level to include.",
|
|
259
|
+
)
|
|
260
|
+
@click.option(
|
|
261
|
+
"--format",
|
|
262
|
+
"fmt",
|
|
263
|
+
type=click.Choice(FORMAT_CHOICES, case_sensitive=False),
|
|
264
|
+
default="table",
|
|
265
|
+
show_default=True,
|
|
266
|
+
help="Output format: table, json, or markdown.",
|
|
267
|
+
)
|
|
268
|
+
@click.option(
|
|
269
|
+
"--watch",
|
|
270
|
+
is_flag=True,
|
|
271
|
+
default=False,
|
|
272
|
+
help="Re-scan automatically when source files change.",
|
|
273
|
+
)
|
|
274
|
+
def scan(
|
|
275
|
+
target: str,
|
|
276
|
+
output: str | None,
|
|
277
|
+
ignore: Sequence[str],
|
|
278
|
+
min_severity: str,
|
|
279
|
+
fmt: str,
|
|
280
|
+
watch: bool,
|
|
281
|
+
) -> None:
|
|
282
|
+
if watch:
|
|
283
|
+
_watch_loop(target=target, ignore=ignore, min_severity=min_severity, output=output, fmt=fmt)
|
|
284
|
+
else:
|
|
285
|
+
_run_scan(
|
|
286
|
+
target=target,
|
|
287
|
+
ignore=ignore,
|
|
288
|
+
min_severity=min_severity.upper(),
|
|
289
|
+
output=output,
|
|
290
|
+
fmt=fmt,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@main.command()
|
|
295
|
+
@click.argument("target", default=".")
|
|
296
|
+
@click.option(
|
|
297
|
+
"--output",
|
|
298
|
+
type=click.Path(dir_okay=False),
|
|
299
|
+
default="report.html",
|
|
300
|
+
show_default=True,
|
|
301
|
+
help="Output path for the HTML report.",
|
|
302
|
+
)
|
|
303
|
+
@click.option(
|
|
304
|
+
"--ignore",
|
|
305
|
+
multiple=True,
|
|
306
|
+
help="Folder(s) to ignore. Can be repeated or comma-separated.",
|
|
307
|
+
)
|
|
308
|
+
@click.option(
|
|
309
|
+
"--severity",
|
|
310
|
+
"min_severity",
|
|
311
|
+
type=click.Choice(SEVERITY_CHOICES, case_sensitive=False),
|
|
312
|
+
default="LOW",
|
|
313
|
+
show_default=True,
|
|
314
|
+
help="Minimum severity level to include.",
|
|
315
|
+
)
|
|
316
|
+
@click.option(
|
|
317
|
+
"--format",
|
|
318
|
+
"fmt",
|
|
319
|
+
type=click.Choice(FORMAT_CHOICES, case_sensitive=False),
|
|
320
|
+
default="table",
|
|
321
|
+
show_default=True,
|
|
322
|
+
help="Output format: table, json, or markdown.",
|
|
323
|
+
)
|
|
324
|
+
@click.option(
|
|
325
|
+
"--watch",
|
|
326
|
+
is_flag=True,
|
|
327
|
+
default=False,
|
|
328
|
+
help="Re-scan automatically when source files change.",
|
|
329
|
+
)
|
|
330
|
+
def report(
|
|
331
|
+
target: str,
|
|
332
|
+
output: str,
|
|
333
|
+
ignore: Sequence[str],
|
|
334
|
+
min_severity: str,
|
|
335
|
+
fmt: str,
|
|
336
|
+
watch: bool,
|
|
337
|
+
) -> None:
|
|
338
|
+
if watch:
|
|
339
|
+
_watch_loop(target=target, ignore=ignore, min_severity=min_severity, output=output, fmt=fmt)
|
|
340
|
+
else:
|
|
341
|
+
_run_scan(
|
|
342
|
+
target=target,
|
|
343
|
+
ignore=ignore,
|
|
344
|
+
min_severity=min_severity.upper(),
|
|
345
|
+
output=output,
|
|
346
|
+
fmt=fmt,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@main.command()
|
|
351
|
+
@click.argument("filepath", type=click.Path(exists=True, dir_okay=False))
|
|
352
|
+
def fix(filepath: str) -> None:
|
|
353
|
+
try:
|
|
354
|
+
code = _read_file_for_fix(filepath)
|
|
355
|
+
except click.ClickException:
|
|
356
|
+
raise
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
raise click.ClickException(f"Unable to access file: {exc}") from exc
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
ai_client = AIClient()
|
|
362
|
+
except RuntimeError as exc:
|
|
363
|
+
raise click.ClickException(str(exc)) from exc
|
|
364
|
+
|
|
365
|
+
filename = os.path.basename(filepath)
|
|
366
|
+
response = ai_client.suggest_fixes(filename, code)
|
|
367
|
+
|
|
368
|
+
if not response.strip():
|
|
369
|
+
console.print("[yellow]AI returned no fix suggestions.[/yellow]")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
console.rule(f"Fix Suggestions: {filename}")
|
|
373
|
+
console.print(response)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _watch_loop(
|
|
377
|
+
*,
|
|
378
|
+
target: str,
|
|
379
|
+
ignore: Sequence[str],
|
|
380
|
+
min_severity: str,
|
|
381
|
+
output: str | None,
|
|
382
|
+
fmt: str,
|
|
383
|
+
) -> None:
|
|
384
|
+
target_path = Path(target).resolve()
|
|
385
|
+
if not target_path.exists() or not target_path.is_dir():
|
|
386
|
+
raise click.ClickException(f"Directory not found: {target}")
|
|
387
|
+
|
|
388
|
+
click.echo(
|
|
389
|
+
f"{Fore.CYAN}Watching {target_path} for changes "
|
|
390
|
+
f"(Ctrl+C to stop)...{Style.RESET_ALL}\n"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
_run_scan(
|
|
394
|
+
target=target,
|
|
395
|
+
ignore=ignore,
|
|
396
|
+
min_severity=min_severity.upper(),
|
|
397
|
+
output=output,
|
|
398
|
+
fmt=fmt,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
_watch_with_watchdog(target_path, ignore, min_severity, output, fmt)
|
|
403
|
+
except ImportError:
|
|
404
|
+
_watch_with_polling(target_path, ignore, min_severity, output, fmt)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _watch_with_watchdog(
|
|
408
|
+
target_path: Path,
|
|
409
|
+
ignore: Sequence[str],
|
|
410
|
+
min_severity: str,
|
|
411
|
+
output: str | None,
|
|
412
|
+
fmt: str,
|
|
413
|
+
) -> None:
|
|
414
|
+
from watchdog.observers import Observer
|
|
415
|
+
from watchdog.events import FileSystemEventHandler
|
|
416
|
+
import threading
|
|
417
|
+
|
|
418
|
+
from .scanner import SUPPORTED_EXTENSIONS
|
|
419
|
+
|
|
420
|
+
debounce_timer: threading.Timer | None = None
|
|
421
|
+
lock = threading.Lock()
|
|
422
|
+
|
|
423
|
+
def _do_rescan() -> None:
|
|
424
|
+
click.echo(f"\n{Fore.CYAN}Change detected - rescanning...{Style.RESET_ALL}\n")
|
|
425
|
+
try:
|
|
426
|
+
_run_scan(
|
|
427
|
+
target=str(target_path),
|
|
428
|
+
ignore=ignore,
|
|
429
|
+
min_severity=min_severity.upper(),
|
|
430
|
+
output=output,
|
|
431
|
+
fmt=fmt,
|
|
432
|
+
)
|
|
433
|
+
except click.ClickException as exc:
|
|
434
|
+
click.echo(f"{Fore.RED}Error: {exc.message}{Style.RESET_ALL}")
|
|
435
|
+
|
|
436
|
+
class _Handler(FileSystemEventHandler):
|
|
437
|
+
def on_any_event(self, event) -> None:
|
|
438
|
+
if event.is_directory:
|
|
439
|
+
return
|
|
440
|
+
src = getattr(event, "src_path", "")
|
|
441
|
+
ext = os.path.splitext(src)[1].lower()
|
|
442
|
+
if ext not in SUPPORTED_EXTENSIONS:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
nonlocal debounce_timer
|
|
446
|
+
with lock:
|
|
447
|
+
if debounce_timer is not None:
|
|
448
|
+
debounce_timer.cancel()
|
|
449
|
+
debounce_timer = threading.Timer(1.5, _do_rescan)
|
|
450
|
+
debounce_timer.daemon = True
|
|
451
|
+
debounce_timer.start()
|
|
452
|
+
|
|
453
|
+
observer = Observer()
|
|
454
|
+
observer.schedule(_Handler(), str(target_path), recursive=True)
|
|
455
|
+
observer.start()
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
while True:
|
|
459
|
+
time.sleep(1)
|
|
460
|
+
except KeyboardInterrupt:
|
|
461
|
+
click.echo(f"\n{Fore.YELLOW}Stopped watching.{Style.RESET_ALL}")
|
|
462
|
+
finally:
|
|
463
|
+
observer.stop()
|
|
464
|
+
observer.join()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _watch_with_polling(
|
|
468
|
+
target_path: Path,
|
|
469
|
+
ignore: Sequence[str],
|
|
470
|
+
min_severity: str,
|
|
471
|
+
output: str | None,
|
|
472
|
+
fmt: str,
|
|
473
|
+
) -> None:
|
|
474
|
+
from .scanner import iter_source_files
|
|
475
|
+
|
|
476
|
+
click.echo(
|
|
477
|
+
f"{Fore.YELLOW}(watchdog not installed - using polling, "
|
|
478
|
+
f"install watchdog for better performance){Style.RESET_ALL}\n"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def _snapshot() -> dict[str, float]:
|
|
482
|
+
result: dict[str, float] = {}
|
|
483
|
+
for fp in iter_source_files(str(target_path), ignore_dirs=ignore):
|
|
484
|
+
try:
|
|
485
|
+
result[fp] = os.path.getmtime(fp)
|
|
486
|
+
except OSError:
|
|
487
|
+
pass
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
prev = _snapshot()
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
while True:
|
|
494
|
+
time.sleep(3)
|
|
495
|
+
current = _snapshot()
|
|
496
|
+
if current != prev:
|
|
497
|
+
prev = current
|
|
498
|
+
click.echo(
|
|
499
|
+
f"\n{Fore.CYAN}Change detected - rescanning...{Style.RESET_ALL}\n"
|
|
500
|
+
)
|
|
501
|
+
try:
|
|
502
|
+
_run_scan(
|
|
503
|
+
target=str(target_path),
|
|
504
|
+
ignore=ignore,
|
|
505
|
+
min_severity=min_severity.upper(),
|
|
506
|
+
output=output,
|
|
507
|
+
fmt=fmt,
|
|
508
|
+
)
|
|
509
|
+
except click.ClickException as exc:
|
|
510
|
+
click.echo(f"{Fore.RED}Error: {exc.message}{Style.RESET_ALL}")
|
|
511
|
+
except KeyboardInterrupt:
|
|
512
|
+
click.echo(f"\n{Fore.YELLOW}Stopped watching.{Style.RESET_ALL}")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
SCAN_PROMPT = """You are a senior code reviewer. Analyze this code for technical
|
|
2
|
+
debt. For each issue found, output EXACTLY in this format:
|
|
3
|
+
SEVERITY: [CRITICAL/HIGH/MEDIUM/LOW]
|
|
4
|
+
FILE: [filename]
|
|
5
|
+
LINE: [line number]
|
|
6
|
+
ISSUE: [one line description]
|
|
7
|
+
FIX: [one line suggested fix]
|
|
8
|
+
---
|
|
9
|
+
Be specific. Only report real issues. Maximum 10 issues per file."""
|
|
10
|
+
|
|
11
|
+
FIX_PROMPT = """You are a senior developer. Given this code, show me exactly
|
|
12
|
+
how to fix the most critical technical debt issues.
|
|
13
|
+
For each fix show:
|
|
14
|
+
BEFORE: [the problematic code]
|
|
15
|
+
AFTER: [the fixed code]
|
|
16
|
+
WHY: [one line explanation]
|
|
17
|
+
Maximum 3 fixes. Be specific and actionable."""
|