fossil-code 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.
fossil/render.py ADDED
@@ -0,0 +1,436 @@
1
+ """Output rendering — Rich terminal panels and plain-text fallback.
2
+
3
+ Implements the Terminal UX Specification from §4 of the pre-development docs:
4
+ - Rich formatted panels with color-coded sections for `fossil explain`
5
+ - Table output for `fossil scan`
6
+ - Plain text fallback when Rich is unavailable or --plain is passed
7
+ - JSON rendering (unchanged)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import os
15
+ from typing import TYPE_CHECKING
16
+
17
+ from fossil.models import ForensicResult
18
+
19
+ if TYPE_CHECKING:
20
+ pass
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # JSON renderer
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def render_json(result: ForensicResult) -> str:
28
+ return json.dumps(result.to_dict(), indent=2, sort_keys=True)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Detect Rich availability
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def _rich_available() -> bool:
37
+ try:
38
+ from rich.console import Console # noqa: F401
39
+
40
+ return True
41
+ except ImportError:
42
+ return False
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Rich terminal renderer (§4.1)
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def render_rich(result: ForensicResult, *, no_color: bool = False) -> str:
51
+ """Render a forensic report using Rich panels, tables, and colors."""
52
+ from io import StringIO
53
+
54
+ from rich.console import Console
55
+ from rich.panel import Panel
56
+ from rich.table import Table
57
+ from rich.text import Text
58
+
59
+ buf = StringIO()
60
+ console = Console(file=buf, width=90, no_color=no_color, force_terminal=True)
61
+
62
+ # ── Status line ──
63
+ status_color = "bold red" if result.dead else "bold green"
64
+ status_dot = "●" if result.dead else "○"
65
+ status_text = Text()
66
+ status_text.append(" FORENSIC REPORT ", style="bold white")
67
+ status_text.append(result.target, style="cyan")
68
+ status_text.append("\n")
69
+ status_text.append(" Status ", style="dim")
70
+ status_text.append(f"{status_dot} {result.status}", style=status_color)
71
+ status_text.append(" Language ", style="dim")
72
+ status_text.append(result.language.title(), style="white")
73
+
74
+ sections = [status_text]
75
+
76
+ # ── History section ──
77
+ if result.git_history.tracked:
78
+ hist = Table(show_header=False, box=None, padding=(0, 1), expand=True)
79
+ hist.add_column("key", style="dim", width=16, no_wrap=True)
80
+ hist.add_column("value")
81
+ if result.git_history.death_commit:
82
+ dc = result.git_history.death_commit
83
+ hash_text = Text(dc.short_hash, style="cyan")
84
+ msg = f' "{dc.message[:60]}"'
85
+ hist.add_row("Dead since", Text.assemble(dc.date[:10], style="white"))
86
+ hist.add_row("Death commit", Text.assemble(hash_text, msg))
87
+ if dc.pr_number:
88
+ hist.add_row("PR", Text(f"#{dc.pr_number} · {dc.message[:55]}", style="white"))
89
+ elif result.git_history.ambiguous_death:
90
+ hist.add_row(
91
+ "Death commit", Text("AMBIGUOUS — no single death commit found", style="yellow")
92
+ )
93
+ if result.git_history.original_author:
94
+ oa = result.git_history.original_author
95
+ hist.add_row(
96
+ "Original by",
97
+ Text(f"{oa.author_name} · first committed {oa.date[:10]}", style="dim white"),
98
+ )
99
+ sections.append(Panel(hist, title="[bold white]History[/bold white]", border_style="dim"))
100
+
101
+ # ── Temporary Hold section ──
102
+ if result.temporary_hold.detected:
103
+ hold_parts: list[Text] = []
104
+ for pat in result.temporary_hold.patterns:
105
+ status_icon = (
106
+ "✓" if pat.condition_met is True else "✗" if pat.condition_met is False else "⚠"
107
+ )
108
+ icon_style = (
109
+ "green"
110
+ if pat.condition_met is True
111
+ else "red"
112
+ if pat.condition_met is False
113
+ else "yellow"
114
+ )
115
+ line = Text()
116
+ line.append(" Pattern ", style="dim")
117
+ line.append(f'"{pat.text[:70]}" (line {pat.line})', style="white")
118
+ line.append("\n Status ", style="dim")
119
+ line.append(f"{status_icon} ", style=icon_style)
120
+ status_label = (
121
+ "RESOLVED"
122
+ if pat.condition_met is True
123
+ else "UNRESOLVED"
124
+ if pat.condition_met is False
125
+ else "UNVERIFIED"
126
+ )
127
+ line.append(status_label, style=icon_style)
128
+ line.append(f" — {pat.evidence}", style="dim white")
129
+ hold_parts.append(line)
130
+ hold_text = Text("\n").join(hold_parts)
131
+ sections.append(
132
+ Panel(hold_text, title="[bold white]Temporary Hold[/bold white]", border_style="dim")
133
+ )
134
+
135
+ # ── Static Analysis section ──
136
+ sa = result.static_analysis
137
+ sa_table = Table(show_header=False, box=None, padding=(0, 2), expand=True)
138
+ sa_table.add_column("k1", style="dim", width=20)
139
+ sa_table.add_column("v1", width=8)
140
+ sa_table.add_column("k2", style="dim", width=20)
141
+ sa_table.add_column("v2", width=20)
142
+ sa_table.add_row(
143
+ "Call sites", str(sa.call_sites), "Dynamic imports", str(len(sa.dynamic_references))
144
+ )
145
+ sa_table.add_row(
146
+ "Import refs",
147
+ str(sa.import_references),
148
+ "Reflection",
149
+ "None detected"
150
+ if not sa.reflection_patterns
151
+ else f"{len(sa.reflection_patterns)} detected",
152
+ )
153
+ sa_table.add_row(
154
+ "Test references",
155
+ str(sa.test_file_references),
156
+ "Config refs",
157
+ str(sa.config_file_references),
158
+ )
159
+ sections.append(
160
+ Panel(sa_table, title="[bold white]Static Analysis[/bold white]", border_style="dim")
161
+ )
162
+
163
+ # ── Confidence section ──
164
+ if result.confidence:
165
+ conf = result.confidence
166
+ bar_filled = conf.score // 5
167
+ bar_empty = 20 - bar_filled
168
+ if conf.score >= 85:
169
+ bar_style, label_style = "bold green", "bold green"
170
+ elif conf.score >= 70 or conf.score >= 55:
171
+ bar_style, label_style = "yellow", "yellow"
172
+ else:
173
+ bar_style, label_style = "red", "red"
174
+ bar = Text()
175
+ bar.append(f" {conf.score}% ", style="bold white")
176
+ bar.append("█" * bar_filled, style=bar_style)
177
+ bar.append("░" * bar_empty, style="dim")
178
+ bar.append(f" {conf.label.upper()} · {conf.risk.upper()}", style=label_style)
179
+ sections.append(Panel(bar, title="[bold white]Confidence[/bold white]", border_style="dim"))
180
+
181
+ # ── Suggested action ──
182
+ if result.suggested_action:
183
+ action = Text()
184
+ action.append(" Suggested ", style="dim")
185
+ action.append(result.suggested_action, style="cyan")
186
+ action.append("\n Auto-PR ", style="dim")
187
+ action.append(result.yolo_command or "", style="cyan")
188
+ sections.append(action)
189
+
190
+ # ── Warnings ──
191
+ if result.warnings:
192
+ warn_text = Text()
193
+ for w in result.warnings:
194
+ warn_text.append(f" ⚠ {w}\n", style="yellow")
195
+ sections.append(warn_text)
196
+
197
+ # ── Duration ──
198
+ dur = Text(f" Analysis duration: {result.analysis_duration_ms}ms", style="dim")
199
+ if result.cached:
200
+ dur.append(" (cached)", style="dim")
201
+ sections.append(dur)
202
+
203
+ # Build the main panel
204
+ from rich.console import Group
205
+
206
+ content = Group(*sections)
207
+ panel = Panel(
208
+ content, title="[bold cyan]fossil[/bold cyan]", border_style="cyan", padding=(1, 2)
209
+ )
210
+
211
+ console.print(panel)
212
+ return buf.getvalue()
213
+
214
+
215
+ def render_rich_scan(
216
+ results: list[ForensicResult],
217
+ repo_root: str,
218
+ total_files: int,
219
+ threshold: int,
220
+ directory: str,
221
+ *,
222
+ no_color: bool = False,
223
+ ) -> str:
224
+ """Render scan results as a Rich table (§4.2)."""
225
+ from io import StringIO
226
+ from pathlib import Path
227
+
228
+ from rich.console import Console
229
+ from rich.table import Table
230
+ from rich.text import Text
231
+
232
+ buf = StringIO()
233
+ console = Console(file=buf, width=100, no_color=no_color, force_terminal=True)
234
+
235
+ if not results:
236
+ console.print(f"[green]✓[/green] No dead code found above {threshold}% threshold.")
237
+ return buf.getvalue()
238
+
239
+ console.print(f"\n fossil scan {directory} ({total_files} files)\n")
240
+
241
+ table = Table(show_edge=False, pad_edge=False, expand=True)
242
+ table.add_column("File", style="cyan", ratio=5)
243
+ table.add_column("Language", ratio=1)
244
+ table.add_column("Dead Since", ratio=1)
245
+ table.add_column("Confidence", justify="right", ratio=1)
246
+
247
+ total_loc = 0
248
+ for r in results:
249
+ try:
250
+ rel = Path(r.abs_path).relative_to(repo_root).as_posix()
251
+ except ValueError:
252
+ rel = r.target
253
+ score = r.confidence.score if r.confidence else 0
254
+ dead_since = ""
255
+ if r.git_history.death_commit:
256
+ dead_since = r.git_history.death_commit.date[:10]
257
+ elif r.git_history.last_modified:
258
+ dead_since = r.git_history.last_modified.date[:10]
259
+
260
+ if score >= 85:
261
+ score_style = "bold green"
262
+ elif score >= 70:
263
+ score_style = "yellow"
264
+ else:
265
+ score_style = "red"
266
+
267
+ table.add_row(rel, r.language.title(), dead_since, Text(f"{score}%", style=score_style))
268
+ with contextlib.suppress(OSError):
269
+ total_loc += sum(
270
+ 1
271
+ for _ in Path(r.abs_path).read_text(encoding="utf-8", errors="replace").splitlines()
272
+ )
273
+
274
+ console.print(table)
275
+ console.print(f"\n {len(results)} dead files found above {threshold}% threshold.")
276
+ if total_loc:
277
+ console.print(f" Estimated removable: ~{total_loc:,} LOC across {len(results)} files.\n")
278
+ console.print(" Run [cyan]fossil explain <file>[/cyan] for full forensic report.")
279
+ console.print(" Run [cyan]fossil clean --threshold 80[/cyan] to see a deletion backlog.\n")
280
+ return buf.getvalue()
281
+
282
+
283
+ def render_rich_clean(
284
+ results: list[ForensicResult],
285
+ repo_root: str,
286
+ threshold: int,
287
+ directory: str,
288
+ dry_run: bool,
289
+ *,
290
+ no_color: bool = False,
291
+ ) -> str:
292
+ """Render clean backlog using Rich."""
293
+ from io import StringIO
294
+ from pathlib import Path
295
+
296
+ from rich.console import Console
297
+ from rich.table import Table
298
+ from rich.text import Text
299
+
300
+ buf = StringIO()
301
+ console = Console(file=buf, width=100, no_color=no_color, force_terminal=True)
302
+
303
+ if not results:
304
+ console.print(f"No deletion candidates found above {threshold}% threshold.")
305
+ return buf.getvalue()
306
+
307
+ mode_label = "[yellow]DRY RUN[/yellow]" if dry_run else "[bold]PLANNED[/bold]"
308
+ console.print(f"\n fossil clean {directory} — {mode_label}\n")
309
+
310
+ table = Table(show_edge=False, expand=True)
311
+ table.add_column("#", width=4, justify="right")
312
+ table.add_column("File", style="cyan", ratio=4)
313
+ table.add_column("Confidence", justify="right", ratio=1)
314
+ table.add_column("Action", ratio=2)
315
+
316
+ for idx, r in enumerate(results, 1):
317
+ try:
318
+ rel = Path(r.abs_path).relative_to(repo_root).as_posix()
319
+ except ValueError:
320
+ rel = r.target
321
+ score = r.confidence.score if r.confidence else 0
322
+ if score >= 85:
323
+ score_style = "bold green"
324
+ elif score >= 70:
325
+ score_style = "yellow"
326
+ else:
327
+ score_style = "red"
328
+ table.add_row(str(idx), rel, Text(f"{score}%", style=score_style), r.suggested_action or "")
329
+
330
+ console.print(table)
331
+ console.print()
332
+ return buf.getvalue()
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Plain-text renderer (unchanged from original, used as fallback)
337
+ # ---------------------------------------------------------------------------
338
+
339
+
340
+ def render_text(result: ForensicResult) -> str:
341
+ lines = [
342
+ "fossil forensic report",
343
+ f"Target: {result.target}",
344
+ f"Status: {result.status}",
345
+ f"Language: {result.language}",
346
+ f"Repository: {result.repo_root}",
347
+ ]
348
+ if result.cached:
349
+ lines.append("Cached: true")
350
+ if result.git_history.death_commit:
351
+ dc = result.git_history.death_commit
352
+ pr = f" (PR #{dc.pr_number})" if dc.pr_number else ""
353
+ lines.append(f"Death commit: {dc.short_hash} {dc.date} {dc.message}{pr}")
354
+ elif result.git_history.tracked:
355
+ lines.append("Death commit: AMBIGUOUS")
356
+ if result.git_history.original_author:
357
+ oa = result.git_history.original_author
358
+ lines.append(f"Original author: {oa.author_name} <{oa.author_email}>")
359
+ static = result.static_analysis
360
+ lines.extend(
361
+ [
362
+ "",
363
+ "Static analysis:",
364
+ f" Import references: {static.import_references}",
365
+ f" Call sites: {static.call_sites}",
366
+ f" Dynamic references: {len(static.dynamic_references)}",
367
+ f" Reflection patterns: {len(static.reflection_patterns)}",
368
+ f" Test references: {static.test_file_references}",
369
+ f" Documentation references: {static.documentation_references}",
370
+ ]
371
+ )
372
+ if result.temporary_hold.detected:
373
+ lines.append("")
374
+ lines.append("Temporary hold patterns:")
375
+ for pattern in result.temporary_hold.patterns:
376
+ status = (
377
+ "resolved"
378
+ if pattern.condition_met is True
379
+ else "unresolved"
380
+ if pattern.condition_met is False
381
+ else "unverified"
382
+ )
383
+ lines.append(f" line {pattern.line}: {status}: {pattern.text}")
384
+ lines.append(f" evidence: {pattern.evidence}")
385
+ if result.confidence:
386
+ lines.extend(
387
+ [
388
+ "",
389
+ f"Confidence: {result.confidence.score}% — {result.confidence.label} · {result.confidence.risk}",
390
+ "Signals:",
391
+ ]
392
+ )
393
+ for signal in result.confidence.signals:
394
+ mark = "applied" if signal.applied else "skipped"
395
+ lines.append(f" {signal.name}: {signal.weight:+d} ({mark}) — {signal.reason}")
396
+ if result.suggested_action:
397
+ lines.append("")
398
+ lines.append(f"Suggested: {result.suggested_action}")
399
+ lines.append(f"Auto-PR: {result.yolo_command}")
400
+ if result.warnings:
401
+ lines.append("")
402
+ lines.append("Warnings:")
403
+ lines.extend(f" {warning}" for warning in result.warnings)
404
+ lines.append("")
405
+ lines.append(f"Analysis duration: {result.analysis_duration_ms}ms")
406
+ return "\n".join(lines)
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Dispatcher — pick Rich or plain based on environment/flags
411
+ # ---------------------------------------------------------------------------
412
+
413
+
414
+ def should_use_rich(
415
+ *, plain: bool = False, no_color: bool = False, json_mode: bool = False
416
+ ) -> bool:
417
+ """Determine whether to use Rich rendering."""
418
+ if json_mode or plain:
419
+ return False
420
+ if os.environ.get("NO_COLOR") or os.environ.get("FOSSIL_NO_COLOR"):
421
+ return _rich_available() # Use Rich but with no_color=True
422
+ return _rich_available()
423
+
424
+
425
+ def render_explain(
426
+ result: ForensicResult, *, json_mode: bool = False, plain: bool = False, no_color: bool = False
427
+ ) -> str:
428
+ """Render a single explain result with the best available renderer."""
429
+ if json_mode:
430
+ return render_json(result)
431
+ no_color = (
432
+ no_color or bool(os.environ.get("NO_COLOR")) or bool(os.environ.get("FOSSIL_NO_COLOR"))
433
+ )
434
+ if not plain and _rich_available():
435
+ return render_rich(result, no_color=no_color)
436
+ return render_text(result)
fossil/repo.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ class FossilError(Exception):
8
+ exit_code = 1
9
+
10
+
11
+ class FileMissingError(FossilError):
12
+ exit_code = 2
13
+
14
+
15
+ class NotGitRepositoryError(FossilError):
16
+ exit_code = 3
17
+
18
+
19
+ def run_git(
20
+ repo_root: Path | None, args: list[str], check: bool = True
21
+ ) -> subprocess.CompletedProcess[str]:
22
+ cmd = ["git"]
23
+ if repo_root is not None:
24
+ cmd.extend(["-C", str(repo_root)])
25
+ cmd.extend(args)
26
+ return subprocess.run(cmd, text=True, capture_output=True, check=check)
27
+
28
+
29
+ def find_repo_root(path: Path) -> Path:
30
+ probe = path if path.is_dir() else path.parent
31
+ try:
32
+ result = run_git(probe, ["rev-parse", "--show-toplevel"])
33
+ except subprocess.CalledProcessError as exc:
34
+ raise NotGitRepositoryError("Not a git repository") from exc
35
+ return Path(result.stdout.strip()).resolve()
36
+
37
+
38
+ def resolve_target(target: str) -> tuple[Path, Path, bool]:
39
+ raw = Path(target).expanduser()
40
+ if not raw.exists():
41
+ raise FileMissingError(f"File not found: {target}")
42
+ symlink = raw.is_symlink()
43
+ path = raw.resolve()
44
+ repo_root = find_repo_root(path)
45
+ try:
46
+ path.relative_to(repo_root)
47
+ except ValueError as exc:
48
+ raise NotGitRepositoryError(f"Path is outside git repository: {path}") from exc
49
+ return path, repo_root, symlink
50
+
51
+
52
+ def relpath(path: Path, repo_root: Path) -> str:
53
+ return path.resolve().relative_to(repo_root.resolve()).as_posix()
54
+
55
+
56
+ def is_tracked(path: Path, repo_root: Path) -> bool:
57
+ result = run_git(
58
+ repo_root, ["ls-files", "--error-unmatch", relpath(path, repo_root)], check=False
59
+ )
60
+ return result.returncode == 0
61
+
62
+
63
+ def is_gitignored(path: Path, repo_root: Path) -> bool:
64
+ result = run_git(repo_root, ["check-ignore", "-q", relpath(path, repo_root)], check=False)
65
+ return result.returncode == 0
66
+
67
+
68
+ def git_head(repo_root: Path) -> str:
69
+ result = run_git(repo_root, ["rev-parse", "HEAD"], check=False)
70
+ if result.returncode != 0:
71
+ return "NO_HEAD"
72
+ return result.stdout.strip()
73
+
74
+
75
+ def remote_url(repo_root: Path) -> str | None:
76
+ result = run_git(repo_root, ["remote", "get-url", "origin"], check=False)
77
+ return result.stdout.strip() or None if result.returncode == 0 else None
78
+
79
+
80
+ def is_shallow(repo_root: Path) -> bool:
81
+ result = run_git(repo_root, ["rev-parse", "--is-shallow-repository"], check=False)
82
+ return result.stdout.strip().lower() == "true"
fossil/scoring.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from fossil.models import (
6
+ ConfidenceResult,
7
+ ConfidenceSignal,
8
+ GitHistoryResult,
9
+ PatternResult,
10
+ StaticAnalysisResult,
11
+ )
12
+
13
+
14
+ def score(
15
+ static: StaticAnalysisResult, git: GitHistoryResult, patterns: PatternResult
16
+ ) -> ConfidenceResult:
17
+ signals: list[ConfidenceSignal] = []
18
+ total = 0
19
+
20
+ def add(name: str, weight: int, applied: bool, reason: str) -> None:
21
+ nonlocal total
22
+ signals.append(ConfidenceSignal(name, weight, applied, reason))
23
+ if applied:
24
+ total += weight
25
+
26
+ zero_refs = static.call_sites == 0 and static.import_references == 0
27
+ add("zero_call_sites", 30, zero_refs, "No main-code imports or calls found.")
28
+ add(
29
+ "no_dynamic_references",
30
+ 20,
31
+ not static.dynamic_references,
32
+ "No importlib/__import__ references found.",
33
+ )
34
+ add(
35
+ "death_commit_identified",
36
+ 15,
37
+ git.death_commit is not None,
38
+ "Git history contains a reference-removal candidate.",
39
+ )
40
+ resolved = patterns.detected and all(p.condition_met is True for p in patterns.patterns)
41
+ add("temporary_hold_resolved", 10, resolved, "All deferred deletion conditions are resolved.")
42
+ add(
43
+ "no_reflection_patterns",
44
+ 10,
45
+ not static.reflection_patterns,
46
+ "No matching getattr/hasattr/setattr references found.",
47
+ )
48
+
49
+ dead_days = _days_since(git.death_commit.date if git.death_commit else None)
50
+ add(
51
+ "file_age_over_90_days_dead",
52
+ 8,
53
+ dead_days is not None and dead_days > 90,
54
+ "Death commit is older than 90 days.",
55
+ )
56
+ add(
57
+ "pr_or_migration_context_found",
58
+ 7,
59
+ bool(git.death_commit and git.death_commit.pr_number),
60
+ "Death commit references a PR.",
61
+ )
62
+
63
+ add(
64
+ "dynamic_import_detected",
65
+ -30,
66
+ bool(static.dynamic_references),
67
+ "Dynamic imports reduce static certainty.",
68
+ )
69
+ add(
70
+ "reflection_detected",
71
+ -20,
72
+ bool(static.reflection_patterns),
73
+ "Reflection patterns reduce static certainty.",
74
+ )
75
+ add(
76
+ "test_file_references_found",
77
+ -10,
78
+ static.test_file_references > 0,
79
+ "Only tests reference this target.",
80
+ )
81
+ unresolved_hold = patterns.detected and any(
82
+ p.condition_met is not True for p in patterns.patterns
83
+ )
84
+ add(
85
+ "keep_for_now_unresolved",
86
+ -15,
87
+ unresolved_hold,
88
+ "At least one deferred deletion condition is unresolved.",
89
+ )
90
+ add(
91
+ "language_unknown_fallback",
92
+ -15,
93
+ static.unknown_language,
94
+ "Fallback text analysis is less precise.",
95
+ )
96
+ modified_days = _days_since(git.last_modified.date if git.last_modified else None)
97
+ add(
98
+ "file_modified_under_30_days_ago",
99
+ -20,
100
+ modified_days is not None and modified_days < 30,
101
+ "Recently modified file.",
102
+ )
103
+ add(
104
+ "death_commit_ambiguous", -10, git.ambiguous_death, "No single death commit was identified."
105
+ )
106
+
107
+ final = max(0, min(100, total))
108
+ if final >= 85:
109
+ label, risk = "High Confidence", "Low Risk"
110
+ elif final >= 70:
111
+ label, risk = "Medium-High Confidence", "Low-Medium Risk"
112
+ elif final >= 55:
113
+ label, risk = "Medium Confidence", "Medium Risk"
114
+ else:
115
+ label, risk = "Low Confidence", "High Risk"
116
+ return ConfidenceResult(final, label, risk, signals)
117
+
118
+
119
+ def _days_since(value: str | None) -> int | None:
120
+ if not value:
121
+ return None
122
+ try:
123
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
124
+ except ValueError:
125
+ return None
126
+ return (datetime.now(UTC) - dt).days