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.
Files changed (35) hide show
  1. {debtpilot-0.2.0 → debtpilot-0.3.0}/PKG-INFO +16 -4
  2. {debtpilot-0.2.0 → debtpilot-0.3.0}/README.md +12 -0
  3. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/PKG-INFO +16 -4
  4. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/SOURCES.txt +5 -2
  5. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/cicd.py +88 -22
  6. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/cli.py +72 -32
  7. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/daemon.py +12 -9
  8. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/database.py +72 -12
  9. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/enterprise_cli.py +10 -0
  10. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/hooks.py +33 -10
  11. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/predictor.py +51 -22
  12. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/scorer.py +71 -24
  13. {debtpilot-0.2.0 → debtpilot-0.3.0}/pyproject.toml +3 -3
  14. debtpilot-0.3.0/tests/test_daemon.py +56 -0
  15. debtpilot-0.3.0/tests/test_hooks.py +96 -0
  16. debtpilot-0.3.0/tests/test_project_isolation.py +108 -0
  17. debtpilot-0.3.0/tests/test_scorer.py +319 -0
  18. debtpilot-0.2.0/debtpilot_core/server.py +0 -0
  19. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/dependency_links.txt +0 -0
  20. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/entry_points.txt +0 -0
  21. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/requires.txt +0 -0
  22. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot.egg-info/top_level.txt +0 -0
  23. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/__init__.py +0 -0
  24. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/ai_advisor.py +0 -0
  25. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/autostart.py +0 -0
  26. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/compliance.py +0 -0
  27. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/dashboard.py +0 -0
  28. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/digest.py +0 -0
  29. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/linter.py +0 -0
  30. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/notifier.py +0 -0
  31. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/reporter.py +0 -0
  32. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/scanner.py +0 -0
  33. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/sync.py +0 -0
  34. {debtpilot-0.2.0 → debtpilot-0.3.0}/debtpilot_core/team.py +0 -0
  35. {debtpilot-0.2.0 → debtpilot-0.3.0}/setup.cfg +0 -0
@@ -1,10 +1,10 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: debtpilot
3
- Version: 0.2.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/YOUR_GITHUB_USERNAME/debtpilot
7
- Project-URL: Issues, https://github.com/YOUR_GITHUB_USERNAME/debtpilot/issues
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
1
+ Metadata-Version: 2.4
2
2
  Name: debtpilot
3
- Version: 0.2.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/YOUR_GITHUB_USERNAME/debtpilot
7
- Project-URL: Issues, https://github.com/YOUR_GITHUB_USERNAME/debtpilot/issues
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} | \`${name}\` | ${f.score.toFixed(1)} | ${f.label} |`;
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
- files = []
188
- for f in Path(project_dir).rglob("*"):
189
- if f.suffix in exts and not any(p in ignored for p in f.parts):
190
- if not backup_pattern.search(f.name):
191
- files.append(str(f))
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
- print(f"\n⚡ DebtPilot CI Scan — {len(files)} files\n")
250
+ max_workers = min(16, (len(files) // 5) + 4)
195
251
 
196
- for fp in files:
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
- results.append({
267
+ return {
212
268
  "filepath": fp,
213
- "score": score_data["total"],
214
- "grade": score_data["grade"],
215
- "label": score_data["label"],
216
- "churn_score": score_data.get("churn_score", 0),
217
- "complexity_score": score_data.get("complexity_score", 0),
218
- "lint_score": score_data.get("lint_score", 0),
219
- "todo_score": score_data.get("todo_score", 0),
220
- "staleness_score": score_data.get("staleness_score", 0),
221
- })
222
- grade = score_data["grade"]
223
- score = score_data["total"]
224
- emoji = {"A":"✅","B":"⚠️","C":"🟠","D":"🔴","F":"💀"}.get(grade, "•")
225
- print(f" {emoji} {Path(fp).name:<45} {score:>5.1f} [{grade}]")
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
- db.ensure_db()
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
- for fp in files:
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
- db.upsert_score(fp, score_data)
93
- if scan_data["churn"]["commits"] > 0:
94
- db.upsert_churn(fp, scan_data["churn"]["commits"],
95
- scan_data["churn"]["authors"], scan_data["churn"]["last_commit"])
96
- for tool, issues in lint_data.items():
97
- if tool != "todos" and issues:
98
- db.upsert_lint(fp, tool, issues)
99
- if scan_data["todos"]:
100
- db.upsert_todos(fp, scan_data["todos"])
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["grade"]
104
- total = score_data["total"]
105
-
106
- if HAS_RICH:
107
- console.print(
108
- f" {GRADE_EMOJI[grade]} [bold]{Path(fp).name:<40}[/bold] "
109
- f"[{_grade_style(grade)}]{total:>5.1f}[/{_grade_style(grade)}] "
110
- f"[dim]{score_data['label']}[/dim]"
111
- )
112
- else:
113
- print(f" [{grade}] {Path(fp).name:<40} {total:>5.1f} {score_data['label']}")
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["total"] for _, s in results) / len(results)
117
- worst = max(results, key=lambda x: x[1]["total"])
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]['total']:.1f})\n")
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]Bug Prediction Report[/bold] — {summary['total_files']} files analysed\n"
350
- if HAS_RICH else f"\nBug Prediction {summary['total_files']} files\n")
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
- prob = p["probability"]
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" {prob:>4}% [cyan]{name:<40}[/cyan] [dim]{drivers}[/dim]")
402
+ f" {idx:>5.1f} [cyan]{name:<40}[/cyan] [dim]{drivers}[/dim]")
363
403
  else:
364
- print(f" {risk:<10} {prob:>4}% {name:<40} {drivers}")
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["total"]
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['label']} ({total})", total)
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["grade"], "label": score_data["label"],
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["grade"] == "F":
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["grade"],
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["grade"], "label": score_data["label"],
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["grade"], score_data["label"])
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["grade"], "label": score_data["label"],
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['grade']}] {Path(filepath).name} score={total}")
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
- DB_DIR = Path.home() / ".debtpilot"
15
- DB_PATH = DB_DIR / "debtpilot.db"
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
- DB_DIR.mkdir(parents=True, exist_ok=True)
77
- with sqlite3.connect(DB_PATH) as conn:
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(DB_PATH)
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: Dict[str, Any]) -> None:
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.get("total", 0),
109
- score_data.get("churn", 0),
110
- score_data.get("complexity", 0),
111
- score_data.get("lint", 0),
112
- score_data.get("todo", 0),
113
- score_data.get("staleness", 0),
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