evolveguard 0.1.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.
@@ -0,0 +1,93 @@
1
+ """
2
+ Programmatic / agent-native entry point.
3
+
4
+ from evolveguard import record_baseline, replay_skill, diff_all, write_baseline, read_baseline
5
+
6
+ baseline = record_baseline("./SKILL.md", "./fixtures.json")
7
+ write_baseline("./.evolveguard-baseline.json", baseline)
8
+
9
+ # ... skill gets edited ...
10
+
11
+ saved = read_baseline("./.evolveguard-baseline.json")
12
+ replay = replay_skill("./SKILL.md", saved)
13
+ report = diff_all(saved, replay)
14
+
15
+ Returns the same structured dataclasses the CLI formats for human/JSON
16
+ output -- an agent framework can call this in-process instead of shelling
17
+ out to the CLI. Same core record/replay/diff logic as the `evolveguard`
18
+ console script; evolveguard/cli.py is a thin argument-parsing wrapper over
19
+ these functions.
20
+
21
+ This is the Python port of the evolveguard npm package
22
+ (https://www.npmjs.com/package/evolveguard). Both distributions implement
23
+ the same golden-transcript record/replay/diff pipeline against a skill's
24
+ own declared and inferred capability surface; see
25
+ https://github.com/RudrenduPaul/evolveguard for the canonical
26
+ documentation, benchmarks, and the original TypeScript source.
27
+ """
28
+ from .diff import diff_all, diff_fixture, diff_surface
29
+ from .errors import EvolveGuardError, format_what_why_fix
30
+ from .fixtures import load_fixtures
31
+ from .parser.skillmd import (
32
+ derive_capability_surface,
33
+ infer_hook_evidence,
34
+ parse_skill_file,
35
+ )
36
+ from .paths import resolve_cli_path, resolve_within_base
37
+ from .record import record_baseline
38
+ from .replay import replay_skill
39
+ from .report import read_baseline, read_report, write_baseline, write_report
40
+ from .snapshot import build_fixture_snapshots, load_skill
41
+ from .types import (
42
+ Baseline,
43
+ CapabilityChange,
44
+ CapabilityEntry,
45
+ DeclaredScope,
46
+ EvidenceRef,
47
+ EvolveGuardReport,
48
+ ExpectedToolCall,
49
+ Fixture,
50
+ FixtureDiff,
51
+ FixtureSnapshot,
52
+ ParsedSkillFile,
53
+ ReplayResult,
54
+ Summary,
55
+ )
56
+
57
+ __version__ = "0.1.0"
58
+
59
+ __all__ = [
60
+ "parse_skill_file",
61
+ "derive_capability_surface",
62
+ "infer_hook_evidence",
63
+ "load_skill",
64
+ "build_fixture_snapshots",
65
+ "load_fixtures",
66
+ "record_baseline",
67
+ "replay_skill",
68
+ "diff_fixture",
69
+ "diff_all",
70
+ "diff_surface",
71
+ "write_baseline",
72
+ "read_baseline",
73
+ "write_report",
74
+ "read_report",
75
+ "EvolveGuardError",
76
+ "format_what_why_fix",
77
+ "resolve_within_base",
78
+ "resolve_cli_path",
79
+ "Baseline",
80
+ "CapabilityChange",
81
+ "CapabilityEntry",
82
+ "DeclaredScope",
83
+ "EvidenceRef",
84
+ "EvolveGuardReport",
85
+ "ExpectedToolCall",
86
+ "Fixture",
87
+ "FixtureDiff",
88
+ "FixtureSnapshot",
89
+ "ParsedSkillFile",
90
+ "ReplayResult",
91
+ "Summary",
92
+ "__version__",
93
+ ]
evolveguard/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Thin argument-parsing wrapper over evolveguard.record/replay/diff/report
4
+ (see those modules for the full record -> replay -> diff data flow). This
5
+ module owns: flag parsing, the WHAT/WHY/FIX error surface for bad CLI
6
+ input, and the process exit-code contract (0 all PASS, 1 at least one
7
+ DRIFT, 2 usage/parse error). Console entry point: `evolveguard <command>
8
+ [options]`, installed via the `evolveguard` console-script defined in
9
+ python/pyproject.toml.
10
+
11
+ Ported from src/evolveguard-cli/cli.ts (which uses `commander`); this port
12
+ uses the stdlib `argparse` to avoid a CLI-framework dependency. Flags,
13
+ defaults, subcommands, and the WHAT/WHY/FIX error text are kept identical
14
+ to the npm CLI's `--help` output and behavior. argparse's own usage-error
15
+ and --help/--version handling already raises SystemExit(2) / SystemExit(0)
16
+ respectively, which happens to match this CLI's documented exit-code
17
+ contract exactly, so run_cli() simply catches SystemExit around argument
18
+ parsing and returns the code instead of letting the process exit.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ from typing import List, Optional
27
+
28
+ from .diff import diff_all
29
+ from .errors import EvolveGuardError, format_what_why_fix
30
+ from .formatters import format_check_result, format_record_result, format_report
31
+ from .record import record_baseline
32
+ from .replay import replay_skill
33
+ from .report import read_baseline, read_report, write_baseline, write_report
34
+
35
+ _VERSION = "0.1.0"
36
+ _DEFAULT_REPORT_PATH = "./evolveguard-report.json"
37
+
38
+ _DESCRIPTION = (
39
+ "Regression-testing CLI for self-edited Claude Agent Skills (SKILL.md, "
40
+ "MEMORY.md) -- golden-transcript record/replay against a skill's own "
41
+ "declared and inferred capability surface, zero hosted infrastructure."
42
+ )
43
+
44
+
45
+ def _default_baseline_path(skill_path: str) -> str:
46
+ return os.path.join(os.path.dirname(os.path.abspath(skill_path)), ".evolveguard-baseline.json")
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(prog="evolveguard", description=_DESCRIPTION)
51
+ parser.add_argument(
52
+ "-V", "--version", action="version", version=f"evolveguard {_VERSION}"
53
+ )
54
+
55
+ subparsers = parser.add_subparsers(dest="command")
56
+
57
+ record_parser = subparsers.add_parser(
58
+ "record",
59
+ help="Record a golden-transcript baseline for a skill against a set of labeled fixtures",
60
+ )
61
+ record_parser.add_argument(
62
+ "skill_path", metavar="skillPath", help="path to the SKILL.md or MEMORY.md file to baseline"
63
+ )
64
+ record_parser.add_argument(
65
+ "--fixtures",
66
+ required=True,
67
+ metavar="<path>",
68
+ help="path to a fixtures JSON file (array of {id, prompt, expectedToolCalls?})",
69
+ )
70
+ record_parser.add_argument(
71
+ "--baseline",
72
+ default=None,
73
+ metavar="<path>",
74
+ help="path to write the baseline file (default: <skill-dir>/.evolveguard-baseline.json)",
75
+ )
76
+ record_parser.add_argument(
77
+ "--json",
78
+ action="store_true",
79
+ default=False,
80
+ help="output structured JSON instead of human-readable text (default: false)",
81
+ )
82
+
83
+ check_parser = subparsers.add_parser(
84
+ "check",
85
+ help="Replay the fixtures from a baseline against the current (possibly edited) skill and report drift",
86
+ )
87
+ check_parser.add_argument(
88
+ "skill_path", metavar="skillPath", help="path to the SKILL.md or MEMORY.md file to check"
89
+ )
90
+ check_parser.add_argument(
91
+ "--baseline",
92
+ default=None,
93
+ metavar="<path>",
94
+ help="path to the baseline file (default: <skill-dir>/.evolveguard-baseline.json)",
95
+ )
96
+ check_parser.add_argument(
97
+ "--report", default=_DEFAULT_REPORT_PATH, metavar="<path>", help="path to write the report file"
98
+ )
99
+ check_parser.add_argument(
100
+ "--allow-drift",
101
+ action="store_true",
102
+ default=False,
103
+ help="exit 0 even if drift is detected (drift is still reported) (default: false)",
104
+ )
105
+ check_parser.add_argument(
106
+ "--json",
107
+ action="store_true",
108
+ default=False,
109
+ help="output structured JSON instead of human-readable text (default: false)",
110
+ )
111
+
112
+ report_parser = subparsers.add_parser(
113
+ "report", help="Print a previously generated evolveguard-report.json"
114
+ )
115
+ report_parser.add_argument(
116
+ "report_path",
117
+ metavar="reportPath",
118
+ nargs="?",
119
+ default=_DEFAULT_REPORT_PATH,
120
+ help="path to the report file",
121
+ )
122
+ report_parser.add_argument(
123
+ "--json",
124
+ action="store_true",
125
+ default=False,
126
+ help="output structured JSON instead of human-readable text (default: false)",
127
+ )
128
+
129
+ subparsers.add_parser(
130
+ "mcp",
131
+ help="[coming soon] Expose record/check/report as MCP tools for a coding agent to call mid-session",
132
+ )
133
+
134
+ return parser
135
+
136
+
137
+ def _handle_error(err: Exception) -> int:
138
+ if isinstance(err, EvolveGuardError):
139
+ sys.stderr.write(str(err) + "\n")
140
+ return 2
141
+ sys.stderr.write(
142
+ format_what_why_fix(
143
+ "evolveguard crashed unexpectedly.",
144
+ str(err),
145
+ "Please open an issue at https://github.com/RudrenduPaul/evolveguard/issues "
146
+ "with the command you ran.",
147
+ )
148
+ + "\n"
149
+ )
150
+ return 2
151
+
152
+
153
+ def _run_record(args: argparse.Namespace) -> int:
154
+ try:
155
+ baseline = record_baseline(args.skill_path, args.fixtures)
156
+ baseline_path = args.baseline or _default_baseline_path(args.skill_path)
157
+ write_baseline(baseline_path, baseline)
158
+
159
+ if args.json:
160
+ sys.stdout.write(
161
+ json.dumps(
162
+ {"baseline": baseline.to_dict(), "baselinePath": baseline_path}, indent=2
163
+ )
164
+ + "\n"
165
+ )
166
+ else:
167
+ sys.stdout.write(format_record_result(baseline, baseline_path) + "\n")
168
+ return 0
169
+ except Exception as err: # noqa: BLE001 -- top-level command error guard, mirrors src/cli.ts
170
+ return _handle_error(err)
171
+
172
+
173
+ def _run_check(args: argparse.Namespace) -> int:
174
+ try:
175
+ baseline_path = args.baseline or _default_baseline_path(args.skill_path)
176
+ baseline = read_baseline(baseline_path)
177
+ replay = replay_skill(args.skill_path, baseline)
178
+ report = diff_all(baseline, replay, allow_drift=args.allow_drift)
179
+ write_report(args.report, report)
180
+
181
+ if args.json:
182
+ sys.stdout.write(json.dumps(report.to_dict(), indent=2) + "\n")
183
+ else:
184
+ sys.stdout.write(format_check_result(report, baseline.recorded_at) + "\n")
185
+ return report.exit_code
186
+ except Exception as err: # noqa: BLE001
187
+ return _handle_error(err)
188
+
189
+
190
+ def _run_report(args: argparse.Namespace) -> int:
191
+ try:
192
+ report = read_report(args.report_path)
193
+ if args.json:
194
+ sys.stdout.write(json.dumps(report.to_dict(), indent=2) + "\n")
195
+ else:
196
+ sys.stdout.write(format_report(report) + "\n")
197
+ return report.exit_code
198
+ except Exception as err: # noqa: BLE001
199
+ return _handle_error(err)
200
+
201
+
202
+ def run_cli(argv: List[str]) -> int:
203
+ """
204
+ `argv` follows the sys.argv convention: argv[0] is the program name,
205
+ the real arguments start at argv[1]. Returns the process exit code.
206
+ """
207
+ parser = build_parser()
208
+ try:
209
+ args = parser.parse_args(argv[1:])
210
+ except SystemExit as exc:
211
+ code = exc.code
212
+ return code if isinstance(code, int) else (0 if code is None else 2)
213
+
214
+ if args.command is None:
215
+ parser.print_help()
216
+ return 0
217
+ if args.command == "record":
218
+ return _run_record(args)
219
+ if args.command == "check":
220
+ return _run_check(args)
221
+ if args.command == "report":
222
+ return _run_report(args)
223
+ if args.command == "mcp":
224
+ sys.stdout.write(
225
+ "evolveguard mcp is not implemented yet. Use `evolveguard record`/`check`/"
226
+ "`report --json` directly from an agent for now.\n"
227
+ )
228
+ return 0
229
+
230
+ parser.print_help()
231
+ return 2
232
+
233
+
234
+ def main() -> None:
235
+ try:
236
+ code = run_cli(sys.argv)
237
+ except SystemExit:
238
+ raise
239
+ except Exception as err: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
240
+ sys.stderr.write(
241
+ format_what_why_fix(
242
+ "evolveguard crashed unexpectedly.",
243
+ str(err),
244
+ "Please open an issue at https://github.com/RudrenduPaul/evolveguard/issues "
245
+ "with the command you ran.",
246
+ )
247
+ + "\n"
248
+ )
249
+ sys.exit(2)
250
+ else:
251
+ sys.exit(code)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ main()
@@ -0,0 +1,200 @@
1
+ """
2
+ Compares baseline vs. replayed tool-call sequences and classifies the
3
+ result. PASS = identical tool set and scopes. DRIFT = a tool appeared that
4
+ wasn't there before, a tool disappeared, or a tool's scope changed -- each
5
+ surfaced as a specific, cited change, not an unexplained failure.
6
+
7
+ Ported from src/evolveguard/diff/index.ts.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List, Optional
12
+
13
+ from ..types import (
14
+ Baseline,
15
+ CapabilityChange,
16
+ CapabilityEntry,
17
+ EvolveGuardReport,
18
+ FixtureDiff,
19
+ FixtureSnapshot,
20
+ ReplayResult,
21
+ Summary,
22
+ )
23
+
24
+
25
+ def diff_fixture(
26
+ baseline_fixture: FixtureSnapshot, replayed_fixture: FixtureSnapshot
27
+ ) -> FixtureDiff:
28
+ changes: List[CapabilityChange] = []
29
+
30
+ baseline_by_tool: Dict[str, CapabilityEntry] = {
31
+ e.tool: e for e in baseline_fixture.tool_call_sequence
32
+ }
33
+ replayed_by_tool: Dict[str, CapabilityEntry] = {
34
+ e.tool: e for e in replayed_fixture.tool_call_sequence
35
+ }
36
+
37
+ for tool, entry in replayed_by_tool.items():
38
+ if tool not in baseline_by_tool:
39
+ changes.append(
40
+ CapabilityChange(
41
+ kind="added",
42
+ tool=tool,
43
+ new_scope=entry.scope,
44
+ message=(
45
+ f"new tool call: {tool} (baseline had none) -- this edit "
46
+ "introduces a capability the baseline never used"
47
+ ),
48
+ )
49
+ )
50
+
51
+ for tool, entry in baseline_by_tool.items():
52
+ if tool not in replayed_by_tool:
53
+ changes.append(
54
+ CapabilityChange(
55
+ kind="removed",
56
+ tool=tool,
57
+ baseline_scope=entry.scope,
58
+ message=(
59
+ f"tool call removed: {tool} (baseline required this) -- this "
60
+ "edit dropped a capability the baseline relied on"
61
+ ),
62
+ )
63
+ )
64
+
65
+ for tool, baseline_entry in baseline_by_tool.items():
66
+ replayed_entry = replayed_by_tool.get(tool)
67
+ if replayed_entry is None:
68
+ continue
69
+ if (
70
+ baseline_entry.scope
71
+ and replayed_entry.scope
72
+ and baseline_entry.scope != replayed_entry.scope
73
+ ):
74
+ changes.append(
75
+ CapabilityChange(
76
+ kind="scope-changed",
77
+ tool=tool,
78
+ baseline_scope=baseline_entry.scope,
79
+ new_scope=replayed_entry.scope,
80
+ message=(
81
+ f'scope changed for {tool}: baseline "{baseline_entry.scope}" '
82
+ f'-> now "{replayed_entry.scope}"'
83
+ ),
84
+ )
85
+ )
86
+
87
+ return FixtureDiff(
88
+ id=baseline_fixture.id,
89
+ prompt=baseline_fixture.prompt,
90
+ verdict="PASS" if not changes else "DRIFT",
91
+ changes=changes,
92
+ )
93
+
94
+
95
+ def diff_surface(
96
+ baseline_surface: List[CapabilityEntry], replay_surface: List[CapabilityEntry]
97
+ ) -> List[CapabilityChange]:
98
+ """
99
+ Diffs the skill's full capability surface (declared + inferred, across
100
+ the whole skill file) independent of any fixture. This is what catches a
101
+ new capability that no fixture's expected_tool_calls anticipated -- a
102
+ per-fixture diff alone can only ever compare the tools that fixture
103
+ declared it cares about.
104
+ """
105
+ changes: List[CapabilityChange] = []
106
+
107
+ baseline_by_tool: Dict[str, CapabilityEntry] = {e.tool: e for e in baseline_surface}
108
+ replayed_by_tool: Dict[str, CapabilityEntry] = {e.tool: e for e in replay_surface}
109
+
110
+ for tool, entry in replayed_by_tool.items():
111
+ if tool not in baseline_by_tool:
112
+ changes.append(
113
+ CapabilityChange(
114
+ kind="added",
115
+ tool=tool,
116
+ new_scope=entry.scope,
117
+ message=(
118
+ f"new capability on the skill surface: {tool} (baseline had "
119
+ "none) -- no fixture's expectedToolCalls covers this tool, so "
120
+ "it would otherwise go unnoticed"
121
+ ),
122
+ )
123
+ )
124
+
125
+ for tool, entry in baseline_by_tool.items():
126
+ if tool not in replayed_by_tool:
127
+ changes.append(
128
+ CapabilityChange(
129
+ kind="removed",
130
+ tool=tool,
131
+ baseline_scope=entry.scope,
132
+ message=(
133
+ f"capability removed from the skill surface: {tool} (baseline "
134
+ "required this) -- this edit dropped a capability the "
135
+ "baseline relied on"
136
+ ),
137
+ )
138
+ )
139
+
140
+ return changes
141
+
142
+
143
+ def diff_all(
144
+ baseline: Baseline, replay: ReplayResult, allow_drift: bool = False
145
+ ) -> EvolveGuardReport:
146
+ """Diffs every fixture in a baseline against its replayed counterpart and builds the full report."""
147
+ replayed_by_id = {f.id: f for f in replay.fixtures}
148
+
149
+ results: List[FixtureDiff] = []
150
+ for baseline_fixture in baseline.fixtures:
151
+ replayed_fixture = replayed_by_id.get(baseline_fixture.id)
152
+ if replayed_fixture is None:
153
+ results.append(
154
+ FixtureDiff(
155
+ id=baseline_fixture.id,
156
+ prompt=baseline_fixture.prompt,
157
+ verdict="DRIFT",
158
+ changes=[
159
+ CapabilityChange(
160
+ kind="removed",
161
+ tool="(fixture missing)",
162
+ message=(
163
+ f'fixture "{baseline_fixture.id}" is missing from the '
164
+ "replay -- the baseline is stale or corrupted"
165
+ ),
166
+ )
167
+ ],
168
+ )
169
+ )
170
+ continue
171
+ results.append(diff_fixture(baseline_fixture, replayed_fixture))
172
+
173
+ # A capability change already surfaced through a per-fixture diff (above)
174
+ # is not repeated here -- surface_changes exists specifically to catch a
175
+ # change no fixture's expected_tool_calls covers, so entries for tools
176
+ # any fixture already flagged are filtered out to avoid reporting the
177
+ # same change twice.
178
+ tools_covered_by_fixtures = {c.tool for r in results for c in r.changes}
179
+ surface_changes = [
180
+ c
181
+ for c in diff_surface(baseline.full_capability_surface, replay.full_capability_surface)
182
+ if c.tool not in tools_covered_by_fixtures
183
+ ]
184
+
185
+ pass_count = sum(1 for r in results if r.verdict == "PASS")
186
+ drift = sum(1 for r in results if r.verdict == "DRIFT")
187
+
188
+ any_drift = drift > 0 or len(surface_changes) > 0
189
+ exit_code = 1 if (any_drift and not allow_drift) else 0
190
+
191
+ return EvolveGuardReport(
192
+ schema_version=1,
193
+ skill_name=replay.skill_name,
194
+ skill_path=replay.skill_path,
195
+ checked_at=replay.replayed_at,
196
+ results=results,
197
+ surface_changes=surface_changes,
198
+ summary=Summary(pass_count=pass_count, drift=drift, total=len(results)),
199
+ exit_code=exit_code,
200
+ )
evolveguard/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ Shared WHAT/WHY/FIX error formatting for EvolveGuardError messages. Ported
3
+ verbatim from src/evolveguard/errors.ts.
4
+ """
5
+ from __future__ import annotations
6
+
7
+
8
+ def format_what_why_fix(what: str, why: str, fix: str) -> str:
9
+ return f"WHAT: {what}\nWHY: {why}\nFIX: {fix}"
10
+
11
+
12
+ class EvolveGuardError(Exception):
13
+ def __init__(self, what: str, why: str, fix: str) -> None:
14
+ super().__init__(format_what_why_fix(what, why, fix))
15
+ self.what = what
16
+ self.why = why
17
+ self.fix = fix
@@ -0,0 +1,106 @@
1
+ """
2
+ Reads and validates a user-supplied fixtures.json against the documented
3
+ schema: a non-empty JSON array of
4
+ {"id": string, "prompt": string, "expectedToolCalls"?: [{"tool": string, "scopeMatches"?: string}]}.
5
+ Ported from src/evolveguard/fixtures.ts (which uses zod; this port validates
6
+ by hand to avoid a schema-validation dependency).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, List
12
+
13
+ from .errors import EvolveGuardError
14
+ from .types import ExpectedToolCall, Fixture
15
+
16
+ _SCHEMA_HINT = (
17
+ 'A fixtures file is a non-empty JSON array of {"id": string, "prompt": string, '
18
+ '"expectedToolCalls"?: [{"tool": string, "scopeMatches"?: string}]}.'
19
+ )
20
+
21
+
22
+ def _schema_error(fixtures_path: str, reason: str) -> EvolveGuardError:
23
+ return EvolveGuardError(
24
+ f'Fixtures file at "{fixtures_path}" does not match the expected schema.',
25
+ reason,
26
+ _SCHEMA_HINT,
27
+ )
28
+
29
+
30
+ def _parse_expected_tool_call(raw: Any, index: int) -> ExpectedToolCall:
31
+ if not isinstance(raw, dict):
32
+ raise ValueError(f"expectedToolCalls[{index}]: expected an object")
33
+ tool = raw.get("tool")
34
+ if not isinstance(tool, str) or not tool:
35
+ raise ValueError(f"expectedToolCalls[{index}].tool: expected a non-empty string")
36
+ scope_matches = raw.get("scopeMatches")
37
+ if scope_matches is not None and not isinstance(scope_matches, str):
38
+ raise ValueError(f"expectedToolCalls[{index}].scopeMatches: expected a string")
39
+ return ExpectedToolCall(tool=tool, scope_matches=scope_matches)
40
+
41
+
42
+ def _parse_fixture(raw: Any, index: int) -> Fixture:
43
+ if not isinstance(raw, dict):
44
+ raise ValueError(f"[{index}]: expected an object")
45
+ fid = raw.get("id")
46
+ if not isinstance(fid, str) or not fid:
47
+ raise ValueError(f"[{index}].id: expected a non-empty string")
48
+ prompt = raw.get("prompt")
49
+ if not isinstance(prompt, str) or not prompt:
50
+ raise ValueError(f"[{index}].prompt: expected a non-empty string")
51
+
52
+ raw_expected = raw.get("expectedToolCalls")
53
+ if raw_expected is None:
54
+ expected = None
55
+ else:
56
+ if not isinstance(raw_expected, list):
57
+ raise ValueError(f"[{index}].expectedToolCalls: expected an array")
58
+ expected = [
59
+ _parse_expected_tool_call(item, i) for i, item in enumerate(raw_expected)
60
+ ]
61
+
62
+ return Fixture(id=fid, prompt=prompt, expected_tool_calls=expected)
63
+
64
+
65
+ def load_fixtures(fixtures_path: str) -> List[Fixture]:
66
+ try:
67
+ with open(fixtures_path, "r", encoding="utf-8") as fh:
68
+ raw_text = fh.read()
69
+ except OSError as err:
70
+ raise EvolveGuardError(
71
+ f'Could not read fixtures file at "{fixtures_path}".',
72
+ str(err),
73
+ "Pass a valid path to a fixtures JSON file with --fixtures.",
74
+ ) from err
75
+
76
+ try:
77
+ parsed = json.loads(raw_text)
78
+ except json.JSONDecodeError as err:
79
+ raise EvolveGuardError(
80
+ f'Fixtures file at "{fixtures_path}" is not valid JSON.',
81
+ str(err),
82
+ "Fix the JSON syntax, e.g. run it through `python3 -m json.tool <file>`.",
83
+ ) from err
84
+
85
+ if not isinstance(parsed, list) or len(parsed) == 0:
86
+ raise _schema_error(
87
+ fixtures_path, "expected a non-empty array, got " + type(parsed).__name__
88
+ )
89
+
90
+ try:
91
+ fixtures = [_parse_fixture(item, i) for i, item in enumerate(parsed)]
92
+ except ValueError as err:
93
+ raise _schema_error(fixtures_path, str(err)) from err
94
+
95
+ ids = set()
96
+ for fixture in fixtures:
97
+ if fixture.id in ids:
98
+ raise EvolveGuardError(
99
+ f'Duplicate fixture id "{fixture.id}" in "{fixtures_path}".',
100
+ "Fixture ids must be unique within a fixtures file -- they are used to "
101
+ "match baseline entries on replay.",
102
+ "Give each fixture a unique id.",
103
+ )
104
+ ids.add(fixture.id)
105
+
106
+ return fixtures