deadtrace 0.1.0a1__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.
deadtrace/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Deadtrace public package metadata."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("deadtrace")
7
+ except PackageNotFoundError: # pragma: no cover - source-tree fallback
8
+ __version__ = "0.1.0a1"
9
+
10
+ __all__ = ["__version__"]
deadtrace/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support ``python -m deadtrace``."""
2
+
3
+ from deadtrace.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
deadtrace/analysis.py ADDED
@@ -0,0 +1,223 @@
1
+ """End-to-end static analysis orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import asdict, dataclass, field, replace
8
+ from hashlib import sha256
9
+ from pathlib import Path
10
+ from time import perf_counter
11
+
12
+ from deadtrace.config import Config
13
+ from deadtrace.core import AnalysisSnapshot, AssemblyState, Limitation, solve
14
+ from deadtrace.deployment import read_deployment_references
15
+ from deadtrace.entry_points import read_project_entry_points
16
+ from deadtrace.findings import Finding, build_findings
17
+ from deadtrace.frameworks import FrameworkModel, build_framework_model
18
+ from deadtrace.model import InventoryReport
19
+ from deadtrace.pytest_semantics import apply_pytest_model, read_pytest_collection
20
+ from deadtrace.python_frontend import PythonProgram, build_python_program
21
+ from deadtrace.scanner import (
22
+ SourceCollection,
23
+ collect_sources,
24
+ inventory_collection,
25
+ parse_collection,
26
+ )
27
+ from deadtrace.target_environment import (
28
+ UNTESTED_VERSION,
29
+ TargetEnvironment,
30
+ read_target_environment,
31
+ )
32
+ from deadtrace.timing import StageTimings
33
+
34
+ MODEL_REVISION = "python-fastapi-dishka/31"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class AnalysisMetrics:
39
+ files: int
40
+ lines: int
41
+ nodes: int
42
+ edges: int
43
+ worlds: int
44
+ collect_seconds: float
45
+ frontend_seconds: float
46
+ solve_seconds: float
47
+ total_seconds: float
48
+ stage_seconds: Mapping[str, float] = field(default_factory=dict)
49
+ counters: Mapping[str, int] = field(default_factory=dict)
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class AnalysisResult:
54
+ collection: SourceCollection
55
+ inventory: InventoryReport
56
+ program: PythonProgram
57
+ model: FrameworkModel
58
+ snapshot: AnalysisSnapshot
59
+ findings: tuple[Finding, ...]
60
+ source_digest: str
61
+ config_digest: str
62
+ model_revision: str
63
+ target_environment: TargetEnvironment
64
+ metrics: AnalysisMetrics
65
+
66
+ @property
67
+ def complete(self) -> bool:
68
+ return bool(self.snapshot.worlds) and all(
69
+ world.assembly_state is AssemblyState.COMPLETE and not world.exhausted_budget
70
+ for world in self.snapshot.worlds
71
+ )
72
+
73
+ @property
74
+ def has_operational_errors(self) -> bool:
75
+ return self.inventory.has_errors or any(
76
+ world.assembly_state is AssemblyState.INVALID for world in self.snapshot.worlds
77
+ )
78
+
79
+
80
+ def analyze(scan_path: Path, config: Config) -> AnalysisResult:
81
+ """Run one immutable, read-only analysis snapshot."""
82
+
83
+ timings = StageTimings()
84
+ started = perf_counter()
85
+ collection = collect_sources(scan_path, timings=timings, exclude=config.exclude)
86
+ parsed = parse_collection(collection, timings=timings)
87
+ inventory = inventory_collection(collection, config, timings=timings, parsed=parsed)
88
+ collected_at = perf_counter()
89
+ program = build_python_program(
90
+ collection, report_exclude=config.report_exclude, timings=timings, parsed=parsed
91
+ )
92
+ with timings.stage("frontend.entry_points"):
93
+ entry_points, entry_point_issues = read_project_entry_points(collection.root)
94
+ with timings.stage("frontend.deployment"):
95
+ deployment = (
96
+ () if config.worlds else read_deployment_references(collection.root, config.exclude)
97
+ )
98
+ model = build_framework_model(
99
+ program,
100
+ config,
101
+ entry_points=entry_points,
102
+ entry_point_issues=entry_point_issues,
103
+ deployment=deployment,
104
+ timings=timings,
105
+ )
106
+ with timings.stage("frontend.pytest"):
107
+ model = apply_pytest_model(
108
+ program,
109
+ model,
110
+ config,
111
+ plugin_modules=tuple(
112
+ entry_point.target.partition(":")[0]
113
+ for entry_point in entry_points
114
+ if entry_point.group == "pytest11"
115
+ ),
116
+ collection=read_pytest_collection(collection.root),
117
+ )
118
+ with timings.stage("frontend.target_environment"):
119
+ target_environment = read_target_environment(
120
+ collection.root,
121
+ {
122
+ binding.target.split(".", 1)[0]
123
+ for module in program.modules.values()
124
+ for binding in module.imports.values()
125
+ },
126
+ )
127
+ if target_environment.issues:
128
+ model.plans = tuple(
129
+ replace(
130
+ plan,
131
+ assembly_state=(
132
+ plan.assembly_state
133
+ if plan.assembly_state is AssemblyState.INVALID
134
+ or all(issue.code == UNTESTED_VERSION for issue in target_environment.issues)
135
+ else AssemblyState.PARTIAL
136
+ ),
137
+ limitations=tuple(
138
+ (
139
+ *plan.limitations,
140
+ *(
141
+ Limitation(
142
+ code=issue.code,
143
+ message=issue.message,
144
+ world=plan.id,
145
+ )
146
+ for issue in target_environment.issues
147
+ ),
148
+ )
149
+ ),
150
+ )
151
+ for plan in model.plans
152
+ )
153
+ if inventory.issues:
154
+ model.plans = tuple(
155
+ replace(
156
+ plan,
157
+ assembly_state=(
158
+ AssemblyState.INVALID
159
+ if plan.assembly_state is AssemblyState.INVALID
160
+ else AssemblyState.PARTIAL
161
+ ),
162
+ limitations=tuple(
163
+ (
164
+ *plan.limitations,
165
+ *(
166
+ Limitation(
167
+ code=issue.code,
168
+ message=f"{issue.path}: {issue.message}",
169
+ world=plan.id,
170
+ )
171
+ for issue in inventory.issues
172
+ ),
173
+ )
174
+ ),
175
+ )
176
+ for plan in model.plans
177
+ )
178
+ frontend_at = perf_counter()
179
+ with timings.stage("solve.reachability"):
180
+ snapshot = solve(model.graph, model.plans, max_steps=config.max_steps)
181
+ with timings.stage("solve.findings"):
182
+ findings = build_findings(program, model, snapshot)
183
+ solved_at = perf_counter()
184
+ timings.count("findings", len(findings))
185
+ metrics = AnalysisMetrics(
186
+ files=len(collection.files),
187
+ lines=sum(unit.source.count("\n") + 1 for unit in collection.units),
188
+ nodes=len(model.graph.nodes),
189
+ edges=len(model.graph.edges),
190
+ worlds=len(snapshot.worlds),
191
+ collect_seconds=collected_at - started,
192
+ frontend_seconds=frontend_at - collected_at,
193
+ solve_seconds=solved_at - frontend_at,
194
+ total_seconds=solved_at - started,
195
+ stage_seconds=timings.frozen_seconds(),
196
+ counters=timings.frozen_counts(),
197
+ )
198
+ return AnalysisResult(
199
+ collection=collection,
200
+ inventory=inventory,
201
+ program=program,
202
+ model=model,
203
+ snapshot=snapshot,
204
+ findings=findings,
205
+ source_digest=_source_digest(collection),
206
+ config_digest=_config_digest(config),
207
+ model_revision=MODEL_REVISION,
208
+ target_environment=target_environment,
209
+ metrics=metrics,
210
+ )
211
+
212
+
213
+ def _source_digest(collection: SourceCollection) -> str:
214
+ canonical = "\n".join(f"{unit.path}:{unit.digest}" for unit in collection.units)
215
+ return sha256(canonical.encode()).hexdigest()
216
+
217
+
218
+ def _config_digest(config: Config) -> str:
219
+ fields = asdict(config)
220
+ if not fields["exclude"]:
221
+ del fields["exclude"] # configurations without it keep their earlier digest
222
+ canonical = json.dumps(fields, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
223
+ return sha256(canonical.encode()).hexdigest()
deadtrace/artifacts.py ADDED
@@ -0,0 +1,87 @@
1
+ """Safe helpers for user-selected JSON artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ from typing import Any, NoReturn
9
+
10
+ MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
11
+ """Largest project input, such as ``pyproject.toml`` or ``uv.lock``, that analysis reads."""
12
+ MAX_REPORT_BYTES = 256 * 1024 * 1024
13
+ """Largest Deadtrace report or baseline read back; a 624k-line monorepo writes about 113 MB."""
14
+
15
+
16
+ class ArtifactError(ValueError):
17
+ """Raised when a report-like artifact is missing, oversized, or malformed."""
18
+
19
+
20
+ class InputTooLargeError(ValueError):
21
+ """An opened input exceeded its reader's byte limit."""
22
+
23
+
24
+ def read_bounded_bytes(path: Path, *, limit: int) -> bytes:
25
+ """Read at most ``limit + 1`` bytes, even if file size metadata is stale.
26
+
27
+ Small chunks avoid reserving the full report allowance for a small file. The extra byte
28
+ distinguishes an input exactly at the limit from one that is too large.
29
+ """
30
+
31
+ if limit < 0:
32
+ raise ValueError("byte limit must be nonnegative")
33
+ chunks: list[bytes] = []
34
+ size = 0
35
+ with path.open("rb") as stream:
36
+ while chunk := stream.read(min(64 * 1024, limit + 1 - size)):
37
+ size += len(chunk)
38
+ if size > limit:
39
+ raise InputTooLargeError(f"input {path} is too large; limit is {limit} bytes")
40
+ chunks.append(chunk)
41
+ return b"".join(chunks)
42
+
43
+
44
+ def read_json_artifact(path: Path) -> dict[str, Any]:
45
+ """Read a bounded JSON object without executing project code."""
46
+
47
+ try:
48
+ source = read_bounded_bytes(path, limit=MAX_REPORT_BYTES).decode("utf-8")
49
+ payload = json.loads(
50
+ source,
51
+ object_pairs_hook=_unique_object,
52
+ parse_constant=_reject_constant,
53
+ parse_float=_finite_float,
54
+ )
55
+ except ArtifactError:
56
+ raise
57
+ except (OSError, UnicodeError, ValueError, RecursionError) as error:
58
+ raise ArtifactError(f"cannot read artifact {path}: {error}") from error
59
+ if not isinstance(payload, dict):
60
+ raise ArtifactError(f"artifact {path} must contain a JSON object")
61
+ return payload
62
+
63
+
64
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
65
+ result: dict[str, Any] = {}
66
+ for key, value in pairs:
67
+ if key in result:
68
+ raise ArtifactError(f"duplicate JSON key: {key!r}")
69
+ result[key] = value
70
+ return result
71
+
72
+
73
+ def _reject_constant(value: str) -> NoReturn:
74
+ raise ArtifactError(f"non-finite JSON number: {value}")
75
+
76
+
77
+ def _finite_float(value: str) -> float:
78
+ number = float(value)
79
+ if not math.isfinite(number):
80
+ raise ArtifactError(f"non-finite JSON number: {value}")
81
+ return number
82
+
83
+
84
+ def render_json_artifact(payload: dict[str, Any]) -> str:
85
+ """Render deterministic UTF-8 JSON text."""
86
+
87
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
deadtrace/baseline.py ADDED
@@ -0,0 +1,199 @@
1
+ """Baseline existing review findings without changing semantic analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from deadtrace.artifacts import ArtifactError, render_json_artifact
10
+ from deadtrace.comparison import (
11
+ method_change_reasons,
12
+ method_descriptor,
13
+ validate_semantic_report,
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class BaselineApplication:
19
+ payload: dict[str, Any]
20
+ comparable: bool
21
+ accepted: int
22
+ new: int
23
+ stale: int
24
+ reasons: tuple[str, ...] = ()
25
+
26
+
27
+ def create_baseline(report: dict[str, Any], reason: str) -> dict[str, Any]:
28
+ """Create an explicit debt ledger from one reviewed semantic report."""
29
+
30
+ validate_semantic_report(report)
31
+ if not reason.strip():
32
+ raise ArtifactError("baseline reason must not be empty")
33
+ findings = report["findings"]
34
+ assert isinstance(findings, list)
35
+ return {
36
+ "schema_version": 1,
37
+ "kind": "deadtrace-baseline",
38
+ "method": method_descriptor(report),
39
+ "source_digest": report["descriptor"].get("source_digest"),
40
+ "entries": [
41
+ {
42
+ "fingerprint": finding["fingerprint"],
43
+ "code": finding.get("code"),
44
+ "members": finding.get("members", []),
45
+ "reason": reason,
46
+ }
47
+ for finding in findings
48
+ if isinstance(finding, dict)
49
+ ],
50
+ }
51
+
52
+
53
+ def update_baseline(
54
+ baseline: dict[str, Any],
55
+ report: dict[str, Any],
56
+ *,
57
+ accept_new: bool = False,
58
+ reason: str | None = None,
59
+ ) -> dict[str, Any]:
60
+ """Rebase reviewed entries; accepting new findings always requires an explicit reason.
61
+
62
+ An entry carries over to a finding with its fingerprint, or with its code and exactly its
63
+ members when a method change renewed the fingerprint.
64
+ """
65
+
66
+ validate_baseline(baseline)
67
+ validate_semantic_report(report)
68
+ if accept_new and (reason is None or not reason.strip()):
69
+ raise ArtifactError("accepting new baseline findings requires a non-empty reason")
70
+ old_entries = baseline["entries"]
71
+ findings = report["findings"]
72
+ assert isinstance(old_entries, list)
73
+ assert isinstance(findings, list)
74
+ by_fingerprint = {
75
+ entry["fingerprint"]: entry for entry in old_entries if isinstance(entry, dict)
76
+ }
77
+ by_content = {_content_key(entry): entry for entry in old_entries if isinstance(entry, dict)}
78
+ entries: list[dict[str, Any]] = []
79
+ for finding in findings:
80
+ assert isinstance(finding, dict)
81
+ fingerprint = finding["fingerprint"]
82
+ old = by_fingerprint.get(fingerprint) or by_content.get(_content_key(finding))
83
+ if old is not None:
84
+ entries.append(
85
+ {
86
+ **deepcopy(old),
87
+ "fingerprint": fingerprint,
88
+ "members": deepcopy(finding.get("members", [])),
89
+ }
90
+ )
91
+ elif accept_new:
92
+ entries.append(
93
+ {
94
+ "fingerprint": fingerprint,
95
+ "code": finding.get("code"),
96
+ "members": finding.get("members", []),
97
+ "reason": reason,
98
+ }
99
+ )
100
+ return {
101
+ "schema_version": 1,
102
+ "kind": "deadtrace-baseline",
103
+ "method": method_descriptor(report),
104
+ "source_digest": report["descriptor"].get("source_digest"),
105
+ "entries": entries,
106
+ }
107
+
108
+
109
+ def validate_baseline(payload: dict[str, Any]) -> None:
110
+ if payload.get("schema_version") != 1 or payload.get("kind") != "deadtrace-baseline":
111
+ raise ArtifactError("baseline must use deadtrace-baseline schema 1")
112
+ if not isinstance(payload.get("method"), dict):
113
+ raise ArtifactError("baseline method must be an object")
114
+ entries = payload.get("entries")
115
+ if not isinstance(entries, list):
116
+ raise ArtifactError("baseline entries must be an array")
117
+ fingerprints: set[str] = set()
118
+ for entry in entries:
119
+ if not isinstance(entry, dict) or not isinstance(entry.get("fingerprint"), str):
120
+ raise ArtifactError("every baseline entry must have a string fingerprint")
121
+ fingerprint = entry["fingerprint"]
122
+ if fingerprint in fingerprints:
123
+ raise ArtifactError(f"duplicate baseline fingerprint: {fingerprint}")
124
+ fingerprints.add(fingerprint)
125
+
126
+
127
+ def apply_baseline(report: dict[str, Any], baseline: dict[str, Any]) -> BaselineApplication:
128
+ """Annotate a report; never remove findings or limitations."""
129
+
130
+ validate_semantic_report(report)
131
+ validate_baseline(baseline)
132
+ reasons = method_change_reasons(baseline["method"], method_descriptor(report))
133
+ comparable = not reasons
134
+ entries = baseline["entries"]
135
+ assert isinstance(entries, list)
136
+ baseline_fingerprints = {entry["fingerprint"] for entry in entries if isinstance(entry, dict)}
137
+ projected = deepcopy(report)
138
+ findings = projected["findings"]
139
+ assert isinstance(findings, list)
140
+ current = {
141
+ finding["fingerprint"]
142
+ for finding in findings
143
+ if isinstance(finding, dict) and isinstance(finding.get("fingerprint"), str)
144
+ }
145
+ accepted = current.intersection(baseline_fingerprints) if comparable else set()
146
+ for finding in findings:
147
+ assert isinstance(finding, dict)
148
+ finding["baseline_status"] = "accepted" if finding["fingerprint"] in accepted else "new"
149
+ stale = baseline_fingerprints - current
150
+ summary = projected.get("summary")
151
+ if not isinstance(summary, dict):
152
+ summary = {}
153
+ projected["summary"] = summary
154
+ summary.update(
155
+ {
156
+ "baselined_finding_count": len(accepted),
157
+ "new_finding_count": len(current - accepted),
158
+ "stale_baseline_count": len(stale),
159
+ }
160
+ )
161
+ projected["baseline"] = {
162
+ "status": "comparable" if comparable else "incomparable",
163
+ "reasons": list(reasons),
164
+ "source_digest": baseline.get("source_digest"),
165
+ "stale_fingerprints": sorted(stale),
166
+ }
167
+ return BaselineApplication(
168
+ payload=projected,
169
+ comparable=comparable,
170
+ accepted=len(accepted),
171
+ new=len(current - accepted),
172
+ stale=len(stale),
173
+ reasons=reasons,
174
+ )
175
+
176
+
177
+ def _content_key(item: dict[str, Any]) -> tuple[object, ...]:
178
+ """A finding's code and member identities, the content its fingerprint hashes."""
179
+
180
+ members = item.get("members", [])
181
+ return (
182
+ item.get("code"),
183
+ tuple(
184
+ sorted(
185
+ (
186
+ str(member.get("path")),
187
+ str(member.get("qualified_name")),
188
+ str(member.get("kind")),
189
+ str(member.get("occurrence")),
190
+ )
191
+ for member in (members if isinstance(members, list) else [])
192
+ if isinstance(member, dict)
193
+ )
194
+ ),
195
+ )
196
+
197
+
198
+ def render_baseline_json(payload: dict[str, Any]) -> str:
199
+ return render_json_artifact(payload)