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/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()
@@ -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
+ )