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/__init__.py +3 -0
- fossil/__main__.py +4 -0
- fossil/analyzers.py +221 -0
- fossil/cache.py +228 -0
- fossil/cli.py +421 -0
- fossil/config_manager.py +141 -0
- fossil/engine.py +122 -0
- fossil/git_miner.py +78 -0
- fossil/models.py +109 -0
- fossil/patterns.py +79 -0
- fossil/py.typed +1 -0
- fossil/render.py +436 -0
- fossil/repo.py +82 -0
- fossil/scoring.py +126 -0
- fossil_code-0.2.0.dist-info/METADATA +377 -0
- fossil_code-0.2.0.dist-info/RECORD +20 -0
- fossil_code-0.2.0.dist-info/WHEEL +5 -0
- fossil_code-0.2.0.dist-info/entry_points.txt +2 -0
- fossil_code-0.2.0.dist-info/licenses/LICENSE +21 -0
- fossil_code-0.2.0.dist-info/top_level.txt +1 -0
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
|