-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate.py
More file actions
232 lines (201 loc) · 7.85 KB
/
Copy pathvalidate.py
File metadata and controls
232 lines (201 loc) · 7.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
#!/usr/bin/env python3
"""validate.py — lightweight checks for zergling sources and package.
Checks:
1. SKILL.md frontmatter parses; has `name`, `description`; description
length is sane; optional `version` is a non-empty string if present.
2. assets/bootstrap.html includes an auto-refresh mechanism
(either a meta http-equiv refresh or a setTimeout reload).
2b. assets/shell.html is a real polling shell — references version.js and
frame.html and has a setInterval loop (not just a meta-refresh placeholder).
3. zergling.skill exists, is a ZIP, contains exactly the expected
entries, and each packaged file is byte-identical to its tracked source.
4. (optional) Runs `agentskills validate <skill-dir>` if `agentskills`
is on PATH. A missing binary is not an error.
Exit code 0 on pass, non-zero on any failure.
Run from anywhere — the script resolves its own directory.
"""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
EXPECTED_ENTRIES = {
"zergling/INSTALL.md": "INSTALL.md",
"zergling/SKILL.md": "SKILL.md",
"zergling/assets/bootstrap.html": "assets/bootstrap.html",
"zergling/assets/shell.html": "assets/shell.html",
"zergling/assets/version.js": "assets/version.js",
"zergling/assets/timelapse-index.html": "assets/timelapse-index.html",
"zergling/assets/timelapse-playlist.js":"assets/timelapse-playlist.js",
}
SKILL_PKG = ROOT / "zergling.skill"
DESC_MAX = 1024 # generous; most validators cap around 1024 chars
errors: list[str] = []
warnings: list[str] = []
def fail(msg: str) -> None:
errors.append(msg)
def warn(msg: str) -> None:
warnings.append(msg)
def step(label: str) -> None:
print(f"\n== {label} ==")
def parse_frontmatter(text: str) -> dict[str, str] | None:
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
if not m:
return None
fm: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" not in line:
continue
k, _, v = line.partition(":")
fm[k.strip()] = v.strip()
return fm
def check_skill_md() -> None:
step("SKILL.md frontmatter")
p = ROOT / "SKILL.md"
if not p.is_file():
fail(f"missing: {p}")
return
fm = parse_frontmatter(p.read_text(encoding="utf-8"))
if fm is None:
fail("SKILL.md: no YAML frontmatter delimited by '---' lines")
return
name = fm.get("name", "")
desc = fm.get("description", "")
print(f" name = {name!r}")
print(f" description = {desc[:80]!r}{'...' if len(desc) > 80 else ''}")
print(f" desc length = {len(desc)} chars")
if name != "zergling":
fail(f"SKILL.md: name must be 'zergling', got {name!r}")
if not desc:
fail("SKILL.md: description is empty")
elif len(desc) > DESC_MAX:
fail(f"SKILL.md: description exceeds {DESC_MAX} chars ({len(desc)})")
if "version" in fm:
v = fm["version"]
print(f" version = {v!r}")
if not v or not re.match(r"^\d+\.\d+(\.\d+)?(-[\w.]+)?$", v):
fail(f"SKILL.md: version {v!r} is not a valid semver-ish string")
else:
warn("SKILL.md: no `version` field in frontmatter (optional but recommended)")
def check_bootstrap() -> None:
step("assets/bootstrap.html auto-refresh")
p = ROOT / "assets" / "bootstrap.html"
if not p.is_file():
fail(f"missing: {p}")
return
body = p.read_text(encoding="utf-8")
has_meta = bool(re.search(r'<meta\s+http-equiv=["\']refresh["\']', body, re.I))
has_reload = bool(re.search(r"setTimeout\s*\(\s*[^,]*location\.reload", body))
if has_meta:
print(" ok: meta http-equiv=refresh present")
elif has_reload:
print(" ok: setTimeout(location.reload) present")
else:
fail("bootstrap.html: no auto-refresh found (expected meta refresh or setTimeout reload)")
def check_shell() -> None:
step("assets/shell.html polling shell")
p = ROOT / "assets" / "shell.html"
if not p.is_file():
fail(f"missing: {p}")
return
body = p.read_text(encoding="utf-8")
# The shell must actively poll version.js and swap frame.html — that is the
# whole reason it exists. A shell that only meta-refreshes is a regression to
# the broken placeholder behavior, so assert the polling machinery is present.
polls_version = "version.js" in body
loads_frame = "frame.html" in body
has_poll_loop = bool(re.search(r"setInterval\s*\(", body))
if not polls_version:
fail("shell.html: does not reference version.js (no change detection)")
if not loads_frame:
fail("shell.html: does not reference frame.html (nothing to display)")
if not has_poll_loop:
fail("shell.html: no setInterval polling loop found")
if polls_version and loads_frame and has_poll_loop:
print(" ok: polls version.js, loads frame.html, has setInterval loop")
def sha256(p: Path) -> str:
h = hashlib.sha256()
h.update(p.read_bytes())
return h.hexdigest()
def check_package() -> None:
step("zergling.skill archive")
if not SKILL_PKG.is_file():
fail(f"missing: {SKILL_PKG}")
return
try:
z = zipfile.ZipFile(SKILL_PKG)
except zipfile.BadZipFile as e:
fail(f"zergling.skill is not a valid ZIP: {e}")
return
with z:
names = set(z.namelist())
print(f" entries: {sorted(names)}")
expected = set(EXPECTED_ENTRIES.keys())
extra = names - expected
missing = expected - names
if extra: fail(f"unexpected entries in package: {sorted(extra)}")
if missing: fail(f"missing entries in package: {sorted(missing)}")
if extra or missing:
return
tmp = Path(tempfile.mkdtemp(prefix="zergling-validate-"))
try:
z.extractall(tmp)
for arc, rel in EXPECTED_ENTRIES.items():
packed_path = tmp / arc.replace("/", os.sep)
source_path = ROOT / rel
if not source_path.is_file():
fail(f"tracked source missing: {source_path}")
continue
if sha256(packed_path) != sha256(source_path):
fail(f"package drift: {arc} differs from tracked {rel}")
else:
print(f" ok: {arc} matches {rel}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def check_agentskills_validator() -> None:
step("agentskills validate (optional)")
if not shutil.which("agentskills"):
print(" skip: `agentskills` not on PATH")
return
tmp = Path(tempfile.mkdtemp(prefix="zergling-agentskills-"))
try:
with zipfile.ZipFile(SKILL_PKG) as z:
z.extractall(tmp)
skill_dir = tmp / "zergling"
proc = subprocess.run(
["agentskills", "validate", str(skill_dir)],
capture_output=True, text=True,
)
out = (proc.stdout or "") + (proc.stderr or "")
print(out.rstrip() or " (no output)")
if proc.returncode != 0:
fail(f"agentskills validate failed (exit {proc.returncode})")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def main() -> int:
print(f"validating zergling at {ROOT}")
check_skill_md()
check_bootstrap()
check_shell()
check_package()
check_agentskills_validator()
print()
if warnings:
print(f"warnings ({len(warnings)}):")
for w in warnings:
print(f" - {w}")
if errors:
print(f"\nFAIL ({len(errors)} error{'s' if len(errors) != 1 else ''}):")
for e in errors:
print(f" - {e}")
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())