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 +10 -0
- deadtrace/__main__.py +6 -0
- deadtrace/analysis.py +223 -0
- deadtrace/artifacts.py +87 -0
- deadtrace/baseline.py +199 -0
- deadtrace/benchmark.py +249 -0
- deadtrace/case_validator.py +272 -0
- deadtrace/cli.py +421 -0
- deadtrace/comparison.py +379 -0
- deadtrace/config.py +210 -0
- deadtrace/core.py +569 -0
- deadtrace/deployment.py +323 -0
- deadtrace/diagnostics.py +102 -0
- deadtrace/entry_points.py +123 -0
- deadtrace/findings.py +388 -0
- deadtrace/frameworks.py +3578 -0
- deadtrace/inventory.py +181 -0
- deadtrace/model.py +87 -0
- deadtrace/nominal_types.py +87 -0
- deadtrace/pytest_semantics.py +1310 -0
- deadtrace/python_frontend.py +4702 -0
- deadtrace/receiver_flow.py +79 -0
- deadtrace/report.py +68 -0
- deadtrace/scanner.py +378 -0
- deadtrace/semantic_report.py +513 -0
- deadtrace/target_environment.py +192 -0
- deadtrace/timing.py +98 -0
- deadtrace-0.1.0a1.dist-info/METADATA +284 -0
- deadtrace-0.1.0a1.dist-info/RECORD +32 -0
- deadtrace-0.1.0a1.dist-info/WHEEL +4 -0
- deadtrace-0.1.0a1.dist-info/entry_points.txt +2 -0
- deadtrace-0.1.0a1.dist-info/licenses/LICENSE +201 -0
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
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)
|