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/cli.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Root CLI — command parser, dispatch, and user-facing error handling.
|
|
2
|
+
|
|
3
|
+
Implements §3.4 of the pre-development docs:
|
|
4
|
+
- fossil explain <target> — full forensic report
|
|
5
|
+
- fossil scan [directory] — directory scan with threshold filtering
|
|
6
|
+
- fossil clean [directory] — prioritized deletion backlog
|
|
7
|
+
- fossil cache clear — delete cache
|
|
8
|
+
- fossil cache stats — show cache statistics
|
|
9
|
+
- fossil config set/show — credential management
|
|
10
|
+
- Global flags: --no-color, --plain, --version
|
|
11
|
+
- Exit codes: 0 (dead), 1 (error), 2 (file not found), 3 (not git repo), 4 (alive/no results), 5 (unsupported)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from fossil import __version__
|
|
24
|
+
from fossil.analyzers import SOURCE_EXTENSIONS, iter_repo_files, language_for
|
|
25
|
+
from fossil.cache import CacheStore
|
|
26
|
+
from fossil.config_manager import masked_config, read_project_config, set_config
|
|
27
|
+
from fossil.engine import explain
|
|
28
|
+
from fossil.render import (
|
|
29
|
+
render_explain,
|
|
30
|
+
render_rich_clean,
|
|
31
|
+
render_rich_scan,
|
|
32
|
+
)
|
|
33
|
+
from fossil.repo import FileMissingError, FossilError, NotGitRepositoryError, find_repo_root
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(prog="fossil", description="Dead-code forensics CLI")
|
|
38
|
+
parser.add_argument("--version", action="version", version=f"fossil {__version__}")
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
# ── fossil explain ──
|
|
42
|
+
explain_p = sub.add_parser("explain", help="Generate a forensic report for one file")
|
|
43
|
+
explain_p.add_argument("target")
|
|
44
|
+
explain_p.add_argument("--json", action="store_true")
|
|
45
|
+
explain_p.add_argument("--plain", action="store_true")
|
|
46
|
+
explain_p.add_argument("--no-color", action="store_true")
|
|
47
|
+
explain_p.add_argument("--no-cache", action="store_true")
|
|
48
|
+
explain_p.add_argument("--depth", type=int, default=500)
|
|
49
|
+
explain_p.add_argument("--remote", choices=["github", "gitlab", "none", "auto"], default="auto")
|
|
50
|
+
explain_p.add_argument("--narrate", action="store_true")
|
|
51
|
+
explain_p.add_argument("--include-code", action="store_true")
|
|
52
|
+
explain_p.add_argument("--yolo", action="store_true")
|
|
53
|
+
explain_p.add_argument("--force-yolo", action="store_true")
|
|
54
|
+
explain_p.set_defaults(func=cmd_explain)
|
|
55
|
+
|
|
56
|
+
# ── fossil scan ──
|
|
57
|
+
scan_p = sub.add_parser("scan", help="Scan a directory for dead files")
|
|
58
|
+
scan_p.add_argument("directory", nargs="?", default=".")
|
|
59
|
+
scan_p.add_argument("--threshold", type=int, default=70)
|
|
60
|
+
scan_p.add_argument("--language", default="all")
|
|
61
|
+
scan_p.add_argument("--exclude", action="append", default=[])
|
|
62
|
+
scan_p.add_argument("--json", action="store_true")
|
|
63
|
+
scan_p.add_argument("--plain", action="store_true")
|
|
64
|
+
scan_p.add_argument("--no-color", action="store_true")
|
|
65
|
+
scan_p.add_argument("--no-cache", action="store_true")
|
|
66
|
+
scan_p.add_argument("--depth", type=int, default=500)
|
|
67
|
+
scan_p.set_defaults(func=cmd_scan)
|
|
68
|
+
|
|
69
|
+
# ── fossil clean ──
|
|
70
|
+
clean_p = sub.add_parser("clean", help="Build a prioritized deletion backlog")
|
|
71
|
+
clean_p.add_argument("directory", nargs="?", default=".")
|
|
72
|
+
clean_p.add_argument("--threshold", type=int, default=80)
|
|
73
|
+
clean_p.add_argument("--dry-run", action="store_true")
|
|
74
|
+
clean_p.add_argument("--yolo", action="store_true")
|
|
75
|
+
clean_p.add_argument("--json", action="store_true")
|
|
76
|
+
clean_p.add_argument("--plain", action="store_true")
|
|
77
|
+
clean_p.add_argument("--no-color", action="store_true")
|
|
78
|
+
clean_p.add_argument("--no-cache", action="store_true")
|
|
79
|
+
clean_p.add_argument("--depth", type=int, default=500)
|
|
80
|
+
clean_p.set_defaults(func=cmd_clean)
|
|
81
|
+
|
|
82
|
+
# ── fossil cache ──
|
|
83
|
+
cache_p = sub.add_parser("cache", help="Cache operations")
|
|
84
|
+
cache_sub = cache_p.add_subparsers(dest="cache_command", required=True)
|
|
85
|
+
clear_p = cache_sub.add_parser("clear")
|
|
86
|
+
clear_p.set_defaults(func=cmd_cache_clear)
|
|
87
|
+
stats_p = cache_sub.add_parser("stats")
|
|
88
|
+
stats_p.set_defaults(func=cmd_cache_stats)
|
|
89
|
+
|
|
90
|
+
# ── fossil config ──
|
|
91
|
+
config_p = sub.add_parser("config", help="Configuration operations")
|
|
92
|
+
config_sub = config_p.add_subparsers(dest="config_command", required=True)
|
|
93
|
+
show_p = config_sub.add_parser("show")
|
|
94
|
+
show_p.set_defaults(func=cmd_config_show)
|
|
95
|
+
set_p = config_sub.add_parser("set")
|
|
96
|
+
set_p.add_argument("key")
|
|
97
|
+
set_p.add_argument("value")
|
|
98
|
+
set_p.set_defaults(func=cmd_config_set)
|
|
99
|
+
return parser
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Command implementations
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
108
|
+
if args.narrate:
|
|
109
|
+
print(
|
|
110
|
+
"--narrate requires a configured LLM provider. Run: fossil config set llm_provider openai",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
113
|
+
return 1
|
|
114
|
+
result = explain(args.target, depth=args.depth, no_cache=args.no_cache)
|
|
115
|
+
if args.yolo or args.force_yolo:
|
|
116
|
+
min_score = 90 if args.yolo and not args.force_yolo else 0
|
|
117
|
+
score = result.confidence.score if result.confidence else 0
|
|
118
|
+
if score < min_score:
|
|
119
|
+
print(
|
|
120
|
+
f"Confidence is {score}%. --yolo blocked below 90%. Use --force-yolo to override.",
|
|
121
|
+
file=sys.stderr,
|
|
122
|
+
)
|
|
123
|
+
return 1
|
|
124
|
+
print(
|
|
125
|
+
"--yolo PR generation requires GitHub/GitLab API integration; no files were changed.",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
output = render_explain(
|
|
130
|
+
result,
|
|
131
|
+
json_mode=args.json,
|
|
132
|
+
plain=args.plain,
|
|
133
|
+
no_color=args.no_color,
|
|
134
|
+
)
|
|
135
|
+
print(output)
|
|
136
|
+
return 0 if result.dead else 4
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
140
|
+
root = Path(args.directory).expanduser().resolve()
|
|
141
|
+
repo_root = find_repo_root(root)
|
|
142
|
+
|
|
143
|
+
# Read project config for exclude patterns
|
|
144
|
+
project_config = read_project_config(repo_root)
|
|
145
|
+
exclude = list(args.exclude)
|
|
146
|
+
if project_config.get("analysis", {}).get("exclude_patterns"):
|
|
147
|
+
exclude.extend(project_config["analysis"]["exclude_patterns"])
|
|
148
|
+
|
|
149
|
+
selected = _language_filter(args.language)
|
|
150
|
+
candidates = [
|
|
151
|
+
path
|
|
152
|
+
for path in iter_repo_files(root, exclude)
|
|
153
|
+
if path.suffix.lower() in SOURCE_EXTENSIONS
|
|
154
|
+
and (selected is None or language_for(path) in selected)
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
if not candidates:
|
|
158
|
+
if args.json:
|
|
159
|
+
print(json.dumps([]))
|
|
160
|
+
else:
|
|
161
|
+
print(
|
|
162
|
+
f"No supported source files found in {args.directory}. Supported: Python, JavaScript, TypeScript, Java, Go."
|
|
163
|
+
)
|
|
164
|
+
return 4
|
|
165
|
+
|
|
166
|
+
# Analyze files with progress
|
|
167
|
+
results = _analyze_files_parallel(
|
|
168
|
+
candidates, args.depth, args.no_cache, args.threshold, args.plain
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if args.json:
|
|
172
|
+
print(json.dumps([r.to_dict() for r in results], indent=2, sort_keys=True))
|
|
173
|
+
else:
|
|
174
|
+
use_rich = not args.plain and _rich_ok()
|
|
175
|
+
if use_rich:
|
|
176
|
+
output = render_rich_scan(
|
|
177
|
+
results,
|
|
178
|
+
str(repo_root),
|
|
179
|
+
len(candidates),
|
|
180
|
+
args.threshold,
|
|
181
|
+
args.directory,
|
|
182
|
+
no_color=args.no_color,
|
|
183
|
+
)
|
|
184
|
+
print(output)
|
|
185
|
+
else:
|
|
186
|
+
if not results:
|
|
187
|
+
print(f"✓ No dead code found above {args.threshold}% threshold.")
|
|
188
|
+
return 4
|
|
189
|
+
print(f"fossil scan {args.directory} ({len(candidates)} files)")
|
|
190
|
+
print(f"{'File':<50} {'Language':<12} {'Confidence':>10} Status")
|
|
191
|
+
print("─" * 85)
|
|
192
|
+
for result in results:
|
|
193
|
+
score = result.confidence.score if result.confidence else 0
|
|
194
|
+
rel = Path(result.abs_path).relative_to(repo_root).as_posix()
|
|
195
|
+
print(f"{rel:<50} {result.language:<12} {score:>9}% {result.confidence.label}")
|
|
196
|
+
print(f"\n{len(results)} dead files found above {args.threshold}% threshold.")
|
|
197
|
+
|
|
198
|
+
return 0 if results else 4
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_clean(args: argparse.Namespace) -> int:
|
|
202
|
+
root = Path(args.directory).expanduser().resolve()
|
|
203
|
+
repo_root = find_repo_root(root)
|
|
204
|
+
|
|
205
|
+
# Read project config
|
|
206
|
+
project_config = read_project_config(repo_root)
|
|
207
|
+
exclude = []
|
|
208
|
+
if project_config.get("analysis", {}).get("exclude_patterns"):
|
|
209
|
+
exclude.extend(project_config["analysis"]["exclude_patterns"])
|
|
210
|
+
|
|
211
|
+
candidates = [
|
|
212
|
+
path for path in iter_repo_files(root, exclude) if path.suffix.lower() in SOURCE_EXTENSIONS
|
|
213
|
+
]
|
|
214
|
+
results = _analyze_files_parallel(
|
|
215
|
+
candidates, args.depth, args.no_cache, args.threshold, args.plain
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if args.json:
|
|
219
|
+
print(json.dumps([r.to_dict() for r in results], indent=2, sort_keys=True))
|
|
220
|
+
elif not results:
|
|
221
|
+
print(f"No deletion candidates found above {args.threshold}% threshold.")
|
|
222
|
+
return 4
|
|
223
|
+
else:
|
|
224
|
+
use_rich = not args.plain and _rich_ok()
|
|
225
|
+
if use_rich:
|
|
226
|
+
output = render_rich_clean(
|
|
227
|
+
results,
|
|
228
|
+
str(repo_root),
|
|
229
|
+
args.threshold,
|
|
230
|
+
args.directory,
|
|
231
|
+
dry_run=args.dry_run or not args.yolo,
|
|
232
|
+
no_color=args.no_color,
|
|
233
|
+
)
|
|
234
|
+
print(output)
|
|
235
|
+
else:
|
|
236
|
+
mode = "dry run" if args.dry_run or not args.yolo else "planned"
|
|
237
|
+
print(f"fossil clean {args.directory} — {mode}")
|
|
238
|
+
for index, result in enumerate(results, 1):
|
|
239
|
+
score = result.confidence.score if result.confidence else 0
|
|
240
|
+
rel = Path(result.abs_path).relative_to(repo_root).as_posix()
|
|
241
|
+
print(f"{index}. {rel} — {score}% — {result.suggested_action}")
|
|
242
|
+
|
|
243
|
+
if args.yolo:
|
|
244
|
+
print(
|
|
245
|
+
"--yolo PR generation requires GitHub/GitLab API integration; no files were changed.",
|
|
246
|
+
file=sys.stderr,
|
|
247
|
+
)
|
|
248
|
+
return 1
|
|
249
|
+
return 0 if results else 4
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _analyze_files_parallel(
|
|
253
|
+
candidates: list[Path],
|
|
254
|
+
depth: int,
|
|
255
|
+
no_cache: bool,
|
|
256
|
+
threshold: int,
|
|
257
|
+
plain: bool,
|
|
258
|
+
) -> list:
|
|
259
|
+
"""Analyze files using ThreadPoolExecutor with optional Rich progress bar."""
|
|
260
|
+
from fossil.engine import explain as explain_file
|
|
261
|
+
|
|
262
|
+
results = []
|
|
263
|
+
use_progress = not plain and _rich_ok() and len(candidates) > 3
|
|
264
|
+
|
|
265
|
+
if use_progress:
|
|
266
|
+
try:
|
|
267
|
+
from rich.progress import (
|
|
268
|
+
BarColumn,
|
|
269
|
+
MofNCompleteColumn,
|
|
270
|
+
Progress,
|
|
271
|
+
SpinnerColumn,
|
|
272
|
+
TextColumn,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
with Progress(
|
|
276
|
+
SpinnerColumn(),
|
|
277
|
+
TextColumn("[bold blue]Scanning..."),
|
|
278
|
+
BarColumn(),
|
|
279
|
+
MofNCompleteColumn(),
|
|
280
|
+
TextColumn("[dim]{task.description}"),
|
|
281
|
+
transient=True,
|
|
282
|
+
) as progress:
|
|
283
|
+
task = progress.add_task("", total=len(candidates))
|
|
284
|
+
worker_count = min(32, (os.cpu_count() or 1) + 4)
|
|
285
|
+
# Use parallel only if enough files
|
|
286
|
+
if len(candidates) >= 10:
|
|
287
|
+
with ThreadPoolExecutor(max_workers=worker_count) as pool:
|
|
288
|
+
futures = {
|
|
289
|
+
pool.submit(explain_file, str(p), depth=depth, no_cache=no_cache): p
|
|
290
|
+
for p in candidates
|
|
291
|
+
}
|
|
292
|
+
for future in as_completed(futures):
|
|
293
|
+
progress.advance(task)
|
|
294
|
+
try:
|
|
295
|
+
result = future.result()
|
|
296
|
+
if (
|
|
297
|
+
result.dead
|
|
298
|
+
and result.confidence
|
|
299
|
+
and result.confidence.score >= threshold
|
|
300
|
+
):
|
|
301
|
+
results.append(result)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
else:
|
|
305
|
+
for path in candidates:
|
|
306
|
+
progress.advance(task)
|
|
307
|
+
try:
|
|
308
|
+
result = explain_file(str(path), depth=depth, no_cache=no_cache)
|
|
309
|
+
if (
|
|
310
|
+
result.dead
|
|
311
|
+
and result.confidence
|
|
312
|
+
and result.confidence.score >= threshold
|
|
313
|
+
):
|
|
314
|
+
results.append(result)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
except ImportError:
|
|
318
|
+
use_progress = False
|
|
319
|
+
|
|
320
|
+
if not use_progress:
|
|
321
|
+
for path in candidates:
|
|
322
|
+
try:
|
|
323
|
+
result = explain_file(str(path), depth=depth, no_cache=no_cache)
|
|
324
|
+
if result.dead and result.confidence and result.confidence.score >= threshold:
|
|
325
|
+
results.append(result)
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
results.sort(key=lambda r: r.confidence.score if r.confidence else 0, reverse=True)
|
|
330
|
+
return results
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _language_filter(value: str) -> set[str] | None:
|
|
334
|
+
if value == "all":
|
|
335
|
+
return None
|
|
336
|
+
mapping = {"py": "python", "js": "javascript", "ts": "typescript", "java": "java", "go": "go"}
|
|
337
|
+
return {mapping.get(item.strip(), item.strip()) for item in value.split(",") if item.strip()}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _rich_ok() -> bool:
|
|
341
|
+
try:
|
|
342
|
+
from rich.console import Console # noqa: F401
|
|
343
|
+
|
|
344
|
+
return True
|
|
345
|
+
except ImportError:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# Cache & config commands
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def cmd_cache_clear(args: argparse.Namespace) -> int:
|
|
355
|
+
repo_root = find_repo_root(Path.cwd())
|
|
356
|
+
CacheStore(repo_root).clear()
|
|
357
|
+
print("Cache cleared.")
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cmd_cache_stats(args: argparse.Namespace) -> int:
|
|
362
|
+
repo_root = find_repo_root(Path.cwd())
|
|
363
|
+
stats = CacheStore(repo_root).stats()
|
|
364
|
+
print(f"Cache location: {repo_root / '.fossil' / 'cache.db'}")
|
|
365
|
+
print(f"Size: {stats['size_bytes'] / 1024:.1f} KB")
|
|
366
|
+
print(f"Analysis results cached: {stats['analysis_count']}")
|
|
367
|
+
print(f"Scan results cached: {stats['scan_count']}")
|
|
368
|
+
print(f"PR lookups cached: {stats['pr_count']}")
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def cmd_config_show(args: argparse.Namespace) -> int:
|
|
373
|
+
values = masked_config()
|
|
374
|
+
if not values:
|
|
375
|
+
print("No fossil config values set.")
|
|
376
|
+
return 0
|
|
377
|
+
for key, value in sorted(values.items()):
|
|
378
|
+
print(f"{key} = {value}")
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def cmd_config_set(args: argparse.Namespace) -> int:
|
|
383
|
+
set_config(args.key, args.value)
|
|
384
|
+
print(f"✓ {args.key} saved.")
|
|
385
|
+
return 0
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# Entry point & error handling
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def main(argv: list[str] | None = None) -> None:
|
|
394
|
+
parser = build_parser()
|
|
395
|
+
args = parser.parse_args(argv)
|
|
396
|
+
try:
|
|
397
|
+
code = args.func(args)
|
|
398
|
+
except FileMissingError as exc:
|
|
399
|
+
code = exc.exit_code
|
|
400
|
+
_print_error(args, "File not found", str(exc), code)
|
|
401
|
+
except NotGitRepositoryError as exc:
|
|
402
|
+
code = exc.exit_code
|
|
403
|
+
_print_error(args, "Not a git repository", str(exc), code)
|
|
404
|
+
except FossilError as exc:
|
|
405
|
+
code = exc.exit_code
|
|
406
|
+
_print_error(args, "fossil error", str(exc), code)
|
|
407
|
+
except Exception as exc: # pragma: no cover - defensive CLI boundary
|
|
408
|
+
code = 1
|
|
409
|
+
_print_error(args, "Unexpected error", str(exc), code)
|
|
410
|
+
raise SystemExit(code)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _print_error(args: argparse.Namespace, error: str, message: str, code: int) -> None:
|
|
414
|
+
if getattr(args, "json", False):
|
|
415
|
+
print(json.dumps({"error": error, "message": message, "code": code}, sort_keys=True))
|
|
416
|
+
else:
|
|
417
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
if __name__ == "__main__":
|
|
421
|
+
main()
|
fossil/config_manager.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Configuration management.
|
|
2
|
+
|
|
3
|
+
Implements §8 of the pre-development docs:
|
|
4
|
+
- User config at ~/.config/fossil/config.toml with 0600 permissions
|
|
5
|
+
- Project config at .fossil.toml (repo root, committed)
|
|
6
|
+
- Environment variable overrides
|
|
7
|
+
- Masked display of sensitive values
|
|
8
|
+
- TOML parsing via tomllib (Python 3.11+)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path.home() / ".config" / "fossil"
|
|
20
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
21
|
+
SENSITIVE = {"github_token", "gitlab_token", "llm_api_key"}
|
|
22
|
+
|
|
23
|
+
# Valid config keys (§8.2)
|
|
24
|
+
VALID_KEYS = {
|
|
25
|
+
"github_token",
|
|
26
|
+
"gitlab_token",
|
|
27
|
+
"llm_api_key",
|
|
28
|
+
"llm_provider",
|
|
29
|
+
"llm_model",
|
|
30
|
+
"llm_base_url",
|
|
31
|
+
"default_depth",
|
|
32
|
+
"cache_ttl_hours",
|
|
33
|
+
"output.color",
|
|
34
|
+
"output.theme",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Env var → config key mapping
|
|
38
|
+
ENV_OVERRIDES = {
|
|
39
|
+
"GITHUB_TOKEN": "github_token",
|
|
40
|
+
"GITLAB_TOKEN": "gitlab_token",
|
|
41
|
+
"FOSSIL_LLM_API_KEY": "llm_api_key",
|
|
42
|
+
"FOSSIL_LLM_PROVIDER": "llm_provider",
|
|
43
|
+
"FOSSIL_LLM_MODEL": "llm_model",
|
|
44
|
+
"FOSSIL_DEFAULT_DEPTH": "default_depth",
|
|
45
|
+
"FOSSIL_LOG_LEVEL": "log_level",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_config(key: str, value: str) -> None:
|
|
50
|
+
"""Write a key-value pair to the user config file."""
|
|
51
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
values = _read_raw_config()
|
|
53
|
+
values[key] = value
|
|
54
|
+
lines = [f'{k} = "{v}"\n' for k, v in sorted(values.items())]
|
|
55
|
+
CONFIG_PATH.write_text("".join(lines), encoding="utf-8")
|
|
56
|
+
os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_raw_config() -> dict[str, str]:
|
|
60
|
+
"""Read config file without env var overrides."""
|
|
61
|
+
if not CONFIG_PATH.exists():
|
|
62
|
+
return {}
|
|
63
|
+
data: dict[str, str] = {}
|
|
64
|
+
try:
|
|
65
|
+
with open(CONFIG_PATH, "rb") as f:
|
|
66
|
+
parsed = tomllib.load(f)
|
|
67
|
+
# Flatten nested TOML sections
|
|
68
|
+
for section_key, section_val in parsed.items():
|
|
69
|
+
if isinstance(section_val, dict):
|
|
70
|
+
for k, v in section_val.items():
|
|
71
|
+
data[f"{section_key}.{k}"] = str(v)
|
|
72
|
+
else:
|
|
73
|
+
data[section_key] = str(section_val)
|
|
74
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
75
|
+
# Fall back to simple key=value parsing for legacy configs
|
|
76
|
+
try:
|
|
77
|
+
for line in CONFIG_PATH.read_text(encoding="utf-8").splitlines():
|
|
78
|
+
if "=" not in line or line.strip().startswith("#"):
|
|
79
|
+
continue
|
|
80
|
+
key, value = line.split("=", 1)
|
|
81
|
+
data[key.strip()] = value.strip().strip('"')
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_config() -> dict[str, str]:
|
|
88
|
+
"""Read config with environment variable overrides."""
|
|
89
|
+
data = _read_raw_config()
|
|
90
|
+
for env, key in ENV_OVERRIDES.items():
|
|
91
|
+
if os.environ.get(env):
|
|
92
|
+
data[key] = os.environ[env]
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_project_config(repo_root: Path) -> dict[str, Any]:
|
|
97
|
+
"""Read .fossil.toml project-level configuration.
|
|
98
|
+
|
|
99
|
+
Returns a dictionary with sections: analysis, thresholds, pr.
|
|
100
|
+
"""
|
|
101
|
+
project_config_path = repo_root / ".fossil.toml"
|
|
102
|
+
if not project_config_path.exists():
|
|
103
|
+
return {}
|
|
104
|
+
try:
|
|
105
|
+
with open(project_config_path, "rb") as f:
|
|
106
|
+
return tomllib.load(f)
|
|
107
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_effective_config(repo_root: Path | None = None) -> dict[str, Any]:
|
|
112
|
+
"""Get merged config: user config → project config → env overrides.
|
|
113
|
+
|
|
114
|
+
Project config values override user config for matching keys.
|
|
115
|
+
Environment variables override everything.
|
|
116
|
+
"""
|
|
117
|
+
config: dict[str, Any] = dict(read_config())
|
|
118
|
+
if repo_root:
|
|
119
|
+
project = read_project_config(repo_root)
|
|
120
|
+
# Merge project config (flattened)
|
|
121
|
+
for section, values in project.items():
|
|
122
|
+
if isinstance(values, dict):
|
|
123
|
+
for k, v in values.items():
|
|
124
|
+
config[f"{section}.{k}"] = v
|
|
125
|
+
else:
|
|
126
|
+
config[section] = values
|
|
127
|
+
return config
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def masked_config() -> dict[str, str]:
|
|
131
|
+
return {
|
|
132
|
+
key: _mask(value) if key in SENSITIVE else value for key, value in read_config().items()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _mask(value: str) -> str:
|
|
137
|
+
if not value:
|
|
138
|
+
return ""
|
|
139
|
+
if len(value) <= 4:
|
|
140
|
+
return "****"
|
|
141
|
+
return f"{value[:4]}...{value[-4:]}"
|
fossil/engine.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from fossil import __version__
|
|
6
|
+
from fossil.analyzers import analyze_file, module_names
|
|
7
|
+
from fossil.cache import CacheStore
|
|
8
|
+
from fossil.git_miner import mine_history
|
|
9
|
+
from fossil.models import ForensicResult
|
|
10
|
+
from fossil.patterns import detect_patterns
|
|
11
|
+
from fossil.repo import git_head, is_gitignored, is_tracked, relpath, resolve_target
|
|
12
|
+
from fossil.scoring import score
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def explain(target: str, *, depth: int = 500, no_cache: bool = False) -> ForensicResult:
|
|
16
|
+
start = time.perf_counter()
|
|
17
|
+
path, repo_root, symlink = resolve_target(target)
|
|
18
|
+
head = git_head(repo_root)
|
|
19
|
+
cache = CacheStore(repo_root)
|
|
20
|
+
if not no_cache:
|
|
21
|
+
cached = cache.get_analysis(path, head, repo_root)
|
|
22
|
+
if cached:
|
|
23
|
+
return _from_dict(cached, cached=True)
|
|
24
|
+
|
|
25
|
+
static = analyze_file(path, repo_root)
|
|
26
|
+
tracked = is_tracked(path, repo_root)
|
|
27
|
+
refs = module_names(path, repo_root) | {path.stem}
|
|
28
|
+
git = mine_history(path, repo_root, depth, refs)
|
|
29
|
+
patterns = detect_patterns(path, repo_root)
|
|
30
|
+
warnings = list(git.warnings)
|
|
31
|
+
if symlink:
|
|
32
|
+
warnings.append(f"Target is a symlink; analyzed resolved path: {path}")
|
|
33
|
+
if is_gitignored(path, repo_root):
|
|
34
|
+
warnings.append("File is gitignored. Analysis may be incomplete.")
|
|
35
|
+
|
|
36
|
+
if not tracked:
|
|
37
|
+
confidence = None
|
|
38
|
+
dead = False
|
|
39
|
+
status = "UNTRACKED — No git history. Cannot determine death date."
|
|
40
|
+
else:
|
|
41
|
+
confidence = score(static, git, patterns)
|
|
42
|
+
dead = static.import_references == 0 and static.call_sites == 0
|
|
43
|
+
status = "DEAD" if dead else "LIVE"
|
|
44
|
+
|
|
45
|
+
rel = relpath(path, repo_root)
|
|
46
|
+
duration = int((time.perf_counter() - start) * 1000)
|
|
47
|
+
result = ForensicResult(
|
|
48
|
+
fossil_version=__version__,
|
|
49
|
+
target=target,
|
|
50
|
+
abs_path=str(path),
|
|
51
|
+
repo_root=str(repo_root),
|
|
52
|
+
language=static.language,
|
|
53
|
+
dead=dead,
|
|
54
|
+
status=status,
|
|
55
|
+
static_analysis=static,
|
|
56
|
+
git_history=git,
|
|
57
|
+
temporary_hold=patterns,
|
|
58
|
+
confidence=confidence,
|
|
59
|
+
suggested_action=f"rm {rel}" if dead else None,
|
|
60
|
+
yolo_command=f"fossil explain {rel} --yolo" if dead else None,
|
|
61
|
+
analysis_duration_ms=duration,
|
|
62
|
+
warnings=warnings,
|
|
63
|
+
)
|
|
64
|
+
if not no_cache:
|
|
65
|
+
cache.put_analysis(path, head, repo_root, __version__, result.to_dict())
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _from_dict(data: dict, cached: bool) -> ForensicResult:
|
|
70
|
+
from fossil.models import (
|
|
71
|
+
CommitInfo,
|
|
72
|
+
ConfidenceResult,
|
|
73
|
+
ConfidenceSignal,
|
|
74
|
+
GitHistoryResult,
|
|
75
|
+
HoldPattern,
|
|
76
|
+
PatternResult,
|
|
77
|
+
Reference,
|
|
78
|
+
StaticAnalysisResult,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
static_data = data["static_analysis"]
|
|
82
|
+
static = StaticAnalysisResult(
|
|
83
|
+
**{
|
|
84
|
+
**static_data,
|
|
85
|
+
"references": [Reference(**r) for r in static_data.get("references", [])],
|
|
86
|
+
"dynamic_references": [
|
|
87
|
+
Reference(**r) for r in static_data.get("dynamic_references", [])
|
|
88
|
+
],
|
|
89
|
+
"reflection_patterns": [
|
|
90
|
+
Reference(**r) for r in static_data.get("reflection_patterns", [])
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
git_data = data["git_history"]
|
|
95
|
+
for key in ("death_commit", "original_author", "last_modified"):
|
|
96
|
+
if git_data.get(key):
|
|
97
|
+
git_data[key] = CommitInfo(**git_data[key])
|
|
98
|
+
git = GitHistoryResult(**git_data)
|
|
99
|
+
pattern_data = data["temporary_hold"]
|
|
100
|
+
patterns = PatternResult(
|
|
101
|
+
detected=pattern_data.get("detected", False),
|
|
102
|
+
patterns=[HoldPattern(**p) for p in pattern_data.get("patterns", [])],
|
|
103
|
+
)
|
|
104
|
+
confidence = None
|
|
105
|
+
if data.get("confidence"):
|
|
106
|
+
c = data["confidence"]
|
|
107
|
+
confidence = ConfidenceResult(
|
|
108
|
+
score=c["score"],
|
|
109
|
+
label=c["label"],
|
|
110
|
+
risk=c["risk"],
|
|
111
|
+
signals=[ConfidenceSignal(**s) for s in c.get("signals", [])],
|
|
112
|
+
)
|
|
113
|
+
return ForensicResult(
|
|
114
|
+
**{
|
|
115
|
+
**data,
|
|
116
|
+
"static_analysis": static,
|
|
117
|
+
"git_history": git,
|
|
118
|
+
"temporary_hold": patterns,
|
|
119
|
+
"confidence": confidence,
|
|
120
|
+
"cached": cached,
|
|
121
|
+
}
|
|
122
|
+
)
|