debtpilot 0.2.0__tar.gz → 0.3.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.
- {debtpilot-0.2.0 → debtpilot-0.3.0}/PKG-INFO +16 -4
- {debtpilot-0.2.0 → debtpilot-0.3.0}/README.md +12 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/PKG-INFO +16 -4
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/SOURCES.txt +5 -2
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/cicd.py +88 -22
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/cli.py +72 -32
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/daemon.py +12 -9
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/database.py +72 -12
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/enterprise_cli.py +10 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/hooks.py +33 -10
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/predictor.py +51 -22
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/scorer.py +71 -24
- {debtpilot-0.2.0 → debtpilot-0.3.0}/pyproject.toml +3 -3
- debtpilot-0.3.0/tests/test_daemon.py +56 -0
- debtpilot-0.3.0/tests/test_hooks.py +96 -0
- debtpilot-0.3.0/tests/test_project_isolation.py +108 -0
- debtpilot-0.3.0/tests/test_scorer.py +319 -0
- debtpilot-0.2.0/debtpilot_core/server.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/dependency_links.txt +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/entry_points.txt +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/requires.txt +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/top_level.txt +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/__init__.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/ai_advisor.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/autostart.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/compliance.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/dashboard.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/digest.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/linter.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/notifier.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/reporter.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/scanner.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/sync.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/team.py +0 -0
- {debtpilot-0.2.0 → debtpilot-0.3.0}/setup.cfg +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: debtpilot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Track technical debt before it blows up in production
|
|
5
5
|
License: MIT
|
|
6
|
-
Project-URL: Homepage, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
6
|
+
Project-URL: Homepage, https://github.com/Bitxn/debtpilot
|
|
7
|
+
Project-URL: Issues, https://github.com/Bitxn/debtpilot/issues
|
|
8
8
|
Keywords: technical-debt,code-quality,developer-tools,cli,linting
|
|
9
9
|
Classifier: Development Status :: 4 - Beta
|
|
10
10
|
Classifier: Environment :: Console
|
|
@@ -100,6 +100,18 @@ lint issues (20%) + stale TODOs (15%) + staleness (10%).
|
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
+
## Where your data lives
|
|
104
|
+
|
|
105
|
+
Scan results are stored **per project**, in a `.debtpilot/` folder at your
|
|
106
|
+
project root (found the same way git finds its root). Two different projects
|
|
107
|
+
scanned on the same machine never share data. The folder self-ignores via its
|
|
108
|
+
own `.gitignore`, so you'll never accidentally commit your scan database.
|
|
109
|
+
|
|
110
|
+
Your global settings (Gemini API key, email digest config) live separately in
|
|
111
|
+
`~/.debtpilot/config.json` — those are per-user, not per-project.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
103
115
|
## AI Fix Suggestions (optional)
|
|
104
116
|
|
|
105
117
|
Get a free Gemini API key at aistudio.google.com then:
|
|
@@ -70,6 +70,18 @@ lint issues (20%) + stale TODOs (15%) + staleness (10%).
|
|
|
70
70
|
|
|
71
71
|
---
|
|
72
72
|
|
|
73
|
+
## Where your data lives
|
|
74
|
+
|
|
75
|
+
Scan results are stored **per project**, in a `.debtpilot/` folder at your
|
|
76
|
+
project root (found the same way git finds its root). Two different projects
|
|
77
|
+
scanned on the same machine never share data. The folder self-ignores via its
|
|
78
|
+
own `.gitignore`, so you'll never accidentally commit your scan database.
|
|
79
|
+
|
|
80
|
+
Your global settings (Gemini API key, email digest config) live separately in
|
|
81
|
+
`~/.debtpilot/config.json` — those are per-user, not per-project.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
73
85
|
## AI Fix Suggestions (optional)
|
|
74
86
|
|
|
75
87
|
Get a free Gemini API key at aistudio.google.com then:
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: debtpilot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Track technical debt before it blows up in production
|
|
5
5
|
License: MIT
|
|
6
|
-
Project-URL: Homepage, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
6
|
+
Project-URL: Homepage, https://github.com/Bitxn/debtpilot
|
|
7
|
+
Project-URL: Issues, https://github.com/Bitxn/debtpilot/issues
|
|
8
8
|
Keywords: technical-debt,code-quality,developer-tools,cli,linting
|
|
9
9
|
Classifier: Development Status :: 4 - Beta
|
|
10
10
|
Classifier: Environment :: Console
|
|
@@ -100,6 +100,18 @@ lint issues (20%) + stale TODOs (15%) + staleness (10%).
|
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
+
## Where your data lives
|
|
104
|
+
|
|
105
|
+
Scan results are stored **per project**, in a `.debtpilot/` folder at your
|
|
106
|
+
project root (found the same way git finds its root). Two different projects
|
|
107
|
+
scanned on the same machine never share data. The folder self-ignores via its
|
|
108
|
+
own `.gitignore`, so you'll never accidentally commit your scan database.
|
|
109
|
+
|
|
110
|
+
Your global settings (Gemini API key, email digest config) live separately in
|
|
111
|
+
`~/.debtpilot/config.json` — those are per-user, not per-project.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
103
115
|
## AI Fix Suggestions (optional)
|
|
104
116
|
|
|
105
117
|
Get a free Gemini API key at aistudio.google.com then:
|
|
@@ -24,6 +24,9 @@ debtpilot_core/predictor.py
|
|
|
24
24
|
debtpilot_core/reporter.py
|
|
25
25
|
debtpilot_core/scanner.py
|
|
26
26
|
debtpilot_core/scorer.py
|
|
27
|
-
debtpilot_core/server.py
|
|
28
27
|
debtpilot_core/sync.py
|
|
29
|
-
debtpilot_core/team.py
|
|
28
|
+
debtpilot_core/team.py
|
|
29
|
+
tests/test_daemon.py
|
|
30
|
+
tests/test_hooks.py
|
|
31
|
+
tests/test_project_isolation.py
|
|
32
|
+
tests/test_scorer.py
|
|
@@ -61,7 +61,7 @@ jobs:
|
|
|
61
61
|
const rows = scores.slice(0, 15).map(f => {
|
|
62
62
|
const name = f.filepath.split('/').pop();
|
|
63
63
|
const emoji = gradeEmoji[f.grade] || '•';
|
|
64
|
-
return `| ${emoji} ${f.grade} |
|
|
64
|
+
return `| ${emoji} ${f.grade} | \\`${name}\\` | ${f.score.toFixed(1)} | ${f.label} |`;
|
|
65
65
|
}).join('\\n');
|
|
66
66
|
|
|
67
67
|
const avg = scores.reduce((a, b) => a + b.score, 0) / (scores.length || 1);
|
|
@@ -158,6 +158,42 @@ def generate_gitlab_ci(output_dir: str = ".") -> str:
|
|
|
158
158
|
output_path.write_text(GITLAB_CI_YAML)
|
|
159
159
|
return str(output_path)
|
|
160
160
|
|
|
161
|
+
def _get_changed_files(project_dir: str, branch: Optional[str] = None) -> Optional[List[str]]:
|
|
162
|
+
"""
|
|
163
|
+
Returns a list of absolute file paths changed vs the base branch,
|
|
164
|
+
or None if we can't determine a diff (falls back to full scan).
|
|
165
|
+
"""
|
|
166
|
+
import subprocess
|
|
167
|
+
|
|
168
|
+
base_candidates = [branch, "origin/main", "origin/master", "main", "master"]
|
|
169
|
+
base_candidates = [b for b in base_candidates if b]
|
|
170
|
+
|
|
171
|
+
for base in base_candidates:
|
|
172
|
+
try:
|
|
173
|
+
result = subprocess.run(
|
|
174
|
+
["git", "diff", "--name-only", f"{base}...HEAD"],
|
|
175
|
+
capture_output=True, text=True, timeout=15, cwd=project_dir,
|
|
176
|
+
)
|
|
177
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
178
|
+
rel_files = result.stdout.strip().splitlines()
|
|
179
|
+
abs_files = [str(Path(project_dir) / f) for f in rel_files]
|
|
180
|
+
return abs_files
|
|
181
|
+
except Exception:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Last resort — diff against the immediately previous commit
|
|
185
|
+
try:
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
["git", "diff", "--name-only", "HEAD~1", "HEAD"],
|
|
188
|
+
capture_output=True, text=True, timeout=15, cwd=project_dir,
|
|
189
|
+
)
|
|
190
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
191
|
+
rel_files = result.stdout.strip().splitlines()
|
|
192
|
+
return [str(Path(project_dir) / f) for f in rel_files]
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
return None
|
|
161
197
|
|
|
162
198
|
def run_ci_scan(
|
|
163
199
|
project_dir: str,
|
|
@@ -174,6 +210,8 @@ def run_ci_scan(
|
|
|
174
210
|
"""
|
|
175
211
|
from debtpilot_core import linter, scanner, scorer, database as db
|
|
176
212
|
|
|
213
|
+
# CI runners invoke this against an explicit project dir; pin the DB there.
|
|
214
|
+
db.set_project_root(project_dir)
|
|
177
215
|
db.ensure_db()
|
|
178
216
|
|
|
179
217
|
exts = {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs"}
|
|
@@ -184,16 +222,34 @@ def run_ci_scan(
|
|
|
184
222
|
|
|
185
223
|
import re
|
|
186
224
|
backup_pattern = re.compile(r'_\d{14}\.py$')
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
225
|
+
|
|
226
|
+
changed_files = _get_changed_files(project_dir, branch)
|
|
227
|
+
|
|
228
|
+
if changed_files is not None:
|
|
229
|
+
# Incremental mode — only scan what changed in this PR/commit
|
|
230
|
+
files = [
|
|
231
|
+
f for f in changed_files
|
|
232
|
+
if Path(f).suffix in exts
|
|
233
|
+
and not any(p in ignored for p in Path(f).parts)
|
|
234
|
+
and not backup_pattern.search(Path(f).name)
|
|
235
|
+
and Path(f).exists()
|
|
236
|
+
]
|
|
237
|
+
print(f"⚡ Incremental scan — {len(files)} changed file(s) detected\n")
|
|
238
|
+
else:
|
|
239
|
+
# Fallback — full repo scan (first run, or git diff unavailable)
|
|
240
|
+
files = []
|
|
241
|
+
for f in Path(project_dir).rglob("*"):
|
|
242
|
+
if f.suffix in exts and not any(p in ignored for p in f.parts):
|
|
243
|
+
if not backup_pattern.search(f.name):
|
|
244
|
+
files.append(str(f))
|
|
245
|
+
print(f"⚡ Full scan — no changed-files baseline found, scanning all {len(files)} file(s)\n")
|
|
246
|
+
|
|
247
|
+
import concurrent.futures
|
|
192
248
|
|
|
193
249
|
results = []
|
|
194
|
-
|
|
250
|
+
max_workers = min(16, (len(files) // 5) + 4)
|
|
195
251
|
|
|
196
|
-
|
|
252
|
+
def _scan_one(fp):
|
|
197
253
|
lint_data = linter.lint_file(fp)
|
|
198
254
|
all_issues = []
|
|
199
255
|
for issues in lint_data.values():
|
|
@@ -208,21 +264,31 @@ def run_ci_scan(
|
|
|
208
264
|
todos=scan_data["todos"],
|
|
209
265
|
staleness_days=scan_data["staleness_days"],
|
|
210
266
|
)
|
|
211
|
-
|
|
267
|
+
return {
|
|
212
268
|
"filepath": fp,
|
|
213
|
-
"score": score_data
|
|
214
|
-
"grade": score_data
|
|
215
|
-
"label": score_data
|
|
216
|
-
"churn_score": score_data.
|
|
217
|
-
"complexity_score": score_data.
|
|
218
|
-
"lint_score": score_data.
|
|
219
|
-
"todo_score": score_data.
|
|
220
|
-
"staleness_score": score_data.
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
269
|
+
"score": score_data.total,
|
|
270
|
+
"grade": score_data.grade,
|
|
271
|
+
"label": score_data.label,
|
|
272
|
+
"churn_score": score_data.churn_score,
|
|
273
|
+
"complexity_score": score_data.complexity_score,
|
|
274
|
+
"lint_score": score_data.lint_score,
|
|
275
|
+
"todo_score": score_data.todo_score,
|
|
276
|
+
"staleness_score": score_data.staleness_score,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if files:
|
|
280
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
281
|
+
future_to_fp = {executor.submit(_scan_one, fp): fp for fp in files}
|
|
282
|
+
for future in concurrent.futures.as_completed(future_to_fp):
|
|
283
|
+
try:
|
|
284
|
+
r = future.result()
|
|
285
|
+
results.append(r)
|
|
286
|
+
grade, score = r["grade"], r["score"]
|
|
287
|
+
emoji = {"A":"✅","B":"⚠️","C":"🟠","D":"🔴","F":"💀"}.get(grade, "•")
|
|
288
|
+
print(f" {emoji} {Path(r['filepath']).name:<45} {score:>5.1f} [{grade}]")
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
fp = future_to_fp[future]
|
|
291
|
+
print(f" ⚠️ Failed to scan {Path(fp).name}: {exc}")
|
|
226
292
|
|
|
227
293
|
results.sort(key=lambda x: -x["score"])
|
|
228
294
|
avg = sum(r["score"] for r in results) / len(results) if results else 0
|
|
@@ -10,6 +10,17 @@ import time
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import List
|
|
12
12
|
|
|
13
|
+
# Windows' default console codepage (cp1252 or similar) can't encode the
|
|
14
|
+
# emoji used throughout this CLI, and rich's legacy Windows renderer doesn't
|
|
15
|
+
# fall back gracefully — it raises UnicodeEncodeError and crashes the whole
|
|
16
|
+
# command. Force UTF-8 output before anything else touches stdout/stderr.
|
|
17
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
18
|
+
if hasattr(_stream, "reconfigure"):
|
|
19
|
+
try:
|
|
20
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
13
24
|
try:
|
|
14
25
|
from rich.console import Console
|
|
15
26
|
from rich.table import Table
|
|
@@ -45,8 +56,12 @@ def _grade_style(grade: str) -> str:
|
|
|
45
56
|
|
|
46
57
|
|
|
47
58
|
def cmd_scan(args) -> int:
|
|
48
|
-
|
|
59
|
+
# Scan results belong to the project being scanned, so pin the DB to the
|
|
60
|
+
# target's location before touching it — this is what keeps two projects
|
|
61
|
+
# scanned on the same machine from bleeding into each other's reports.
|
|
49
62
|
target = Path(args.path).resolve()
|
|
63
|
+
db.set_project_root(target if target.is_dir() else target.parent)
|
|
64
|
+
db.ensure_db()
|
|
50
65
|
|
|
51
66
|
if target.is_file():
|
|
52
67
|
files = [str(target)]
|
|
@@ -75,8 +90,15 @@ def cmd_scan(args) -> int:
|
|
|
75
90
|
_cprint(f"\n[bold]DebtPilot scan[/bold] — {len(files)} file(s)\n" if HAS_RICH
|
|
76
91
|
else f"\nDebtPilot scan — {len(files)} file(s)\n")
|
|
77
92
|
|
|
93
|
+
import concurrent.futures
|
|
94
|
+
import threading
|
|
95
|
+
|
|
78
96
|
results = []
|
|
79
|
-
|
|
97
|
+
db_lock = threading.Lock()
|
|
98
|
+
print_lock = threading.Lock()
|
|
99
|
+
|
|
100
|
+
def _process_file(fp: str):
|
|
101
|
+
"""Runs in a worker thread — does the slow I/O work (git, lint) outside the lock."""
|
|
80
102
|
lint_data = linter.lint_file(fp)
|
|
81
103
|
all_issues = []
|
|
82
104
|
for issues in lint_data.values():
|
|
@@ -89,35 +111,51 @@ def cmd_scan(args) -> int:
|
|
|
89
111
|
lint_issues=all_issues, todos=scan_data["todos"],
|
|
90
112
|
staleness_days=scan_data["staleness_days"],
|
|
91
113
|
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
return fp, score_data, scan_data, lint_data
|
|
115
|
+
|
|
116
|
+
def _save_and_print(fp, score_data, scan_data, lint_data):
|
|
117
|
+
"""Runs on the main thread — SQLite writes are serialized to avoid lock contention."""
|
|
118
|
+
with db_lock:
|
|
119
|
+
db.upsert_score(fp, score_data)
|
|
120
|
+
if scan_data["churn"]["commits"] > 0:
|
|
121
|
+
db.upsert_churn(fp, scan_data["churn"]["commits"],
|
|
122
|
+
scan_data["churn"]["authors"], scan_data["churn"]["last_commit"])
|
|
123
|
+
for tool, issues in lint_data.items():
|
|
124
|
+
if tool != "todos" and issues:
|
|
125
|
+
db.upsert_lint(fp, tool, issues)
|
|
126
|
+
if scan_data["todos"]:
|
|
127
|
+
db.upsert_todos(fp, scan_data["todos"])
|
|
101
128
|
|
|
102
129
|
results.append((fp, score_data))
|
|
103
|
-
grade = score_data
|
|
104
|
-
total = score_data
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
grade = score_data.grade
|
|
131
|
+
total = score_data.total
|
|
132
|
+
|
|
133
|
+
with print_lock:
|
|
134
|
+
if HAS_RICH:
|
|
135
|
+
console.print(
|
|
136
|
+
f" {GRADE_EMOJI[grade]} [bold]{Path(fp).name:<40}[/bold] "
|
|
137
|
+
f"[{_grade_style(grade)}]{total:>5.1f}[/{_grade_style(grade)}] "
|
|
138
|
+
f"[dim]{score_data.label}[/dim]"
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
print(f" [{grade}] {Path(fp).name:<40} {total:>5.1f} {score_data.label}")
|
|
142
|
+
max_workers = min(32, (len(files) // 10) + 4) # scale workers to project size, cap at 16
|
|
143
|
+
|
|
144
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
145
|
+
future_to_fp = {executor.submit(_process_file, fp): fp for fp in files}
|
|
146
|
+
for future in concurrent.futures.as_completed(future_to_fp):
|
|
147
|
+
try:
|
|
148
|
+
fp, score_data, scan_data, lint_data = future.result()
|
|
149
|
+
_save_and_print(fp, score_data, scan_data, lint_data)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
fp = future_to_fp[future]
|
|
152
|
+
with print_lock:
|
|
153
|
+
_cprint(f" ⚠️ Failed to scan {Path(fp).name}: {exc}")
|
|
115
154
|
if len(results) > 1:
|
|
116
|
-
avg = sum(s
|
|
117
|
-
worst = max(results, key=lambda x: x[1]
|
|
155
|
+
avg = sum(s.total for _, s in results) / len(results)
|
|
156
|
+
worst = max(results, key=lambda x: x[1].total)
|
|
118
157
|
_cprint(f"\n Average debt score : {avg:.1f}")
|
|
119
|
-
_cprint(f" Highest risk file : {Path(worst[0]).name} ({worst[1]
|
|
120
|
-
|
|
158
|
+
_cprint(f" Highest risk file : {Path(worst[0]).name} ({worst[1].total:.1f})\n")
|
|
121
159
|
return 0
|
|
122
160
|
|
|
123
161
|
|
|
@@ -346,22 +384,24 @@ def cmd_predict(args) -> int:
|
|
|
346
384
|
_cprint("No data yet. Run: debtpilot scan .")
|
|
347
385
|
return 0
|
|
348
386
|
|
|
349
|
-
_cprint(f"\n🔮 [bold]
|
|
350
|
-
|
|
387
|
+
_cprint(f"\n🔮 [bold]Risk Index Report[/bold] — {summary['total_files']} files analysed\n"
|
|
388
|
+
f"[dim]Heuristic prioritisation signal, not a validated prediction.[/dim]\n"
|
|
389
|
+
if HAS_RICH else f"\nRisk Index Report — {summary['total_files']} files\n"
|
|
390
|
+
f"(Heuristic prioritisation signal, not a validated prediction.)\n")
|
|
351
391
|
_cprint(f" Critical: {summary['critical']} High: {summary['high']} "
|
|
352
392
|
f"Medium: {summary['medium']} Low: {summary['low']}\n")
|
|
353
393
|
|
|
354
394
|
for p in predictions:
|
|
355
395
|
colors = {"CRITICAL":"bold red","HIGH":"red","MEDIUM":"orange3","LOW":"green"}
|
|
356
396
|
risk = p["risk_level"]
|
|
357
|
-
|
|
397
|
+
idx = p.get("risk_index", p.get("probability", 0))
|
|
358
398
|
name = Path(p["filepath"]).name
|
|
359
399
|
drivers = " · ".join(p["drivers"][:2])
|
|
360
400
|
if HAS_RICH:
|
|
361
401
|
console.print(f" [{colors.get(risk,'white')}]{risk:<10}[/{colors.get(risk,'white')}]"
|
|
362
|
-
f" {
|
|
402
|
+
f" {idx:>5.1f} [cyan]{name:<40}[/cyan] [dim]{drivers}[/dim]")
|
|
363
403
|
else:
|
|
364
|
-
print(f" {risk:<10} {
|
|
404
|
+
print(f" {risk:<10} {idx:>5.1f} {name:<40} {drivers}")
|
|
365
405
|
return 0
|
|
366
406
|
|
|
367
407
|
|
|
@@ -98,28 +98,28 @@ async def process_file(filepath: str) -> None:
|
|
|
98
98
|
if scan_data["todos"]:
|
|
99
99
|
db.upsert_todos(filepath, scan_data["todos"])
|
|
100
100
|
|
|
101
|
-
total = score_data
|
|
101
|
+
total = score_data.total
|
|
102
102
|
prev = db.get_latest_score(filepath)
|
|
103
103
|
prev_score = prev["score"] if prev else 0
|
|
104
104
|
|
|
105
105
|
if total >= ALERT_THRESHOLD and prev_score < ALERT_THRESHOLD:
|
|
106
106
|
alert_id = db.create_alert(filepath, "debt_threshold",
|
|
107
|
-
f"Score crossed {ALERT_THRESHOLD}: {score_data
|
|
107
|
+
f"Score crossed {ALERT_THRESHOLD}: {score_data.label} ({total})", total)
|
|
108
108
|
await hub.broadcast({
|
|
109
109
|
"event": "alert", "alert_id": alert_id, "filepath": filepath,
|
|
110
|
-
"score": total, "grade": score_data
|
|
110
|
+
"score": total, "grade": score_data.grade, "label": score_data.label,
|
|
111
111
|
"message": f"⚠️ {Path(filepath).name} crossed the danger threshold",
|
|
112
112
|
"recommendations": scorer.top_recommendations(score_data),
|
|
113
113
|
})
|
|
114
114
|
# 🔔 Real desktop notification
|
|
115
115
|
try:
|
|
116
116
|
from debtpilot_core.notifier import alert_danger_threshold, alert_critical_file
|
|
117
|
-
if score_data
|
|
117
|
+
if score_data.grade == "F":
|
|
118
118
|
alert_critical_file(filepath, total)
|
|
119
119
|
else:
|
|
120
120
|
alert_danger_threshold(
|
|
121
121
|
filepath, total,
|
|
122
|
-
score_data
|
|
122
|
+
score_data.grade,
|
|
123
123
|
scorer.top_recommendations(score_data)
|
|
124
124
|
)
|
|
125
125
|
except Exception:
|
|
@@ -130,23 +130,23 @@ async def process_file(filepath: str) -> None:
|
|
|
130
130
|
f"Score spiked +{total - prev_score:.1f} to {total}", total)
|
|
131
131
|
await hub.broadcast({
|
|
132
132
|
"event": "alert", "alert_id": alert_id, "filepath": filepath,
|
|
133
|
-
"score": total, "grade": score_data
|
|
133
|
+
"score": total, "grade": score_data.grade, "label": score_data.label,
|
|
134
134
|
"message": f"🔺 {Path(filepath).name} score spiked",
|
|
135
135
|
"recommendations": scorer.top_recommendations(score_data),
|
|
136
136
|
})
|
|
137
137
|
# 🔔 Real desktop notification
|
|
138
138
|
try:
|
|
139
139
|
from debtpilot_core.notifier import alert_debt_spike
|
|
140
|
-
alert_debt_spike(filepath, total, score_data
|
|
140
|
+
alert_debt_spike(filepath, total, score_data.grade, score_data.label)
|
|
141
141
|
except Exception:
|
|
142
142
|
pass
|
|
143
143
|
|
|
144
144
|
await hub.broadcast({
|
|
145
145
|
"event": "score_updated", "filepath": filepath,
|
|
146
|
-
"score": total, "grade": score_data
|
|
146
|
+
"score": total, "grade": score_data.grade, "label": score_data.label,
|
|
147
147
|
"timestamp": int(time.time()),
|
|
148
148
|
})
|
|
149
|
-
logger.info(f"[{score_data
|
|
149
|
+
logger.info(f"[{score_data.grade}] {Path(filepath).name} score={total}")
|
|
150
150
|
|
|
151
151
|
|
|
152
152
|
class DebtEventHandler(FileSystemEventHandler):
|
|
@@ -216,6 +216,9 @@ async def ws_handler(ws, path="/"):
|
|
|
216
216
|
|
|
217
217
|
|
|
218
218
|
async def run_daemon(project_dir: str, port: int = DEFAULT_PORT) -> None:
|
|
219
|
+
# The daemon may be launched from a different cwd than the project it
|
|
220
|
+
# watches (e.g. Windows Task Scheduler), so pin the DB to project_dir.
|
|
221
|
+
db.set_project_root(project_dir)
|
|
219
222
|
db.ensure_db()
|
|
220
223
|
logger.info(f"DebtPilot daemon starting | project={project_dir} | port={port}")
|
|
221
224
|
loop = asyncio.get_event_loop()
|
|
@@ -3,6 +3,7 @@ DebtPilot - SQLite storage layer.
|
|
|
3
3
|
Stores file debt scores, churn history, lint results, and alerts.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import os
|
|
6
7
|
import sqlite3
|
|
7
8
|
import json
|
|
8
9
|
import time
|
|
@@ -11,8 +12,52 @@ from typing import Optional, List, Dict, Any
|
|
|
11
12
|
from contextlib import contextmanager
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
# ── Per-project database location ─────────────────────────────────────────────
|
|
16
|
+
#
|
|
17
|
+
# The scan database is scoped to a single project, NOT global. Before this,
|
|
18
|
+
# every project scanned on a machine wrote into one shared ~/.debtpilot DB, so
|
|
19
|
+
# `debtpilot report` in project B would show project A's files mixed in — a
|
|
20
|
+
# real data-isolation defect. The DB now lives at <project-root>/.debtpilot/,
|
|
21
|
+
# discovered the same way git finds its root (walk up for a .git marker),
|
|
22
|
+
# falling back to the current directory.
|
|
23
|
+
#
|
|
24
|
+
# Note: this is separate from ~/.debtpilot/config.json, which is intentionally
|
|
25
|
+
# global (your Gemini key and email settings are per-user, not per-project).
|
|
26
|
+
|
|
27
|
+
_DB_FILENAME = "debtpilot.db"
|
|
28
|
+
_project_root_override: Optional[Path] = None
|
|
29
|
+
_db_path_cache: Optional[Path] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_project_root(path) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Force which project the DB belongs to. Used by the daemon and CI/CD, which
|
|
35
|
+
are handed an explicit project directory that may differ from cwd, and by
|
|
36
|
+
tests that need an isolated DB.
|
|
37
|
+
"""
|
|
38
|
+
global _project_root_override, _db_path_cache
|
|
39
|
+
_project_root_override = Path(path).resolve()
|
|
40
|
+
_db_path_cache = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_project_root() -> Path:
|
|
44
|
+
if _project_root_override is not None:
|
|
45
|
+
return _project_root_override
|
|
46
|
+
env = os.environ.get("DEBTPILOT_PROJECT_ROOT")
|
|
47
|
+
if env:
|
|
48
|
+
return Path(env).resolve()
|
|
49
|
+
start = Path.cwd().resolve()
|
|
50
|
+
for parent in [start] + list(start.parents):
|
|
51
|
+
if (parent / ".git").exists():
|
|
52
|
+
return parent
|
|
53
|
+
return start
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_db_path() -> Path:
|
|
57
|
+
global _db_path_cache
|
|
58
|
+
if _db_path_cache is None:
|
|
59
|
+
_db_path_cache = _find_project_root() / ".debtpilot" / _DB_FILENAME
|
|
60
|
+
return _db_path_cache
|
|
16
61
|
|
|
17
62
|
SCHEMA = """
|
|
18
63
|
CREATE TABLE IF NOT EXISTS file_scores (
|
|
@@ -73,15 +118,25 @@ CREATE INDEX IF NOT EXISTS idx_alerts_filepath ON alerts(filepath);
|
|
|
73
118
|
|
|
74
119
|
|
|
75
120
|
def ensure_db() -> None:
|
|
76
|
-
|
|
77
|
-
|
|
121
|
+
db_path = get_db_path()
|
|
122
|
+
db_dir = db_path.parent
|
|
123
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
# Self-ignore: drop a .gitignore so users never accidentally commit their
|
|
125
|
+
# local scan database (same trick pytest/mypy use for their caches).
|
|
126
|
+
gitignore = db_dir / ".gitignore"
|
|
127
|
+
if not gitignore.exists():
|
|
128
|
+
try:
|
|
129
|
+
gitignore.write_text("# Created automatically by DebtPilot\n*\n")
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
with sqlite3.connect(db_path) as conn:
|
|
78
133
|
conn.executescript(SCHEMA)
|
|
79
134
|
conn.commit()
|
|
80
135
|
|
|
81
136
|
|
|
82
137
|
@contextmanager
|
|
83
138
|
def get_conn():
|
|
84
|
-
conn = sqlite3.connect(
|
|
139
|
+
conn = sqlite3.connect(get_db_path())
|
|
85
140
|
conn.row_factory = sqlite3.Row
|
|
86
141
|
try:
|
|
87
142
|
yield conn
|
|
@@ -93,7 +148,12 @@ def get_conn():
|
|
|
93
148
|
conn.close()
|
|
94
149
|
|
|
95
150
|
|
|
96
|
-
def upsert_score(filepath: str, score_data
|
|
151
|
+
def upsert_score(filepath: str, score_data) -> None:
|
|
152
|
+
"""
|
|
153
|
+
score_data must be a scorer.ScoreResult — attribute access, not .get().
|
|
154
|
+
If a field is missing, this raises AttributeError immediately rather
|
|
155
|
+
than silently writing a 0, which is the exact bug class this replaces.
|
|
156
|
+
"""
|
|
97
157
|
now = int(time.time())
|
|
98
158
|
with get_conn() as conn:
|
|
99
159
|
conn.execute(
|
|
@@ -105,12 +165,12 @@ def upsert_score(filepath: str, score_data: Dict[str, Any]) -> None:
|
|
|
105
165
|
""",
|
|
106
166
|
(
|
|
107
167
|
filepath,
|
|
108
|
-
score_data.
|
|
109
|
-
score_data.
|
|
110
|
-
score_data.
|
|
111
|
-
score_data.
|
|
112
|
-
score_data.
|
|
113
|
-
score_data.
|
|
168
|
+
score_data.total,
|
|
169
|
+
score_data.churn_score,
|
|
170
|
+
score_data.complexity_score,
|
|
171
|
+
score_data.lint_score,
|
|
172
|
+
score_data.todo_score,
|
|
173
|
+
score_data.staleness_score,
|
|
114
174
|
now,
|
|
115
175
|
),
|
|
116
176
|
)
|
|
@@ -6,6 +6,16 @@ Team management, CI/CD setup, compliance reports.
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
# See cli.py for why this is needed: Windows' default console codepage
|
|
10
|
+
# can't encode the emoji this module prints, and rich's legacy Windows
|
|
11
|
+
# renderer raises UnicodeEncodeError instead of degrading gracefully.
|
|
12
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
13
|
+
if hasattr(_stream, "reconfigure"):
|
|
14
|
+
try:
|
|
15
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
|
|
9
19
|
try:
|
|
10
20
|
from rich.console import Console
|
|
11
21
|
from rich.table import Table
|