codetruth 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.
Files changed (37) hide show
  1. codetruth/__init__.py +16 -0
  2. codetruth/api.py +102 -0
  3. codetruth/cli.py +183 -0
  4. codetruth/core/__init__.py +0 -0
  5. codetruth/core/cache.py +104 -0
  6. codetruth/core/config.py +74 -0
  7. codetruth/core/deletion.py +165 -0
  8. codetruth/core/evidence.py +306 -0
  9. codetruth/core/graph.py +44 -0
  10. codetruth/core/models.py +193 -0
  11. codetruth/core/plugin.py +88 -0
  12. codetruth/core/report.py +107 -0
  13. codetruth/core/scanner.py +228 -0
  14. codetruth/languages/__init__.py +0 -0
  15. codetruth/languages/go/__init__.py +1 -0
  16. codetruth/languages/javascript/__init__.py +8 -0
  17. codetruth/languages/javascript/edges.py +361 -0
  18. codetruth/languages/javascript/extractor.py +402 -0
  19. codetruth/languages/javascript/plugin.py +52 -0
  20. codetruth/languages/javascript/rules.py +201 -0
  21. codetruth/languages/python/__init__.py +0 -0
  22. codetruth/languages/python/edges.py +474 -0
  23. codetruth/languages/python/extractor.py +271 -0
  24. codetruth/languages/python/plugin.py +49 -0
  25. codetruth/languages/python/rules.py +332 -0
  26. codetruth/mcp_server.py +138 -0
  27. codetruth/rules/python/common_dynamic.yaml +70 -0
  28. codetruth/rules/python/django.yaml +57 -0
  29. codetruth/rules/python/fastapi.yaml +38 -0
  30. codetruth/rules/python/orm_web.yaml +50 -0
  31. codetruth/runtime/__init__.py +283 -0
  32. codetruth-0.2.0.dist-info/METADATA +215 -0
  33. codetruth-0.2.0.dist-info/RECORD +37 -0
  34. codetruth-0.2.0.dist-info/WHEEL +5 -0
  35. codetruth-0.2.0.dist-info/entry_points.txt +2 -0
  36. codetruth-0.2.0.dist-info/licenses/LICENSE +21 -0
  37. codetruth-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,138 @@
1
+ """The headline interface: CodeTruth as an MCP server.
2
+
3
+ Agent workflow:
4
+ 1. Agent identifies a symbol it wants to delete.
5
+ 2. Agent calls check_deletion_safety(repo_path, symbol).
6
+ 3. CodeTruth returns the evidence record.
7
+ 4. Agent only deletes on status == "safe_to_delete"; anything else routes
8
+ to human review or is left alone.
9
+
10
+ Register with Claude Code:
11
+ claude mcp add codetruth -- codetruth mcp
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Optional
17
+
18
+ from mcp.server.fastmcp import FastMCP
19
+
20
+ from .api import check_deletion_safety as _check
21
+ from .api import plan_deletion as _plan
22
+ from .api import scan as _scan
23
+ from .core.scanner import ScanResult
24
+
25
+ mcp = FastMCP(
26
+ "codetruth",
27
+ instructions=(
28
+ "Deletion-safety verification for code symbols. Before deleting any "
29
+ "symbol, call check_deletion_safety and only proceed when status is "
30
+ "'safe_to_delete'. 'likely_dead' and 'uncertain_dynamic_risk' require "
31
+ "human review. Detection is deterministic — do not override it with "
32
+ "your own judgment about whether code looks unused."),
33
+ )
34
+
35
+ _CACHE: dict[tuple, tuple[float, ScanResult]] = {}
36
+ _CACHE_TTL_SECONDS = 300.0
37
+
38
+
39
+ def _cached_scan(repo_path: str, treat_public_as_api: Optional[bool],
40
+ force_rescan: bool = False,
41
+ reachability: str = "default",
42
+ language: str = "python") -> ScanResult:
43
+ key = (repo_path, treat_public_as_api, reachability, language)
44
+ now = time.time()
45
+ hit = _CACHE.get(key)
46
+ if hit and not force_rescan and now - hit[0] < _CACHE_TTL_SECONDS:
47
+ return hit[1]
48
+ # force_rescan also busts the persistent on-disk cache, not just the
49
+ # in-memory TTL cache.
50
+ result = _scan(repo_path, language=language,
51
+ treat_public_as_api=treat_public_as_api,
52
+ use_cache=not force_rescan, reachability=reachability)
53
+ _CACHE[key] = (now, result)
54
+ return result
55
+
56
+
57
+ @mcp.tool()
58
+ def scan(repo_path: str, status: str = "", limit: int = 100,
59
+ treat_public_as_api: Optional[bool] = None,
60
+ force_rescan: bool = False, strict: bool = False,
61
+ language: str = "python") -> dict:
62
+ """Scan a repository and return deletion-safety evidence records.
63
+
64
+ Args:
65
+ repo_path: absolute path to the repository root.
66
+ status: optional filter — one of safe_to_delete, likely_dead,
67
+ uncertain_dynamic_risk, definitely_used. Empty = all candidates
68
+ (everything not proven used).
69
+ limit: max records returned.
70
+ treat_public_as_api: True for libraries (conservative). False only
71
+ for application repos nothing external imports. None (default)
72
+ defers to app_mode in the repo's .codetruth.toml, else True.
73
+ force_rescan: bypass the 5-minute scan cache.
74
+ strict: strict reachability — flag code that is internally connected
75
+ but not reachable from any real entry point (route, CLI command,
76
+ __main__, test, declared entrypoint). Orphaned clumps come back
77
+ grouped via each record's 'cluster' field.
78
+ language: 'python' (default) or 'javascript'/'typescript' (beta,
79
+ requires the codetruth[javascript] extra).
80
+ """
81
+ result = _cached_scan(repo_path, treat_public_as_api, force_rescan,
82
+ "strict" if strict else "default", language)
83
+ if status:
84
+ records = [r for r in result.records if r.status.value == status]
85
+ else:
86
+ records = result.candidates()
87
+ return {
88
+ "summary": result.summary(),
89
+ "records": [r.to_dict() for r in records[:limit]],
90
+ "truncated": len(records) > limit,
91
+ "note": "Only 'safe_to_delete' may be deleted without human review.",
92
+ }
93
+
94
+
95
+ @mcp.tool()
96
+ def check_deletion_safety(repo_path: str, symbol: str,
97
+ treat_public_as_api: Optional[bool] = None,
98
+ force_rescan: bool = False) -> dict:
99
+ """Check whether one symbol is safe to delete. Call this BEFORE deleting.
100
+
101
+ Args:
102
+ repo_path: absolute path to the repository root.
103
+ symbol: symbol id ('pkg.module:Qual.name'), dotted path, or bare name.
104
+ treat_public_as_api: True for libraries (default, conservative).
105
+ force_rescan: bypass the 5-minute scan cache.
106
+
107
+ Returns the evidence record: status, risk_level, recommended_action,
108
+ and the evidence for/against deletion. Only delete on 'safe_to_delete'.
109
+ """
110
+ result = _cached_scan(repo_path, treat_public_as_api, force_rescan)
111
+ return _check(repo_path, symbol, result=result)
112
+
113
+
114
+ @mcp.tool()
115
+ def plan_deletion(repo_path: str, symbol: str,
116
+ treat_public_as_api: Optional[bool] = None,
117
+ force_rescan: bool = False) -> dict:
118
+ """Advisory plan describing what removing a symbol would involve: the
119
+ exact source span, imports that would become orphaned, and any __all__
120
+ entry. CodeTruth NEVER applies the plan — it is information for the
121
+ human/agent who decides. Only act when status is 'safe_to_delete'.
122
+
123
+ Args:
124
+ repo_path: absolute path to the repository root.
125
+ symbol: symbol id ('pkg.module:Qual.name'), dotted path, or bare name.
126
+ treat_public_as_api: True for libraries (default, conservative).
127
+ force_rescan: bypass the scan caches.
128
+ """
129
+ result = _cached_scan(repo_path, treat_public_as_api, force_rescan)
130
+ return _plan(repo_path, symbol, result=result)
131
+
132
+
133
+ def main() -> None:
134
+ mcp.run()
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,70 @@
1
+ language: python
2
+ framework: common (celery / click / pytest / registries / event systems)
3
+ rules:
4
+ - id: celery-task-decorator
5
+ type: decorator
6
+ patterns:
7
+ - "*.task"
8
+ - "shared_task"
9
+ - "*.shared_task"
10
+ - "*.periodic_task"
11
+ effect: entrypoint
12
+ reason: "Celery task — invoked by name through the task queue"
13
+
14
+ - id: cli-command-decorator
15
+ type: decorator
16
+ patterns:
17
+ - "*.command"
18
+ - "*.group"
19
+ - "click.command"
20
+ - "click.group"
21
+ - "*.callback"
22
+ effect: entrypoint
23
+ reason: "CLI command decorator — invoked by the CLI framework at runtime"
24
+
25
+ - id: pytest-fixture-decorator
26
+ type: decorator
27
+ patterns:
28
+ - "fixture"
29
+ - "pytest.fixture"
30
+ - "*.fixture"
31
+ effect: entrypoint
32
+ reason: "pytest fixture — injected by name into tests"
33
+
34
+ - id: registry-decorator
35
+ type: decorator
36
+ patterns:
37
+ - "register"
38
+ - "*.register"
39
+ - "*.register_*"
40
+ effect: entrypoint
41
+ reason: "registry decorator — symbol is registered and looked up dynamically"
42
+
43
+ - id: event-listener-decorator
44
+ type: decorator
45
+ patterns:
46
+ - "*.listens_for"
47
+ - "*.event"
48
+ - "*.on"
49
+ - "*.subscribe"
50
+ - "*.subscriber"
51
+ - "*.hookimpl"
52
+ - "atexit.register"
53
+ effect: entrypoint
54
+ reason: "event/listener decorator — invoked by an event system"
55
+
56
+ - id: property-like-decorator
57
+ type: decorator
58
+ patterns:
59
+ - "property"
60
+ - "cached_property"
61
+ - "functools.cached_property"
62
+ - "*.setter"
63
+ - "*.getter"
64
+ - "*.deleter"
65
+ - "*.validator"
66
+ - "*.field_validator"
67
+ - "*.model_validator"
68
+ - "*.root_validator"
69
+ effect: entrypoint
70
+ reason: "property/validator descriptor — invoked implicitly via attribute access or model validation"
@@ -0,0 +1,57 @@
1
+ language: python
2
+ framework: django
3
+ rules:
4
+ - id: django-signal-receiver
5
+ type: decorator
6
+ patterns:
7
+ - "receiver"
8
+ - "*.receiver"
9
+ effect: entrypoint
10
+ reason: "Django signal receiver — connected and invoked via the signal system"
11
+
12
+ - id: django-admin-register
13
+ type: decorator
14
+ patterns:
15
+ - "admin.register"
16
+ - "*.admin.register"
17
+ - "register.filter"
18
+ - "register.simple_tag"
19
+ - "register.inclusion_tag"
20
+ - "register.tag"
21
+ effect: entrypoint
22
+ reason: "Django registration decorator — registered with a framework registry"
23
+
24
+ - id: django-settings-constants
25
+ type: name
26
+ name_regex: "^[A-Z][A-Z0-9_]*$"
27
+ path_glob: "*settings*.py"
28
+ symbol_type: variable
29
+ reason: "Django settings constant — consumed by the framework by name"
30
+
31
+ - id: django-management-command
32
+ type: name
33
+ name_regex: "^Command$"
34
+ path_glob: "*management/commands/*.py"
35
+ symbol_type: class
36
+ reason: "Django management command — discovered and invoked by manage.py"
37
+
38
+ - id: django-migration
39
+ type: name
40
+ name_regex: "^Migration$"
41
+ path_glob: "*migrations/*.py"
42
+ symbol_type: class
43
+ reason: "Django migration — discovered and applied by the migration framework"
44
+
45
+ - id: django-urlpatterns
46
+ type: name
47
+ name_regex: "^(urlpatterns|handler404|handler500|handler403|handler400)$"
48
+ path_glob: "*urls.py"
49
+ symbol_type: variable
50
+ reason: "Django URL configuration — consumed by the framework by name"
51
+
52
+ - id: wsgi-asgi-application
53
+ type: name
54
+ name_regex: "^(application|app)$"
55
+ path_glob: "*sgi.py"
56
+ symbol_type: variable
57
+ reason: "WSGI/ASGI application object — loaded by name by the app server"
@@ -0,0 +1,38 @@
1
+ language: python
2
+ framework: fastapi / flask / starlette
3
+ rules:
4
+ - id: http-route-decorator
5
+ type: decorator
6
+ patterns:
7
+ - "*.get"
8
+ - "*.post"
9
+ - "*.put"
10
+ - "*.delete"
11
+ - "*.patch"
12
+ - "*.options"
13
+ - "*.head"
14
+ - "*.trace"
15
+ - "*.route"
16
+ - "*.api_route"
17
+ - "*.websocket"
18
+ - "*.websocket_route"
19
+ effect: entrypoint
20
+ reason: "HTTP route decorator — handler is invoked by the web framework"
21
+
22
+ - id: web-lifecycle-decorator
23
+ type: decorator
24
+ patterns:
25
+ - "*.middleware"
26
+ - "*.exception_handler"
27
+ - "*.on_event"
28
+ - "*.errorhandler"
29
+ - "*.before_request"
30
+ - "*.after_request"
31
+ - "*.before_first_request"
32
+ - "*.teardown_request"
33
+ - "*.teardown_appcontext"
34
+ - "*.context_processor"
35
+ - "*.template_filter"
36
+ - "*.template_global"
37
+ effect: entrypoint
38
+ reason: "web framework lifecycle/handler decorator — invoked by the framework"
@@ -0,0 +1,50 @@
1
+ language: python
2
+ framework: sqlalchemy / starlette / typer / setuptools
3
+ rules:
4
+ - id: sqlalchemy-event-listener
5
+ type: decorator
6
+ patterns:
7
+ - "*.listens_for"
8
+ - "event.listens_for"
9
+ - "*.validates"
10
+ - "validates"
11
+ effect: entrypoint
12
+ reason: "SQLAlchemy event/validator hook — invoked by the ORM, not called directly"
13
+
14
+ - id: sqlalchemy-declarative-hook
15
+ type: decorator
16
+ patterns:
17
+ - "declared_attr"
18
+ - "*.declared_attr"
19
+ - "hybrid_property"
20
+ - "*.hybrid_property"
21
+ - "hybrid_method"
22
+ - "*.expression"
23
+ - "*.inplace"
24
+ effect: entrypoint
25
+ reason: "SQLAlchemy declarative/hybrid attribute — resolved by the ORM at mapping time"
26
+
27
+ - id: typer-command
28
+ type: decorator
29
+ patterns:
30
+ - "*.command"
31
+ - "*.callback"
32
+ effect: entrypoint
33
+ reason: "Typer command/callback — invoked by the CLI framework"
34
+
35
+ - id: starlette-route-decorator
36
+ type: decorator
37
+ patterns:
38
+ - "*.route"
39
+ - "*.websocket_route"
40
+ - "*.on_event"
41
+ - "*.add_event_handler"
42
+ effect: entrypoint
43
+ reason: "Starlette route/lifecycle handler — invoked by the ASGI framework"
44
+
45
+ - id: setuptools-entrypoint-name
46
+ type: name
47
+ name_regex: "^main$"
48
+ path_glob: "*/__main__.py"
49
+ symbol_type: function
50
+ reason: "package __main__ entry point — run via `python -m package`"
@@ -0,0 +1,283 @@
1
+ """Phase 6 — runtime usage evidence.
2
+
3
+ `@codetruth.track` records real invocations of a function in a running app.
4
+ "Zero calls observed over N days" is the strongest evidence for deletion —
5
+ and the only answer to cross-service usage that static analysis can't see.
6
+
7
+ Production-hardened:
8
+ - **Multiprocess-safe**: each process appends to its own file
9
+ (`runtime-<pid>.jsonl` next to the configured path); readers merge every
10
+ sibling, so concurrent workers never contend or corrupt a shared log.
11
+ - **Interval flushing**: a daemon thread flushes counts every
12
+ $CODETRUTH_FLUSH_INTERVAL seconds (default 60) — long-running servers
13
+ don't have to exit cleanly for evidence to land.
14
+ - **Auto-instrumentation**: `instrument_package("pkg")` wraps every function
15
+ and method of a package (already-imported modules immediately, future
16
+ imports via an import hook) — no source edits needed.
17
+
18
+ Log location: $CODETRUTH_RUNTIME_LOG (default .codetruth/runtime.jsonl in
19
+ the CWD); the per-process suffix is added automatically.
20
+ Entries:
21
+ {"event": "register", "symbol": "pkg.mod:func", "ts": 1710000000.0}
22
+ {"event": "calls", "symbol": "pkg.mod:func", "count": 17, "ts": ...}
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import atexit
27
+ import functools
28
+ import importlib.util
29
+ import json
30
+ import os
31
+ import sys
32
+ import threading
33
+ import time
34
+ import types
35
+ from collections import Counter
36
+ from pathlib import Path
37
+
38
+ from ..core.models import Marker, MarkerKind
39
+
40
+ _lock = threading.Lock()
41
+ _counts: Counter = Counter()
42
+ _registered: set[str] = set()
43
+ _flusher_installed = False
44
+
45
+
46
+ def _base_log_path() -> Path:
47
+ return Path(os.environ.get("CODETRUTH_RUNTIME_LOG",
48
+ ".codetruth/runtime.jsonl"))
49
+
50
+
51
+ def _log_path() -> Path:
52
+ """Per-process log file: '<stem>-<pid><suffix>' beside the base path."""
53
+ base = _base_log_path()
54
+ return base.with_name(f"{base.stem}-{os.getpid()}{base.suffix}")
55
+
56
+
57
+ def _append(entry: dict) -> None:
58
+ path = _log_path()
59
+ try:
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ with path.open("a", encoding="utf-8") as fh:
62
+ fh.write(json.dumps(entry) + "\n")
63
+ except OSError:
64
+ pass # tracing must never take the application down
65
+
66
+
67
+ def _flush() -> None:
68
+ with _lock:
69
+ counts = dict(_counts)
70
+ _counts.clear()
71
+ if not counts:
72
+ return
73
+ ts = time.time()
74
+ for symbol, count in counts.items():
75
+ _append({"event": "calls", "symbol": symbol, "count": count, "ts": ts})
76
+
77
+
78
+ def _flush_loop(interval: float) -> None:
79
+ while True:
80
+ time.sleep(interval)
81
+ _flush()
82
+
83
+
84
+ def _install_flusher() -> None:
85
+ global _flusher_installed
86
+ if _flusher_installed:
87
+ return
88
+ _flusher_installed = True
89
+ atexit.register(_flush)
90
+ interval = float(os.environ.get("CODETRUTH_FLUSH_INTERVAL", "60"))
91
+ if interval > 0:
92
+ threading.Thread(target=_flush_loop, args=(interval,),
93
+ daemon=True, name="codetruth-flush").start()
94
+
95
+
96
+ def track(fn=None, *, name: str | None = None):
97
+ """Decorator: register the symbol at import, count real invocations.
98
+
99
+ Usage: @codetruth.track or @codetruth.track(name="pkg.mod:func")
100
+ """
101
+ def decorate(func):
102
+ if getattr(func, "_codetruth_tracked", False):
103
+ return func
104
+ symbol = name or f"{func.__module__}:{func.__qualname__}"
105
+ with _lock:
106
+ if symbol not in _registered:
107
+ _registered.add(symbol)
108
+ _append({"event": "register", "symbol": symbol,
109
+ "ts": time.time()})
110
+ _install_flusher()
111
+
112
+ @functools.wraps(func)
113
+ def wrapper(*args, **kwargs):
114
+ with _lock:
115
+ _counts[symbol] += 1
116
+ total = sum(_counts.values())
117
+ if total >= 1000: # bound memory between flushes
118
+ _flush()
119
+ return func(*args, **kwargs)
120
+
121
+ wrapper._codetruth_tracked = True
122
+ return wrapper
123
+
124
+ return decorate(fn) if fn is not None else decorate
125
+
126
+
127
+ # --- auto-instrumentation ----------------------------------------------------
128
+
129
+ def _instrument_module(mod: types.ModuleType) -> int:
130
+ """Wrap every function and public method defined in this module."""
131
+ wrapped = 0
132
+ for attr, obj in list(vars(mod).items()):
133
+ if isinstance(obj, types.FunctionType) \
134
+ and obj.__module__ == mod.__name__ \
135
+ and not getattr(obj, "_codetruth_tracked", False):
136
+ setattr(mod, attr, track(obj))
137
+ wrapped += 1
138
+ elif isinstance(obj, type) and obj.__module__ == mod.__name__:
139
+ for mname, meth in list(vars(obj).items()):
140
+ if isinstance(meth, types.FunctionType) \
141
+ and not mname.startswith("__") \
142
+ and not getattr(meth, "_codetruth_tracked", False):
143
+ setattr(obj, mname, track(meth))
144
+ wrapped += 1
145
+ return wrapped
146
+
147
+
148
+ class _AutoTrackLoader:
149
+ def __init__(self, loader):
150
+ self._loader = loader
151
+
152
+ def __getattr__(self, item):
153
+ return getattr(self._loader, item)
154
+
155
+ def create_module(self, spec):
156
+ create = getattr(self._loader, "create_module", None)
157
+ return create(spec) if create else None
158
+
159
+ def exec_module(self, module):
160
+ self._loader.exec_module(module)
161
+ _instrument_module(module)
162
+
163
+
164
+ class _AutoTrackFinder:
165
+ """Meta-path finder that instruments configured packages on import."""
166
+
167
+ def __init__(self):
168
+ self.packages: set[str] = set()
169
+ self._resolving = threading.local()
170
+
171
+ def _matches(self, fullname: str) -> bool:
172
+ return any(fullname == p or fullname.startswith(p + ".")
173
+ for p in self.packages)
174
+
175
+ def find_spec(self, fullname, path=None, target=None):
176
+ if getattr(self._resolving, "active", False) or not self._matches(fullname):
177
+ return None
178
+ self._resolving.active = True
179
+ try:
180
+ spec = importlib.util.find_spec(fullname)
181
+ except (ImportError, ValueError):
182
+ return None
183
+ finally:
184
+ self._resolving.active = False
185
+ if spec and spec.loader:
186
+ spec.loader = _AutoTrackLoader(spec.loader)
187
+ return spec
188
+
189
+
190
+ _FINDER = _AutoTrackFinder()
191
+
192
+
193
+ def instrument_package(package_name: str) -> int:
194
+ """Auto-track every function/method of a package without source edits.
195
+
196
+ Instruments modules already imported and installs an import hook for the
197
+ rest. Returns the number of callables wrapped so far.
198
+ """
199
+ if _FINDER not in sys.meta_path:
200
+ sys.meta_path.insert(0, _FINDER)
201
+ _FINDER.packages.add(package_name)
202
+ wrapped = 0
203
+ for mod_name, mod in list(sys.modules.items()):
204
+ if mod is not None and (mod_name == package_name
205
+ or mod_name.startswith(package_name + ".")):
206
+ wrapped += _instrument_module(mod)
207
+ _install_flusher()
208
+ return wrapped
209
+
210
+
211
+ def autotrack() -> int:
212
+ """Instrument every package named in $CODETRUTH_AUTOTRACK (comma-sep)."""
213
+ wrapped = 0
214
+ for pkg in os.environ.get("CODETRUTH_AUTOTRACK", "").split(","):
215
+ if pkg.strip():
216
+ wrapped += instrument_package(pkg.strip())
217
+ return wrapped
218
+
219
+
220
+ # --- reading logs back -------------------------------------------------------
221
+
222
+ def _log_files(path: str | Path) -> list[Path]:
223
+ """The given file plus every per-process sibling ('<stem>-*<suffix>')."""
224
+ p = Path(path)
225
+ files = [p] if p.is_file() else []
226
+ if p.parent.is_dir():
227
+ files += sorted(f for f in p.parent.glob(f"{p.stem}-*{p.suffix}")
228
+ if f.is_file())
229
+ return files
230
+
231
+
232
+ def load_runtime_log(path: str | Path) -> dict[str, dict]:
233
+ """Aggregate (merging per-process files): symbol -> {calls,
234
+ registered_ts, last_ts}."""
235
+ out: dict[str, dict] = {}
236
+ for file in _log_files(path):
237
+ try:
238
+ text = file.read_text(encoding="utf-8")
239
+ except OSError:
240
+ continue
241
+ for line in text.splitlines():
242
+ line = line.strip()
243
+ if not line:
244
+ continue
245
+ try:
246
+ entry = json.loads(line)
247
+ except json.JSONDecodeError:
248
+ continue
249
+ sym = entry.get("symbol")
250
+ if not sym:
251
+ continue
252
+ rec = out.setdefault(sym, {"calls": 0, "registered_ts": None,
253
+ "last_ts": None})
254
+ if entry.get("event") == "register":
255
+ ts = entry.get("ts")
256
+ if rec["registered_ts"] is None or (ts and ts < rec["registered_ts"]):
257
+ rec["registered_ts"] = ts
258
+ elif entry.get("event") == "calls":
259
+ rec["calls"] += int(entry.get("count", 0))
260
+ rec["last_ts"] = entry.get("ts") or rec["last_ts"]
261
+ return out
262
+
263
+
264
+ def load_runtime_markers(path: str | Path,
265
+ known_symbols: set[str]) -> list[Marker]:
266
+ """Convert runtime logs into evidence markers for the classifier."""
267
+ markers: list[Marker] = []
268
+ now = time.time()
269
+ for symbol, rec in load_runtime_log(path).items():
270
+ if symbol not in known_symbols:
271
+ continue
272
+ if rec["calls"] > 0:
273
+ markers.append(Marker(
274
+ symbol, MarkerKind.RUNTIME_USED,
275
+ f"observed {rec['calls']} runtime call(s) in production "
276
+ "tracing — definitely used", rule="runtime-tracking"))
277
+ elif rec["registered_ts"]:
278
+ days = max(0.0, (now - rec["registered_ts"]) / 86400)
279
+ markers.append(Marker(
280
+ symbol, MarkerKind.RUNTIME_ZERO,
281
+ f"0 runtime calls observed over {days:.1f} day(s) of "
282
+ "production tracing", rule="runtime-tracking"))
283
+ return markers