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,90 @@
1
+ """
2
+ Human-readable output formatting for the CLI. Ported from
3
+ src/evolveguard-cli/formatters.ts.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import List
8
+
9
+ from .types import Baseline, CapabilityChange, EvolveGuardReport
10
+
11
+ _VERSION = "0.1.0"
12
+
13
+
14
+ def format_record_result(baseline: Baseline, baseline_path: str) -> str:
15
+ lines: List[str] = []
16
+ lines.append(f"EvolveGuard v{_VERSION} -- Baseline Recorded")
17
+ lines.append(f"skill: {baseline.skill_name} fixtures: {len(baseline.fixtures)}")
18
+ lines.append("")
19
+ for fixture in baseline.fixtures:
20
+ tool_names = ", ".join(e.tool for e in fixture.tool_call_sequence) or (
21
+ "(no capabilities detected)"
22
+ )
23
+ lines.append(f' recorded fixture: "{fixture.prompt}" tools: {tool_names}')
24
+ lines.append("")
25
+ lines.append(f"baseline written to {baseline_path}")
26
+ return "\n".join(lines)
27
+
28
+
29
+ def _summarize_change(change: CapabilityChange) -> str:
30
+ if change.kind == "added":
31
+ return f"new tool call: {change.tool} (baseline had none)"
32
+ if change.kind == "removed":
33
+ return f"tool call removed: {change.tool}"
34
+ return f"scope changed: {change.tool}"
35
+
36
+
37
+ def format_check_result(report: EvolveGuardReport, baseline_recorded_at: str) -> str:
38
+ lines: List[str] = []
39
+ date = baseline_recorded_at[:10]
40
+ lines.append(f"EvolveGuard v{_VERSION} -- Regression Check")
41
+ lines.append(
42
+ f"skill: {report.skill_name} baseline: {date} fixtures: {report.summary.total}"
43
+ )
44
+ lines.append("")
45
+
46
+ for result in report.results:
47
+ tag = "[PASS] " if result.verdict == "PASS" else "[DRIFT]"
48
+ if result.verdict == "PASS":
49
+ lines.append(f'{tag} fixture: "{result.prompt}" tool-call sequence unchanged')
50
+ else:
51
+ first = result.changes[0] if result.changes else None
52
+ summary = _summarize_change(first) if first else "behavior changed"
53
+ lines.append(f'{tag} fixture: "{result.prompt}" {summary}')
54
+ for change in result.changes:
55
+ lines.append(f" -> {change.message}")
56
+
57
+ if report.surface_changes:
58
+ lines.append("")
59
+ lines.append("skill-level surface changes (not tied to a specific fixture):")
60
+ for change in report.surface_changes:
61
+ lines.append(f" -> {change.message}")
62
+
63
+ lines.append("")
64
+ lines.append(f"{report.summary.pass_count} PASS, {report.summary.drift} DRIFT, 0 FAIL")
65
+ if report.exit_code == 1:
66
+ lines.append("exit code 1 (DRIFT blocks merge by default; override with --allow-drift)")
67
+ else:
68
+ lines.append("exit code 0")
69
+
70
+ return "\n".join(lines)
71
+
72
+
73
+ def format_report(report: EvolveGuardReport) -> str:
74
+ lines: List[str] = []
75
+ lines.append(f"EvolveGuard v{_VERSION} -- Report")
76
+ lines.append(f"skill: {report.skill_name} checked: {report.checked_at}")
77
+ lines.append("")
78
+ for result in report.results:
79
+ lines.append(f"[{result.verdict}] {result.id}: {result.prompt}")
80
+ for change in result.changes:
81
+ lines.append(f" -> {change.message}")
82
+ if report.surface_changes:
83
+ lines.append("")
84
+ lines.append("skill-level surface changes:")
85
+ for change in report.surface_changes:
86
+ lines.append(f" -> {change.message}")
87
+ lines.append("")
88
+ lines.append(f"{report.summary.pass_count} PASS, {report.summary.drift} DRIFT, 0 FAIL")
89
+ lines.append(f"exit code {report.exit_code}")
90
+ return "\n".join(lines)
File without changes
@@ -0,0 +1,249 @@
1
+ """
2
+ Parses a Claude Agent Skill file (SKILL.md) or an auto-memory file
3
+ (MEMORY.md). SKILL.md files carry YAML frontmatter declaring the skill's
4
+ name and scope; MEMORY.md files typically do not, so a file with no
5
+ frontmatter is treated as declaring an empty scope, and its capability
6
+ surface is derived entirely from static evidence found in its own body
7
+ text (see derive_capability_surface below).
8
+
9
+ Frontmatter schema this parser understands:
10
+ ---
11
+ name: my-skill
12
+ description: ...
13
+ tools: [fs.read, fs.write] # declared tool names, optional
14
+ network: false # boolean, optional (default false)
15
+ filesystem: read-only # "none" | "read-only" | "read-write", optional (default none)
16
+ scope: "./workspace/**" # glob the filesystem tools are scoped to, optional (default "./**")
17
+ hooks: ["scripts/pre-run.sh"] # bundled hook scripts, paths relative to the skill file, optional
18
+ ---
19
+
20
+ This schema is a superset compatible with SkillGuard's own DeclaredScope
21
+ (network: boolean, filesystem: none|read-only|read-write) -- EvolveGuard
22
+ reuses the same field names and parsing pattern deliberately so a skill
23
+ author only has to think about one frontmatter shape across both tools.
24
+
25
+ Ported from src/evolveguard/parser/skillmd.ts.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ from typing import Dict, List, Optional, Tuple
32
+
33
+ import yaml
34
+
35
+ from ..paths import resolve_within_base
36
+ from ..types import CapabilityEntry, DeclaredScope, EvidenceRef, ParsedSkillFile
37
+
38
+ _FRONTMATTER_RE = re.compile(r"^---\r?\n([\s\S]*?)\r?\n---")
39
+
40
+ _KNOWN_FILESYSTEM_VALUES = {"none", "read-only", "read-write"}
41
+
42
+
43
+ def parse_skill_file(content: str, file_base_name: str) -> ParsedSkillFile:
44
+ match = _FRONTMATTER_RE.match(content)
45
+
46
+ def base_name() -> str:
47
+ return os.path.splitext(os.path.basename(file_base_name))[0]
48
+
49
+ if not match:
50
+ return ParsedSkillFile(
51
+ name=base_name(),
52
+ has_frontmatter=False,
53
+ declared_scope=DeclaredScope(
54
+ tools=[], network=False, filesystem="none", scope="./**", hooks=[]
55
+ ),
56
+ body=content,
57
+ )
58
+
59
+ try:
60
+ data = yaml.safe_load(match.group(1))
61
+ except yaml.YAMLError:
62
+ data = None
63
+
64
+ record = data if isinstance(data, dict) else {}
65
+
66
+ raw_name = record.get("name")
67
+ name = (
68
+ raw_name.strip()
69
+ if isinstance(raw_name, str) and raw_name.strip()
70
+ else base_name()
71
+ )
72
+
73
+ description = record.get("description")
74
+ if not isinstance(description, str):
75
+ description = None
76
+
77
+ raw_tools = record.get("tools")
78
+ tools = [t for t in raw_tools if isinstance(t, str)] if isinstance(raw_tools, list) else []
79
+
80
+ network = record.get("network") is True
81
+
82
+ filesystem_raw = record.get("filesystem")
83
+ filesystem = (
84
+ filesystem_raw
85
+ if isinstance(filesystem_raw, str) and filesystem_raw in _KNOWN_FILESYSTEM_VALUES
86
+ else "none"
87
+ )
88
+
89
+ raw_scope = record.get("scope")
90
+ scope = raw_scope if isinstance(raw_scope, str) and raw_scope.strip() else "./**"
91
+
92
+ raw_hooks = record.get("hooks")
93
+ hooks = [h for h in raw_hooks if isinstance(h, str)] if isinstance(raw_hooks, list) else []
94
+
95
+ body = content[match.end() :]
96
+
97
+ return ParsedSkillFile(
98
+ name=name,
99
+ description=description,
100
+ has_frontmatter=True,
101
+ declared_scope=DeclaredScope(
102
+ tools=tools, network=network, filesystem=filesystem, scope=scope, hooks=hooks
103
+ ),
104
+ body=body,
105
+ )
106
+
107
+
108
+ # Note: `curl`/`wget` deliberately have no trailing `\s` baked into the
109
+ # alternation -- `\b` after "curl" already requires a non-word character to
110
+ # follow (a space, a flag like "-X", end of string, all count), so
111
+ # consuming the whitespace into the match would break that boundary check
112
+ # whenever the very next character after the space is itself non-word (e.g.
113
+ # "curl -X ..."), silently missing real command lines.
114
+ _NETWORK_EVIDENCE_RE = re.compile(
115
+ r"\b(fetch|axios|http\.request|https\.request|requests\.(get|post|put|delete)"
116
+ r"|urllib\.request|urlopen|socket\.socket|curl|wget)\b",
117
+ re.IGNORECASE,
118
+ )
119
+
120
+ _FS_WRITE_EVIDENCE_RE = re.compile(
121
+ r"""\b(fs\.writeFile|fs\.writeFileSync|fs\.appendFile|fs\.unlink|open\([^)]*['"]w"""
122
+ r"""|os\.remove|os\.rmdir|shutil\.rmtree)\b|>\s*/|rm\s+-rf""",
123
+ re.IGNORECASE,
124
+ )
125
+
126
+
127
+ def _first_match_line(content: str, pattern: re.Pattern) -> Optional[int]:
128
+ m = pattern.search(content)
129
+ if not m:
130
+ return None
131
+ return content.count("\n", 0, m.start()) + 1
132
+
133
+
134
+ def infer_hook_evidence(
135
+ skill_dir: str, hooks: List[str]
136
+ ) -> Dict[str, List[Dict[str, object]]]:
137
+ """
138
+ Reads a skill's declared hook scripts (paths validated against the
139
+ skill's own directory -- see paths.py) and scans each for static
140
+ evidence of network or filesystem-write behavior. This is the same
141
+ read-only, regex-based evidence model SkillGuard uses; EvolveGuard
142
+ never executes anything from the skill or its hooks.
143
+ """
144
+ network_evidence: List[Dict[str, object]] = []
145
+ fs_write_evidence: List[Dict[str, object]] = []
146
+
147
+ for hook_path in hooks:
148
+ try:
149
+ abs_path = resolve_within_base(skill_dir, hook_path)
150
+ except Exception:
151
+ continue
152
+
153
+ try:
154
+ with open(abs_path, "r", encoding="utf-8") as fh:
155
+ content = fh.read()
156
+ except OSError:
157
+ continue
158
+
159
+ net_line = _first_match_line(content, _NETWORK_EVIDENCE_RE)
160
+ if net_line is not None:
161
+ network_evidence.append({"file": hook_path, "line": net_line})
162
+
163
+ fs_line = _first_match_line(content, _FS_WRITE_EVIDENCE_RE)
164
+ if fs_line is not None:
165
+ fs_write_evidence.append({"file": hook_path, "line": fs_line})
166
+
167
+ return {"networkEvidence": network_evidence, "fsWriteEvidence": fs_write_evidence}
168
+
169
+
170
+ def derive_capability_surface(
171
+ parsed: ParsedSkillFile, skill_dir: str
172
+ ) -> List[CapabilityEntry]:
173
+ """
174
+ Derives the full capability surface for a parsed skill: declared entries
175
+ from frontmatter, plus inferred entries from static evidence in the
176
+ skill's own body text and any bundled hook scripts. This is the
177
+ deterministic "transcript baseline" EvolveGuard records and later
178
+ re-derives on replay -- see README "How it works" for why this is not a
179
+ live LLM run.
180
+ """
181
+ entries: List[CapabilityEntry] = []
182
+ seen = set()
183
+
184
+ def add_declared(tool: str, scope: Optional[str] = None) -> None:
185
+ key = f"declared:{tool}"
186
+ if key in seen:
187
+ return
188
+ seen.add(key)
189
+ entries.append(CapabilityEntry(tool=tool, source="declared", scope=scope))
190
+
191
+ for tool in parsed.declared_scope.tools:
192
+ add_declared(tool)
193
+
194
+ if parsed.declared_scope.network:
195
+ add_declared("network.fetch")
196
+
197
+ if parsed.declared_scope.filesystem in ("read-only", "read-write"):
198
+ add_declared("fs.read", parsed.declared_scope.scope)
199
+ if parsed.declared_scope.filesystem == "read-write":
200
+ add_declared("fs.write", parsed.declared_scope.scope)
201
+
202
+ # Inferred evidence from the skill body itself (covers MEMORY.md, which has no frontmatter).
203
+ body_net_line = _first_match_line(parsed.body, _NETWORK_EVIDENCE_RE)
204
+ body_fs_line = _first_match_line(parsed.body, _FS_WRITE_EVIDENCE_RE)
205
+
206
+ inferred_network_evidence: List[Dict[str, object]] = (
207
+ [{"file": "(body)", "line": body_net_line}] if body_net_line is not None else []
208
+ )
209
+ inferred_fs_evidence: List[Dict[str, object]] = (
210
+ [{"file": "(body)", "line": body_fs_line}] if body_fs_line is not None else []
211
+ )
212
+
213
+ # Inferred evidence from bundled hook scripts declared in frontmatter.
214
+ hook_evidence = infer_hook_evidence(skill_dir, parsed.declared_scope.hooks)
215
+ inferred_network_evidence.extend(hook_evidence["networkEvidence"])
216
+ inferred_fs_evidence.extend(hook_evidence["fsWriteEvidence"])
217
+
218
+ def add_inferred(tool: str, evidence: List[Dict[str, object]]) -> None:
219
+ if not evidence:
220
+ return
221
+ key = f"inferred:{tool}"
222
+ if key in seen:
223
+ existing = next(
224
+ (e for e in entries if e.source == "inferred" and e.tool == tool), None
225
+ )
226
+ if existing is not None:
227
+ existing.evidence = (existing.evidence or []) + [
228
+ EvidenceRef(file=str(e["file"]), line=int(e["line"])) for e in evidence
229
+ ]
230
+ return
231
+ # If this tool is already declared, do not duplicate it as a separate inferred entry --
232
+ # declared coverage takes precedence and the inferred evidence is redundant confirmation.
233
+ if f"declared:{tool}" in seen:
234
+ return
235
+ seen.add(key)
236
+ entries.append(
237
+ CapabilityEntry(
238
+ tool=tool,
239
+ source="inferred",
240
+ evidence=[
241
+ EvidenceRef(file=str(e["file"]), line=int(e["line"])) for e in evidence
242
+ ],
243
+ )
244
+ )
245
+
246
+ add_inferred("network.fetch", inferred_network_evidence)
247
+ add_inferred("fs.write", inferred_fs_evidence)
248
+
249
+ return entries
evolveguard/paths.py ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ Resolves a user-supplied path (SKILL.md, fixtures.json, a hook script
3
+ referenced from frontmatter) and rejects anything that escapes the
4
+ expected base directory. This is the one place path-traversal protection
5
+ lives -- every module that reads a file supplied indirectly through
6
+ frontmatter (hook script paths) routes through this function rather than
7
+ concatenating strings and calling open() directly.
8
+
9
+ A skill file's own frontmatter is untrusted input (it may itself be the
10
+ edit under test), so `hooks: ["../../../etc/passwd"]` must never resolve
11
+ outside the skill's own directory.
12
+
13
+ The lexical check alone is not enough: a path that looks safe
14
+ ("hooks/pre.sh") can itself be a symlink whose real target lives outside
15
+ the skill directory, and open() follows symlinks transparently. So after
16
+ the lexical check passes, this also resolves symlinks (via
17
+ os.path.realpath) on both the base and the target and re-checks the same
18
+ containment invariant against the real paths -- a symlink escape fails
19
+ exactly like a literal "../" escape. A target that does not exist yet
20
+ (e.g. a hook path being validated before the file is created) skips the
21
+ realpath re-check rather than erroring; the caller's own file read will
22
+ fail on it as before.
23
+
24
+ Ported from src/evolveguard/paths.ts.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import os
29
+
30
+ from .errors import EvolveGuardError
31
+
32
+
33
+ def _resolve(base: str, target: str) -> str:
34
+ combined = target if os.path.isabs(target) else os.path.join(base, target)
35
+ return os.path.normpath(combined)
36
+
37
+
38
+ def _escapes_base(resolved_base: str, resolved_target: str) -> bool:
39
+ relative = os.path.relpath(resolved_target, resolved_base)
40
+ return relative == ".." or relative.startswith(".." + os.sep)
41
+
42
+
43
+ def _outside_base_error(user_path: str, resolved_base: str) -> EvolveGuardError:
44
+ return EvolveGuardError(
45
+ f'Path "{user_path}" resolves outside its skill directory.',
46
+ f"Hook and reference paths declared in a skill file must stay within "
47
+ f"the skill's own directory ({resolved_base}) -- this prevents a "
48
+ f"malicious or accidentally-broken skill file from making EvolveGuard "
49
+ f"read files outside its scope.",
50
+ "Use a path relative to the skill file that stays inside its own "
51
+ "directory, and make sure it is not a symlink pointing elsewhere.",
52
+ )
53
+
54
+
55
+ def resolve_within_base(base_dir: str, user_path: str) -> str:
56
+ resolved_base = os.path.abspath(base_dir)
57
+ resolved_target = _resolve(resolved_base, user_path)
58
+
59
+ if _escapes_base(resolved_base, resolved_target):
60
+ raise _outside_base_error(user_path, resolved_base)
61
+
62
+ if not os.path.exists(resolved_base) or not os.path.exists(resolved_target):
63
+ # Base or target doesn't exist on disk (yet) -- nothing to follow, the
64
+ # lexical check above already stands as the answer for this call.
65
+ return resolved_target
66
+
67
+ real_base = os.path.realpath(resolved_base)
68
+ real_target = os.path.realpath(resolved_target)
69
+
70
+ if _escapes_base(real_base, real_target):
71
+ raise _outside_base_error(user_path, resolved_base)
72
+
73
+ return resolved_target
74
+
75
+
76
+ def resolve_cli_path(cwd: str, user_path: str) -> str:
77
+ """Resolves and validates a top-level CLI-supplied path (SKILL.md, fixtures.json, baseline, report)."""
78
+ return _resolve(os.path.abspath(cwd), user_path)
@@ -0,0 +1,30 @@
1
+ """
2
+ Captures a golden-transcript baseline for a skill: parses the skill file,
3
+ derives its declared + inferred capability surface, and snapshots each
4
+ fixture's expected tool-call sequence against that surface. This never
5
+ invokes a live agent -- see README "How it works".
6
+
7
+ Ported from src/evolveguard/record/index.ts.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+
13
+ from ..fixtures import load_fixtures
14
+ from ..snapshot import build_fixture_snapshots, load_skill
15
+ from ..types import Baseline
16
+
17
+
18
+ def record_baseline(skill_path: str, fixtures_path: str) -> Baseline:
19
+ skill = load_skill(skill_path)
20
+ fixtures = load_fixtures(fixtures_path)
21
+ fixture_snapshots = build_fixture_snapshots(skill.capability_surface, fixtures)
22
+
23
+ return Baseline(
24
+ schema_version=1,
25
+ skill_name=skill.name,
26
+ skill_path=skill.skill_path,
27
+ recorded_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
28
+ full_capability_surface=skill.capability_surface,
29
+ fixtures=fixture_snapshots,
30
+ )
@@ -0,0 +1,35 @@
1
+ """
2
+ Re-parses the (possibly edited) skill file and re-derives its capability
3
+ surface using the exact same deterministic logic `record` used, then
4
+ re-snapshots each fixture from the baseline against the new surface. The
5
+ fixture list is taken from the baseline itself (not re-read from an
6
+ external fixtures.json) so `evolveguard check` only needs the skill path.
7
+
8
+ Ported from src/evolveguard/replay/index.ts.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+
14
+ from ..snapshot import build_fixture_snapshots, load_skill
15
+ from ..types import Baseline, Fixture, ReplayResult
16
+
17
+
18
+ def replay_skill(skill_path: str, baseline: Baseline) -> ReplayResult:
19
+ skill = load_skill(skill_path)
20
+
21
+ fixtures = [
22
+ Fixture(id=f.id, prompt=f.prompt, expected_tool_calls=f.expected_tool_calls)
23
+ for f in baseline.fixtures
24
+ ]
25
+
26
+ fixture_snapshots = build_fixture_snapshots(skill.capability_surface, fixtures)
27
+
28
+ return ReplayResult(
29
+ schema_version=1,
30
+ skill_name=skill.name,
31
+ skill_path=skill.skill_path,
32
+ replayed_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
33
+ full_capability_surface=skill.capability_surface,
34
+ fixtures=fixture_snapshots,
35
+ )
@@ -0,0 +1,95 @@
1
+ """
2
+ Reads and writes baseline (.evolveguard-baseline.json) and report
3
+ (evolveguard-report.json) files as pretty-printed, deterministic JSON, with
4
+ schema validation on read. Ported from src/evolveguard/report/index.ts
5
+ (which uses zod; this port validates by hand to avoid a schema-validation
6
+ dependency).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from ..errors import EvolveGuardError
13
+ from ..types import Baseline, EvolveGuardReport
14
+
15
+
16
+ def write_baseline(baseline_path: str, baseline: Baseline) -> None:
17
+ with open(baseline_path, "w", encoding="utf-8") as fh:
18
+ fh.write(json.dumps(baseline.to_dict(), indent=2) + "\n")
19
+
20
+
21
+ def read_baseline(baseline_path: str) -> Baseline:
22
+ try:
23
+ with open(baseline_path, "r", encoding="utf-8") as fh:
24
+ raw_text = fh.read()
25
+ except OSError as err:
26
+ raise EvolveGuardError(
27
+ f'Could not read baseline file at "{baseline_path}".',
28
+ str(err),
29
+ "Run `evolveguard record <SKILL.md> --fixtures <fixtures.json>` first to "
30
+ "create a baseline.",
31
+ ) from err
32
+
33
+ try:
34
+ parsed = json.loads(raw_text)
35
+ except json.JSONDecodeError as err:
36
+ raise EvolveGuardError(
37
+ f'Baseline file at "{baseline_path}" is not valid JSON.',
38
+ str(err),
39
+ "Re-run `evolveguard record` to regenerate the baseline file.",
40
+ ) from err
41
+
42
+ try:
43
+ if not isinstance(parsed, dict) or parsed.get("schemaVersion") != 1:
44
+ raise ValueError("missing or unexpected schemaVersion")
45
+ if not parsed.get("fixtures"):
46
+ raise ValueError("fixtures: expected a non-empty array")
47
+ baseline = Baseline.from_dict(parsed)
48
+ except (KeyError, ValueError, TypeError) as err:
49
+ raise EvolveGuardError(
50
+ f'Baseline file at "{baseline_path}" does not match the expected schema.',
51
+ str(err),
52
+ "Re-run `evolveguard record` to regenerate the baseline file, or check "
53
+ "it was not hand-edited.",
54
+ ) from err
55
+
56
+ return baseline
57
+
58
+
59
+ def write_report(report_path: str, report: EvolveGuardReport) -> None:
60
+ with open(report_path, "w", encoding="utf-8") as fh:
61
+ fh.write(json.dumps(report.to_dict(), indent=2) + "\n")
62
+
63
+
64
+ def read_report(report_path: str) -> EvolveGuardReport:
65
+ try:
66
+ with open(report_path, "r", encoding="utf-8") as fh:
67
+ raw_text = fh.read()
68
+ except OSError as err:
69
+ raise EvolveGuardError(
70
+ f'Could not read report file at "{report_path}".',
71
+ str(err),
72
+ "Run `evolveguard check <SKILL.md>` first to generate a report.",
73
+ ) from err
74
+
75
+ try:
76
+ parsed = json.loads(raw_text)
77
+ except json.JSONDecodeError as err:
78
+ raise EvolveGuardError(
79
+ f'Report file at "{report_path}" is not valid JSON.',
80
+ str(err),
81
+ "Re-run `evolveguard check` to regenerate the report file.",
82
+ ) from err
83
+
84
+ try:
85
+ if not isinstance(parsed, dict) or parsed.get("schemaVersion") != 1:
86
+ raise ValueError("missing or unexpected schemaVersion")
87
+ report = EvolveGuardReport.from_dict(parsed)
88
+ except (KeyError, ValueError, TypeError) as err:
89
+ raise EvolveGuardError(
90
+ f'Report file at "{report_path}" does not match the expected schema.',
91
+ str(err),
92
+ "Re-run `evolveguard check` to regenerate the report file.",
93
+ ) from err
94
+
95
+ return report