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.
- evolveguard/__init__.py +93 -0
- evolveguard/cli.py +255 -0
- evolveguard/diff/__init__.py +200 -0
- evolveguard/errors.py +17 -0
- evolveguard/fixtures.py +106 -0
- evolveguard/formatters.py +90 -0
- evolveguard/parser/__init__.py +0 -0
- evolveguard/parser/skillmd.py +249 -0
- evolveguard/paths.py +78 -0
- evolveguard/record/__init__.py +30 -0
- evolveguard/replay/__init__.py +35 -0
- evolveguard/report/__init__.py +95 -0
- evolveguard/snapshot.py +113 -0
- evolveguard/types.py +292 -0
- evolveguard-0.1.0.dist-info/METADATA +275 -0
- evolveguard-0.1.0.dist-info/RECORD +19 -0
- evolveguard-0.1.0.dist-info/WHEEL +4 -0
- evolveguard-0.1.0.dist-info/entry_points.txt +2 -0
- evolveguard-0.1.0.dist-info/licenses/LICENSE +21 -0
evolveguard/__init__.py
ADDED
|
@@ -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
|
evolveguard/fixtures.py
ADDED
|
@@ -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
|