stepfork 0.1.0a2__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 (49) hide show
  1. stepfork/__init__.py +32 -0
  2. stepfork/__main__.py +6 -0
  3. stepfork/cli/__init__.py +1 -0
  4. stepfork/cli/diff.py +156 -0
  5. stepfork/cli/export.py +210 -0
  6. stepfork/cli/inspect.py +155 -0
  7. stepfork/cli/main.py +54 -0
  8. stepfork/cli/replay.py +221 -0
  9. stepfork/cli/validate.py +160 -0
  10. stepfork/diff/__init__.py +25 -0
  11. stepfork/diff/compare.py +144 -0
  12. stepfork/diff/engine.py +267 -0
  13. stepfork/diff/models.py +63 -0
  14. stepfork/export/__init__.py +20 -0
  15. stepfork/export/entrypoint.py +58 -0
  16. stepfork/export/generator.py +178 -0
  17. stepfork/export/runtime.py +101 -0
  18. stepfork/fork/__init__.py +4 -0
  19. stepfork/inspect/__init__.py +11 -0
  20. stepfork/inspect/inspector.py +143 -0
  21. stepfork/inspect/models.py +42 -0
  22. stepfork/minimize/__init__.py +4 -0
  23. stepfork/py.typed +0 -0
  24. stepfork/recorder/__init__.py +21 -0
  25. stepfork/recorder/llm.py +68 -0
  26. stepfork/recorder/session.py +500 -0
  27. stepfork/recorder/tooling.py +208 -0
  28. stepfork/replay/__init__.py +32 -0
  29. stepfork/replay/exceptions.py +37 -0
  30. stepfork/replay/plan.py +282 -0
  31. stepfork/replay/session.py +338 -0
  32. stepfork/trace/__init__.py +115 -0
  33. stepfork/trace/canonical.py +55 -0
  34. stepfork/trace/hashing.py +75 -0
  35. stepfork/trace/integrity.py +196 -0
  36. stepfork/trace/jsonable.py +97 -0
  37. stepfork/trace/manifest.py +85 -0
  38. stepfork/trace/models.py +271 -0
  39. stepfork/trace/redaction.py +294 -0
  40. stepfork/trace/replay_policy.py +18 -0
  41. stepfork/trace/schema.py +25 -0
  42. stepfork/trace/storage.py +250 -0
  43. stepfork/trace/validation.py +285 -0
  44. stepfork/version.py +5 -0
  45. stepfork-0.1.0a2.dist-info/METADATA +377 -0
  46. stepfork-0.1.0a2.dist-info/RECORD +49 -0
  47. stepfork-0.1.0a2.dist-info/WHEEL +4 -0
  48. stepfork-0.1.0a2.dist-info/entry_points.txt +2 -0
  49. stepfork-0.1.0a2.dist-info/licenses/LICENSE +184 -0
@@ -0,0 +1,267 @@
1
+ """Semantic comparison of two recorded agent behaviors.
2
+
3
+ Timestamps, event IDs, run IDs, and wall-clock durations are ignored by
4
+ default. Comparison covers dependency call order, tool names, LLM models,
5
+ inputs, outputs, error types, state changes, and run status.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from stepfork.diff.compare import collect_field_changes, values_equal
14
+ from stepfork.diff.models import DiffResult, FieldChange, StepDiff, TraceSummary
15
+ from stepfork.replay.plan import extract_recorded_calls
16
+ from stepfork.trace import (
17
+ ErrorEvent,
18
+ JsonValue,
19
+ LLMRequest,
20
+ RunEnd,
21
+ StateChange,
22
+ ToolCall,
23
+ Trace,
24
+ )
25
+ from stepfork.trace.redaction import redact_json
26
+
27
+ TraceInput = str | Path | Trace
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class BehaviorStep:
32
+ """One comparable unit of agent behavior extracted from a trace."""
33
+
34
+ step_type: str
35
+ label: str
36
+ payload: dict[str, JsonValue]
37
+
38
+
39
+ def diff_traces(
40
+ baseline: TraceInput,
41
+ candidate: TraceInput,
42
+ ) -> DiffResult:
43
+ """Compare baseline and candidate traces as structured behavior.
44
+
45
+ Accepts `.sftrace` paths or already-loaded :class:`Trace` objects and
46
+ raises `TraceStorageError` for unreadable bundles.
47
+ """
48
+ baseline_trace = _load(baseline)
49
+ candidate_trace = _load(candidate)
50
+
51
+ baseline_steps = extract_behavior(baseline_trace)
52
+ candidate_steps = extract_behavior(candidate_trace)
53
+ steps = _diff_steps(baseline_steps, candidate_steps)
54
+
55
+ added = sum(step.kind == "added" for step in steps)
56
+ removed = sum(step.kind == "removed" for step in steps)
57
+ changed = sum(step.kind == "changed" for step in steps)
58
+ unchanged = sum(step.kind == "unchanged" for step in steps)
59
+
60
+ return DiffResult(
61
+ equivalent=added == 0 and removed == 0 and changed == 0,
62
+ baseline=_summary(baseline_trace, baseline),
63
+ candidate=_summary(candidate_trace, candidate),
64
+ steps=steps,
65
+ added=added,
66
+ removed=removed,
67
+ changed=changed,
68
+ unchanged=unchanged,
69
+ )
70
+
71
+
72
+ def extract_behavior(trace: Trace) -> list[BehaviorStep]:
73
+ """Extract the ordered, sanitized behavior steps from a trace."""
74
+ calls = {call.call_event_id: call for call in extract_recorded_calls(trace)}
75
+ steps: list[BehaviorStep] = []
76
+
77
+ for event in trace.events:
78
+ if isinstance(event, ToolCall | LLMRequest):
79
+ call = calls.get(event.id)
80
+ if call is None: # pragma: no cover - defensive
81
+ continue
82
+ steps.append(
83
+ BehaviorStep(
84
+ step_type=call.kind,
85
+ label=call.label,
86
+ payload=_sanitized_payload(
87
+ input=call.input,
88
+ output=call.output,
89
+ status=call.status.value,
90
+ ),
91
+ )
92
+ )
93
+ elif isinstance(event, StateChange):
94
+ steps.append(
95
+ BehaviorStep(
96
+ step_type="state",
97
+ label=event.key or "state",
98
+ payload={
99
+ "before": _sanitize(event.before),
100
+ "after": _sanitize(event.after),
101
+ },
102
+ )
103
+ )
104
+ elif isinstance(event, ErrorEvent):
105
+ steps.append(
106
+ BehaviorStep(
107
+ step_type="error",
108
+ label=event.error_type,
109
+ payload={"message": _sanitize(event.message)},
110
+ )
111
+ )
112
+ elif isinstance(event, RunEnd):
113
+ steps.append(
114
+ BehaviorStep(
115
+ step_type="run",
116
+ label="run",
117
+ payload={
118
+ "run_status": event.run_status.value,
119
+ "output": _sanitize(event.output),
120
+ },
121
+ )
122
+ )
123
+
124
+ return steps
125
+
126
+
127
+ def _diff_steps(
128
+ baseline_steps: list[BehaviorStep],
129
+ candidate_steps: list[BehaviorStep],
130
+ ) -> list[StepDiff]:
131
+ diffs: list[StepDiff] = []
132
+ total = max(len(baseline_steps), len(candidate_steps))
133
+
134
+ for index in range(total):
135
+ if index >= len(baseline_steps):
136
+ step = candidate_steps[index]
137
+ diffs.append(
138
+ StepDiff(
139
+ index=index,
140
+ kind="added",
141
+ step_type=step.step_type,
142
+ label=step.label,
143
+ changes=[],
144
+ )
145
+ )
146
+ continue
147
+ if index >= len(candidate_steps):
148
+ step = baseline_steps[index]
149
+ diffs.append(
150
+ StepDiff(
151
+ index=index,
152
+ kind="removed",
153
+ step_type=step.step_type,
154
+ label=step.label,
155
+ changes=[],
156
+ )
157
+ )
158
+ continue
159
+
160
+ baseline_step = baseline_steps[index]
161
+ candidate_step = candidate_steps[index]
162
+ changes: list[FieldChange] = []
163
+
164
+ if baseline_step.step_type != candidate_step.step_type:
165
+ changes.append(
166
+ FieldChange(
167
+ path="type",
168
+ kind="changed",
169
+ expected=baseline_step.step_type,
170
+ actual=candidate_step.step_type,
171
+ )
172
+ )
173
+ if baseline_step.label != candidate_step.label:
174
+ changes.append(
175
+ FieldChange(
176
+ path="label",
177
+ kind="changed",
178
+ expected=baseline_step.label,
179
+ actual=candidate_step.label,
180
+ )
181
+ )
182
+
183
+ keys = sorted(set(baseline_step.payload) | set(candidate_step.payload))
184
+ for key in keys:
185
+ if key not in baseline_step.payload:
186
+ changes.append(
187
+ FieldChange(
188
+ path=key,
189
+ kind="added",
190
+ expected=None,
191
+ actual=candidate_step.payload[key],
192
+ )
193
+ )
194
+ elif key not in candidate_step.payload:
195
+ changes.append(
196
+ FieldChange(
197
+ path=key,
198
+ kind="removed",
199
+ expected=baseline_step.payload[key],
200
+ actual=None,
201
+ )
202
+ )
203
+ else:
204
+ collect_field_changes(
205
+ baseline_step.payload[key],
206
+ candidate_step.payload[key],
207
+ path=key,
208
+ output=changes,
209
+ )
210
+
211
+ diffs.append(
212
+ StepDiff(
213
+ index=index,
214
+ kind="changed" if changes else "unchanged",
215
+ step_type=baseline_step.step_type,
216
+ label=baseline_step.label,
217
+ fields=keys,
218
+ changes=changes,
219
+ )
220
+ )
221
+
222
+ return diffs
223
+
224
+
225
+ def _sanitized_payload(
226
+ *,
227
+ input: JsonValue,
228
+ output: JsonValue | None,
229
+ status: str,
230
+ ) -> dict[str, JsonValue]:
231
+ return {
232
+ "input": _sanitize(input),
233
+ "output": _sanitize(output),
234
+ "status": status,
235
+ }
236
+
237
+
238
+ def _sanitize(value: JsonValue | None) -> JsonValue:
239
+ return redact_json(value).value
240
+
241
+
242
+ def _summary(trace: Trace, source: TraceInput) -> TraceSummary:
243
+ path = str(source) if isinstance(source, str | Path) else None
244
+ return TraceSummary(
245
+ path=path,
246
+ agent_name=trace.agent_name,
247
+ run_id=trace.run_id,
248
+ status=trace.status.value,
249
+ events=len(trace.events),
250
+ )
251
+
252
+
253
+ def _load(source: TraceInput) -> Trace:
254
+ if isinstance(source, Trace):
255
+ return source
256
+ return Trace.load(Path(source))
257
+
258
+
259
+ def steps_equivalent(left: BehaviorStep, right: BehaviorStep) -> bool:
260
+ """Return True when two behavior steps describe identical behavior."""
261
+ if left.step_type != right.step_type or left.label != right.label:
262
+ return False
263
+ if set(left.payload) != set(right.payload):
264
+ return False
265
+ return all(
266
+ values_equal(left.payload[key], right.payload[key]) for key in left.payload
267
+ )
@@ -0,0 +1,63 @@
1
+ """Structured, machine-readable behavioral diff models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from stepfork.trace.models import JsonValue
10
+
11
+ ChangeKind = Literal["added", "removed", "changed", "unchanged"]
12
+ LeafChangeKind = Literal["added", "removed", "changed"]
13
+
14
+
15
+ class FieldChange(BaseModel):
16
+ """One changed field difference between baseline and candidate."""
17
+
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+ path: str
21
+ kind: LeafChangeKind
22
+ expected: JsonValue | None = None
23
+ actual: JsonValue | None = None
24
+
25
+
26
+ class StepDiff(BaseModel):
27
+ """Comparison result for one aligned behavior step."""
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ index: int
32
+ kind: ChangeKind
33
+ step_type: str
34
+ label: str
35
+ fields: list[str] = Field(default_factory=list)
36
+ changes: list[FieldChange] = Field(default_factory=list)
37
+
38
+
39
+ class TraceSummary(BaseModel):
40
+ """Identity metadata for one side of the comparison."""
41
+
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+ path: str | None = None
45
+ agent_name: str
46
+ run_id: str
47
+ status: str
48
+ events: int
49
+
50
+
51
+ class DiffResult(BaseModel):
52
+ """Structured behavioral comparison between two traces."""
53
+
54
+ model_config = ConfigDict(extra="forbid")
55
+
56
+ equivalent: bool
57
+ baseline: TraceSummary
58
+ candidate: TraceSummary
59
+ steps: list[StepDiff] = Field(default_factory=list)
60
+ added: int = 0
61
+ removed: int = 0
62
+ changed: int = 0
63
+ unchanged: int = 0
@@ -0,0 +1,20 @@
1
+ """Regression-test exporters."""
2
+
3
+ from stepfork.export.entrypoint import EntrypointError, resolve_entrypoint
4
+ from stepfork.export.generator import (
5
+ ExportError,
6
+ ExportExistsError,
7
+ export_pytest_test,
8
+ generate_pytest_source,
9
+ )
10
+ from stepfork.export.runtime import run_regression_case
11
+
12
+ __all__ = [
13
+ "EntrypointError",
14
+ "ExportError",
15
+ "ExportExistsError",
16
+ "export_pytest_test",
17
+ "generate_pytest_source",
18
+ "resolve_entrypoint",
19
+ "run_regression_case",
20
+ ]
@@ -0,0 +1,58 @@
1
+ """Trusted resolution of ``MODULE:FUNCTION`` entrypoints.
2
+
3
+ Stepfork only imports entrypoints the user passes explicitly on the command
4
+ line or in API calls. Trace bundles are data and are never imported, evaluated,
5
+ or executed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import sys
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+
17
+ class EntrypointError(ValueError):
18
+ """Raised when an entrypoint specification cannot be resolved."""
19
+
20
+
21
+ def resolve_entrypoint(
22
+ spec: str,
23
+ *,
24
+ import_root: Path | None = None,
25
+ ) -> Callable[[], Any]:
26
+ """Import ``MODULE:FUNCTION`` and return the zero-argument callable.
27
+
28
+ ``import_root`` (when given) is prepended to ``sys.path`` so local
29
+ project modules such as ``examples.booking_agent`` resolve regardless of
30
+ how Python was started.
31
+ """
32
+ module_name, separator, attribute_path = spec.partition(":")
33
+ if not separator or not module_name or not attribute_path:
34
+ raise EntrypointError(
35
+ f"invalid entrypoint {spec!r}; expected the form MODULE:FUNCTION"
36
+ )
37
+
38
+ if import_root is not None:
39
+ root = str(import_root)
40
+ if root not in sys.path:
41
+ sys.path.insert(0, root)
42
+
43
+ try:
44
+ module = importlib.import_module(module_name)
45
+ except Exception as exc:
46
+ raise EntrypointError(
47
+ f"cannot import module {module_name!r}: {type(exc).__name__}: {exc}"
48
+ ) from exc
49
+
50
+ target: Any = module
51
+ for part in attribute_path.split("."):
52
+ if not hasattr(target, part):
53
+ raise EntrypointError(f"module {module_name!r} has no attribute {part!r}")
54
+ target = getattr(target, part)
55
+
56
+ if not callable(target):
57
+ raise EntrypointError(f"entrypoint {spec!r} is not callable")
58
+ return cast("Callable[[], Any]", target)
@@ -0,0 +1,178 @@
1
+ """Generation of executable pytest regression tests from `.sftrace` bundles.
2
+
3
+ Generated files come from a fixed, reviewed template. Trace payloads are never
4
+ interpolated into executable code: only sanitized JSON literals, paths, and
5
+ the user-supplied entrypoint specification are embedded.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import pprint
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from stepfork.trace import JsonValue
16
+ from stepfork.trace.redaction import redact_json
17
+ from stepfork.version import __version__
18
+
19
+ MAX_RELATIVE_PARENTS = 3
20
+
21
+ TEMPLATE = '''"""Stepfork regression test for {title}.
22
+
23
+ Generated by stepfork {version} from the trace bundle `{trace_name}`.
24
+ Regenerate with the same `stepfork export` command plus `--overwrite`.
25
+
26
+ This test executes the trusted local entrypoint `{entrypoint}` under frozen
27
+ Stepfork replay and asserts the behavior recorded in EXPECTATION. Stepfork
28
+ never executes code embedded in a trace bundle.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from pathlib import Path
34
+
35
+ from stepfork.export.runtime import run_regression_case
36
+
37
+ TRACE_PATH = {trace_expr}
38
+ IMPORT_ROOT = {import_root_expr}
39
+ ENTRYPOINT = {entrypoint_literal}
40
+ MODE = {mode_literal}
41
+ EXPECTATION = {expectation_literal}
42
+ HAS_EXPECTATION = {has_expectation_literal}
43
+
44
+
45
+ def {function_name}() -> None:
46
+ """Fail when the agent diverges from the expected outcome."""
47
+ run_regression_case(
48
+ trace_path=TRACE_PATH,
49
+ import_root=IMPORT_ROOT,
50
+ entrypoint=ENTRYPOINT,
51
+ expectation=EXPECTATION,
52
+ has_expectation=HAS_EXPECTATION,
53
+ mode=MODE,
54
+ )
55
+ '''
56
+
57
+
58
+ class ExportError(ValueError):
59
+ """Raised when a regression test cannot be generated."""
60
+
61
+
62
+ class ExportExistsError(ExportError):
63
+ """Raised when the output file exists and overwrite was not requested."""
64
+
65
+
66
+ def generate_pytest_source(
67
+ *,
68
+ trace_path: Path,
69
+ output_path: Path,
70
+ entrypoint: str,
71
+ expectation: JsonValue | None = None,
72
+ has_expectation: bool = True,
73
+ mode: str = "frozen",
74
+ import_root: Path | None = None,
75
+ ) -> str:
76
+ """Return the source code for a pytest regression test."""
77
+ trace = trace_path.resolve()
78
+ root = (import_root if import_root is not None else Path.cwd()).resolve()
79
+
80
+ return TEMPLATE.format(
81
+ title=_docstring_text(_trace_title(trace)),
82
+ version=_docstring_text(__version__),
83
+ trace_name=_docstring_text(trace.name),
84
+ entrypoint=_docstring_text(entrypoint),
85
+ trace_expr=_path_expression(trace, output_path),
86
+ import_root_expr=_path_expression(root, output_path),
87
+ entrypoint_literal=repr(entrypoint),
88
+ mode_literal=repr(mode),
89
+ expectation_literal=pprint.pformat(
90
+ redact_json(expectation).value if has_expectation else None,
91
+ width=88,
92
+ sort_dicts=True,
93
+ ),
94
+ has_expectation_literal=repr(has_expectation),
95
+ function_name=_test_function_name(trace),
96
+ )
97
+
98
+
99
+ def export_pytest_test(
100
+ *,
101
+ trace_path: Path,
102
+ output_path: Path,
103
+ entrypoint: str,
104
+ expectation: JsonValue | None = None,
105
+ has_expectation: bool = True,
106
+ mode: str = "frozen",
107
+ import_root: Path | None = None,
108
+ overwrite: bool = False,
109
+ ) -> Path:
110
+ """Write a pytest regression test and return its path."""
111
+ if output_path.exists() and not overwrite:
112
+ raise ExportExistsError(
113
+ f"{output_path} already exists; pass --overwrite to replace it"
114
+ )
115
+ if output_path.exists() and output_path.is_dir():
116
+ raise ExportError(f"{output_path} is a directory, not a file")
117
+
118
+ source = generate_pytest_source(
119
+ trace_path=trace_path,
120
+ output_path=output_path,
121
+ entrypoint=entrypoint,
122
+ expectation=expectation,
123
+ has_expectation=has_expectation,
124
+ mode=mode,
125
+ import_root=import_root,
126
+ )
127
+ output_path.parent.mkdir(parents=True, exist_ok=True)
128
+ output_path.write_text(source, encoding="utf-8")
129
+ return output_path
130
+
131
+
132
+ def _path_expression(target: Path, output_path: Path) -> str:
133
+ output_dir = output_path.parent.resolve()
134
+ try:
135
+ relative = os.path.relpath(target, output_dir)
136
+ except ValueError: # different drives on Windows
137
+ return f"Path({str(target)!r})"
138
+
139
+ parts = Path(relative).parts
140
+ if parts.count("..") > MAX_RELATIVE_PARENTS:
141
+ return f"Path({str(target)!r})"
142
+ if not parts:
143
+ return "Path(__file__).resolve().parent"
144
+ suffix = "".join(f" / {part!r}" for part in parts)
145
+ return f"Path(__file__).resolve().parent{suffix}"
146
+
147
+
148
+ def _safe_stem(trace_path: Path) -> str:
149
+ stem = re.sub(r"\W+", "_", trace_path.stem).strip("_")
150
+ return stem
151
+
152
+
153
+ def _docstring_text(value: str, *, limit: int = 200) -> str:
154
+ """Return text that is safe to embed inside a triple-quoted docstring.
155
+
156
+ Trace directory names and entrypoint specs are user- or attacker-supplied.
157
+ Interpolating them verbatim lets a triple double-quote or backslash
158
+ sequence terminate the module docstring early and turn the remainder into
159
+ code. Collapse whitespace, neutralize quotes and backslashes, and bound
160
+ the length.
161
+ """
162
+ collapsed = " ".join(value.split())
163
+ escaped = collapsed.replace("\\", "\\\\").replace('"', '\\"')
164
+ if len(escaped) > limit:
165
+ escaped = escaped[: limit - 1] + "…"
166
+ return escaped
167
+
168
+
169
+ def _trace_title(trace_path: Path) -> str:
170
+ stem = _safe_stem(trace_path)
171
+ return stem or "agent run"
172
+
173
+
174
+ def _test_function_name(trace_path: Path) -> str:
175
+ stem = _safe_stem(trace_path)
176
+ if not stem:
177
+ return "test_stepfork_regression"
178
+ return f"test_stepfork_regression_{stem}"
@@ -0,0 +1,101 @@
1
+ """Runtime helper executed by Stepfork-generated pytest regression tests.
2
+
3
+ This module is deliberately small, fixed, and reviewed. Generated tests only
4
+ assemble literals and call :func:`run_regression_case`; no generated code is
5
+ ever derived from trace payloads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from stepfork.diff.compare import first_difference
13
+ from stepfork.export.entrypoint import EntrypointError, resolve_entrypoint
14
+ from stepfork.replay import (
15
+ ReplayError,
16
+ ReplayMismatchError,
17
+ ReplayPolicyError,
18
+ ReplaySession,
19
+ )
20
+ from stepfork.trace import (
21
+ JsonValue,
22
+ Trace,
23
+ TraceSerializationError,
24
+ TraceStorageError,
25
+ canonical_json_bytes,
26
+ to_json_value,
27
+ )
28
+
29
+ PREVIEW_LIMIT = 300
30
+
31
+
32
+ def run_regression_case(
33
+ *,
34
+ trace_path: Path,
35
+ import_root: Path | None,
36
+ entrypoint: str,
37
+ expectation: JsonValue | None = None,
38
+ has_expectation: bool = True,
39
+ mode: str = "frozen",
40
+ ) -> JsonValue:
41
+ """Replay a trusted entrypoint and assert the expected behavior.
42
+
43
+ Fails with an actionable ``AssertionError`` when the trace is missing, the
44
+ entrypoint cannot load, replay diverges from the recorded dependency
45
+ sequence, or the returned value differs from ``expectation``.
46
+ """
47
+ bundle = Path(trace_path)
48
+ if not bundle.exists():
49
+ raise AssertionError(f"stepfork: trace bundle not found: {bundle}")
50
+ try:
51
+ recorded = Trace.load(bundle)
52
+ except TraceStorageError as exc:
53
+ raise AssertionError(f"stepfork: trace bundle unreadable: {exc}") from exc
54
+
55
+ try:
56
+ entry = resolve_entrypoint(entrypoint, import_root=import_root)
57
+ except EntrypointError as exc:
58
+ raise AssertionError(f"stepfork: entrypoint failed: {exc}") from exc
59
+
60
+ with ReplaySession.from_trace(recorded, mode=mode) as replay:
61
+ try:
62
+ actual = entry()
63
+ except ReplayMismatchError as exc:
64
+ raise AssertionError(f"stepfork replay divergence: {exc}") from exc
65
+ except ReplayPolicyError as exc:
66
+ raise AssertionError(f"stepfork replay policy violation: {exc}") from exc
67
+ try:
68
+ replay.verify_complete()
69
+ except ReplayError as exc:
70
+ raise AssertionError(f"stepfork replay divergence: {exc}") from exc
71
+
72
+ try:
73
+ actual_json = to_json_value(actual)
74
+ except TraceSerializationError as exc:
75
+ raise AssertionError(
76
+ f"stepfork: entrypoint returned a non-serializable value: {exc}"
77
+ ) from exc
78
+
79
+ if has_expectation:
80
+ difference = first_difference(expectation, actual_json)
81
+ if difference is not None:
82
+ path, expected_value, actual_value = difference
83
+ raise AssertionError(
84
+ f"behavior mismatch at '{path}': "
85
+ f"expected {_preview(expected_value)}, "
86
+ f"got {_preview(actual_value)}"
87
+ )
88
+
89
+ return actual_json
90
+
91
+
92
+ def _preview(value: JsonValue | None) -> str:
93
+ if value is None:
94
+ return "None"
95
+ try:
96
+ text = canonical_json_bytes(value).decode("utf-8", errors="replace")
97
+ except TypeError:
98
+ return "<unserializable>"
99
+ if len(text) > PREVIEW_LIMIT:
100
+ return f"{text[: PREVIEW_LIMIT - 1]}…"
101
+ return text
@@ -0,0 +1,4 @@
1
+ """Trace forking.
2
+
3
+ Planned for Stepfork v0.2.
4
+ """
@@ -0,0 +1,11 @@
1
+ """Trace inspection helpers."""
2
+
3
+ from stepfork.inspect.inspector import inspect_bundle, inspect_trace
4
+ from stepfork.inspect.models import EventSummary, TraceInspection
5
+
6
+ __all__ = [
7
+ "EventSummary",
8
+ "TraceInspection",
9
+ "inspect_bundle",
10
+ "inspect_trace",
11
+ ]