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.
- codetruth/__init__.py +16 -0
- codetruth/api.py +102 -0
- codetruth/cli.py +183 -0
- codetruth/core/__init__.py +0 -0
- codetruth/core/cache.py +104 -0
- codetruth/core/config.py +74 -0
- codetruth/core/deletion.py +165 -0
- codetruth/core/evidence.py +306 -0
- codetruth/core/graph.py +44 -0
- codetruth/core/models.py +193 -0
- codetruth/core/plugin.py +88 -0
- codetruth/core/report.py +107 -0
- codetruth/core/scanner.py +228 -0
- codetruth/languages/__init__.py +0 -0
- codetruth/languages/go/__init__.py +1 -0
- codetruth/languages/javascript/__init__.py +8 -0
- codetruth/languages/javascript/edges.py +361 -0
- codetruth/languages/javascript/extractor.py +402 -0
- codetruth/languages/javascript/plugin.py +52 -0
- codetruth/languages/javascript/rules.py +201 -0
- codetruth/languages/python/__init__.py +0 -0
- codetruth/languages/python/edges.py +474 -0
- codetruth/languages/python/extractor.py +271 -0
- codetruth/languages/python/plugin.py +49 -0
- codetruth/languages/python/rules.py +332 -0
- codetruth/mcp_server.py +138 -0
- codetruth/rules/python/common_dynamic.yaml +70 -0
- codetruth/rules/python/django.yaml +57 -0
- codetruth/rules/python/fastapi.yaml +38 -0
- codetruth/rules/python/orm_web.yaml +50 -0
- codetruth/runtime/__init__.py +283 -0
- codetruth-0.2.0.dist-info/METADATA +215 -0
- codetruth-0.2.0.dist-info/RECORD +37 -0
- codetruth-0.2.0.dist-info/WHEEL +5 -0
- codetruth-0.2.0.dist-info/entry_points.txt +2 -0
- codetruth-0.2.0.dist-info/licenses/LICENSE +21 -0
- codetruth-0.2.0.dist-info/top_level.txt +1 -0
codetruth/mcp_server.py
ADDED
|
@@ -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
|