|
| 1 | +"""Validate plugin content. CI runs each check as its own job; run locally with no arguments to run them all. |
| 2 | +
|
| 3 | + python .github/scripts/validate_plugin.py # every check |
| 4 | + python .github/scripts/validate_plugin.py rules counts # selected checks |
| 5 | +
|
| 6 | +Exits non-zero and prints one ::error:: line per problem when a check fails. |
| 7 | +""" |
| 8 | + |
| 9 | +import json |
| 10 | +import os |
| 11 | +import re |
| 12 | +import subprocess |
| 13 | +import sys |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +ROOT = Path(__file__).resolve().parents[2] |
| 17 | +MANIFEST = ".cursor-plugin/plugin.json" |
| 18 | +DATA_DIR = "mcp-server/data" |
| 19 | + |
| 20 | + |
| 21 | +def read(rel): |
| 22 | + return (ROOT / rel).read_text(encoding="utf-8") |
| 23 | + |
| 24 | + |
| 25 | +def tracked_files(): |
| 26 | + # Only files git knows about, so local scratch files do not fail the checks |
| 27 | + out = subprocess.run(["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True).stdout |
| 28 | + return [p for p in out.splitlines() if (ROOT / p).is_file()] |
| 29 | + |
| 30 | + |
| 31 | +def frontmatter(rel): |
| 32 | + """Return (fields, body) for a file with --- delimited frontmatter, or (None, reason).""" |
| 33 | + content = read(rel) |
| 34 | + if not content.startswith("---"): |
| 35 | + return None, "missing YAML frontmatter" |
| 36 | + parts = content.split("---", 2) |
| 37 | + if len(parts) < 3: |
| 38 | + return None, "malformed frontmatter (no closing ---)" |
| 39 | + fields = {} |
| 40 | + for line in parts[1].splitlines(): |
| 41 | + key, sep, value = line.partition(":") |
| 42 | + if sep and key.strip() and not key.startswith((" ", "\t")): |
| 43 | + fields[key.strip()] = value.strip() |
| 44 | + return fields, parts[2].strip() |
| 45 | + |
| 46 | + |
| 47 | +def parse_globs(value): |
| 48 | + try: |
| 49 | + globs = json.loads(value) |
| 50 | + except (TypeError, ValueError): |
| 51 | + return None |
| 52 | + if isinstance(globs, list) and all(isinstance(g, str) for g in globs): |
| 53 | + return globs |
| 54 | + return None |
| 55 | + |
| 56 | + |
| 57 | +# Checks: each returns a list of error strings and may print a summary line |
| 58 | + |
| 59 | + |
| 60 | +def check_data(): |
| 61 | + schemas = { |
| 62 | + "unity_api_common.json": {"name": str, "namespace": str, "category": str, "description": str, "signature": str}, |
| 63 | + "deprecated_patterns.json": {"legacy": str, "replacement": str, "reason": str, "since_version": str}, |
| 64 | + "lifecycle_order.json": {"method": str, "phase": str, "description": str, "runs_per_frame": bool}, |
| 65 | + "shader_properties.json": {"effect": str, "description": str, "pipelines": list, "properties": list}, |
| 66 | + "platform_defines.json": {"platform": str, "display_name": str, "define": str, "capabilities": list}, |
| 67 | + } |
| 68 | + errors = [] |
| 69 | + for name, fields in schemas.items(): |
| 70 | + data = json.loads(read(f"{DATA_DIR}/{name}")) |
| 71 | + if not isinstance(data, list): |
| 72 | + errors.append(f"{name} must be an array") |
| 73 | + continue |
| 74 | + for i, entry in enumerate(data): |
| 75 | + for field, kind in fields.items(): |
| 76 | + if field not in entry: |
| 77 | + errors.append(f"{name}[{i}]: missing {field}") |
| 78 | + elif not isinstance(entry[field], kind): |
| 79 | + errors.append(f"{name}[{i}]: {field} must be {kind.__name__}") |
| 80 | + print(f"{name}: {len(data)} entries") |
| 81 | + return errors |
| 82 | + |
| 83 | + |
| 84 | +def check_manifest(): |
| 85 | + errors = [] |
| 86 | + m = json.loads(read(MANIFEST)) |
| 87 | + json.loads(read("mcp.json")) |
| 88 | + required = ["name", "displayName", "description", "version", "author", "license", "skills", "rules"] |
| 89 | + errors += [f"{MANIFEST}: missing field {f}" for f in required if f not in m] |
| 90 | + if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", m.get("name", "")): |
| 91 | + errors.append(f"{MANIFEST}: name must be lowercase kebab-case") |
| 92 | + if not re.match(r"^\d+\.\d+\.\d+$", m.get("version", "")): |
| 93 | + errors.append(f"{MANIFEST}: version must be semver (X.Y.Z)") |
| 94 | + if not (isinstance(m.get("author"), dict) and "name" in m["author"]): |
| 95 | + errors.append(f"{MANIFEST}: author must be an object with a name") |
| 96 | + if not isinstance(m.get("keywords"), list): |
| 97 | + errors.append(f"{MANIFEST}: keywords must be an array") |
| 98 | + |
| 99 | + for key in ("skills", "rules"): |
| 100 | + for path in m.get(key, []): |
| 101 | + if not (ROOT / path).exists(): |
| 102 | + errors.append(f"{MANIFEST}: {key} entry not found: {path}") |
| 103 | + listed = set(m.get("skills", [])) |
| 104 | + for skill in sorted((ROOT / "skills").glob("*/SKILL.md")): |
| 105 | + rel = skill.relative_to(ROOT).as_posix() |
| 106 | + if rel not in listed: |
| 107 | + errors.append(f"{MANIFEST}: {rel} exists but is not listed under skills") |
| 108 | + listed = set(m.get("rules", [])) |
| 109 | + for rule in sorted((ROOT / "rules").glob("*.mdc")): |
| 110 | + rel = rule.relative_to(ROOT).as_posix() |
| 111 | + if rel not in listed: |
| 112 | + errors.append(f"{MANIFEST}: {rel} exists but is not listed under rules") |
| 113 | + |
| 114 | + config = m.get("mcpServers") |
| 115 | + if not (isinstance(config, str) and (ROOT / config).exists()): |
| 116 | + errors.append(f"{MANIFEST}: mcpServers must point to an existing file, got {config!r}") |
| 117 | + else: |
| 118 | + servers = json.loads(read(config)).get("mcpServers") or {} |
| 119 | + if not servers: |
| 120 | + errors.append(f"{config}: no MCP servers registered") |
| 121 | + for name, server in servers.items(): |
| 122 | + for arg in server.get("args", []): |
| 123 | + if arg.startswith("${CURSOR_PLUGIN_ROOT}/"): |
| 124 | + path = arg[len("${CURSOR_PLUGIN_ROOT}/"):] |
| 125 | + if not (ROOT / path).exists(): |
| 126 | + errors.append(f"{config}: {name}: {path} not found") |
| 127 | + if os.path.isabs(arg): |
| 128 | + errors.append(f"{config}: {name}: absolute path {arg} breaks other installs") |
| 129 | + print(f"MCP servers registered: {', '.join(servers)}") |
| 130 | + print(f"Manifest lists {len(m.get('skills', []))} skills and {len(m.get('rules', []))} rules") |
| 131 | + return errors |
| 132 | + |
| 133 | + |
| 134 | +def check_skills(): |
| 135 | + errors = [] |
| 136 | + dirs = sorted(d for d in (ROOT / "skills").iterdir() if d.is_dir()) |
| 137 | + for d in dirs: |
| 138 | + rel = f"skills/{d.name}/SKILL.md" |
| 139 | + if not (ROOT / rel).exists(): |
| 140 | + errors.append(f"{rel}: SKILL.md missing") |
| 141 | + continue |
| 142 | + fields, body = frontmatter(rel) |
| 143 | + if fields is None: |
| 144 | + errors.append(f"{rel}: {body}") |
| 145 | + continue |
| 146 | + for field in ("title", "description", "globs", "standards-version"): |
| 147 | + if field not in fields: |
| 148 | + errors.append(f"{rel}: frontmatter missing {field}") |
| 149 | + if "globs" in fields and parse_globs(fields["globs"]) is None: |
| 150 | + errors.append(f"{rel}: globs must be a JSON-style list of strings") |
| 151 | + if len(body) < 100: |
| 152 | + errors.append(f"{rel}: body too short ({len(body)} chars, minimum 100)") |
| 153 | + print(f"Checked {len(dirs)} skills") |
| 154 | + return errors |
| 155 | + |
| 156 | + |
| 157 | +def check_rules(): |
| 158 | + errors = [] |
| 159 | + files = sorted((ROOT / "rules").glob("*.mdc")) |
| 160 | + for path in files: |
| 161 | + rel = path.relative_to(ROOT).as_posix() |
| 162 | + fields, body = frontmatter(rel) |
| 163 | + if fields is None: |
| 164 | + errors.append(f"{rel}: {body}") |
| 165 | + continue |
| 166 | + for field in ("title", "description", "globs", "alwaysApply", "standards-version"): |
| 167 | + if field not in fields: |
| 168 | + errors.append(f"{rel}: frontmatter missing {field}") |
| 169 | + always = fields.get("alwaysApply") |
| 170 | + if always not in ("true", "false"): |
| 171 | + errors.append(f"{rel}: alwaysApply must be true or false") |
| 172 | + globs = parse_globs(fields.get("globs", "")) |
| 173 | + if globs is None: |
| 174 | + errors.append(f"{rel}: globs must be a JSON-style list of strings") |
| 175 | + elif always == "true" and globs: |
| 176 | + # Cursor ignores globs on always-applied rules, so the scope would silently be everything |
| 177 | + errors.append(f"{rel}: alwaysApply: true ignores globs; set alwaysApply: false or use globs: []") |
| 178 | + if len(body) < 20: |
| 179 | + errors.append(f"{rel}: body too short ({len(body)} chars)") |
| 180 | + print(f"Checked {len(files)} rules") |
| 181 | + return errors |
| 182 | + |
| 183 | + |
| 184 | +DASH_EXTS = (".md", ".mdc", ".cs", ".json", ".py", ".shader", ".hlsl", ".uxml", ".uss") |
| 185 | +CREDENTIAL = re.compile( |
| 186 | + r"""password\s*=\s*["'][^"']+|api_key\s*=\s*["'][^"']+|token\s*=\s*["'][A-Za-z0-9]+""", re.IGNORECASE |
| 187 | +) |
| 188 | +CREDENTIAL_ALLOW = ("example", "placeholder", "mock", "destroyCancellationToken") |
| 189 | +FILE_SCOPED_NAMESPACE = re.compile(r"^\s*namespace\s+[A-Za-z0-9_.]+\s*;", re.MULTILINE) |
| 190 | +CSHARP10_ADVICE = re.compile(r"\b(use|prefer)\s+(file-scoped namespaces|primary constructors)", re.IGNORECASE) |
| 191 | + |
| 192 | + |
| 193 | +def check_content(): |
| 194 | + errors = [] |
| 195 | + files = tracked_files() |
| 196 | + for rel in files: |
| 197 | + if not rel.endswith(DASH_EXTS): |
| 198 | + continue |
| 199 | + text = read(rel) |
| 200 | + for n, line in enumerate(text.splitlines(), 1): |
| 201 | + if "–" in line or "—" in line: |
| 202 | + errors.append(f"{rel}:{n}: em or en dash; use a hyphen or rewrite") |
| 203 | + if rel.endswith((".cs", ".py", ".json")) and CREDENTIAL.search(line): |
| 204 | + if not any(word in line for word in CREDENTIAL_ALLOW): |
| 205 | + errors.append(f"{rel}:{n}: possible hardcoded credential") |
| 206 | + if rel.endswith(".cs") and FILE_SCOPED_NAMESPACE.search(text): |
| 207 | + errors.append(f"{rel}: file-scoped namespaces are C# 10; Unity 6 compiles C# 9") |
| 208 | + if rel.endswith((".md", ".mdc")) and rel.split("/")[0] in ("skills", "rules", "snippets", "templates"): |
| 209 | + if CSHARP10_ADVICE.search(text): |
| 210 | + errors.append(f"{rel}: recommends C# 10+ features that Unity 6 cannot compile") |
| 211 | + |
| 212 | + snippets = [p for p in (ROOT / "snippets").rglob("*") if p.is_file() and p.name != "README.md"] |
| 213 | + for p in snippets: |
| 214 | + if p.stat().st_size < 10: |
| 215 | + errors.append(f"{p.relative_to(ROOT).as_posix()}: snippet is empty or too small") |
| 216 | + print(f"Scanned {len(files)} tracked files and {len(snippets)} snippets") |
| 217 | + return errors |
| 218 | + |
| 219 | + |
| 220 | +def check_templates(): |
| 221 | + errors = [] |
| 222 | + dirs = sorted(d for d in (ROOT / "templates").iterdir() if d.is_dir()) |
| 223 | + for d in dirs: |
| 224 | + rel = f"templates/{d.name}" |
| 225 | + if not (d / "README.md").exists(): |
| 226 | + errors.append(f"{rel}: missing README.md") |
| 227 | + scripts = sorted(d.glob("*.cs")) |
| 228 | + if not scripts: |
| 229 | + errors.append(f"{rel}: no .cs files found") |
| 230 | + for cs in scripts: |
| 231 | + if len(cs.read_text(encoding="utf-8")) < 50: |
| 232 | + errors.append(f"{rel}/{cs.name}: file too small") |
| 233 | + print(f"Checked {len(dirs)} templates") |
| 234 | + return errors |
| 235 | + |
| 236 | + |
| 237 | +# Files whose counts must match the repo. Release-history lines (v1.0.0 etc.) are skipped. |
| 238 | +COUNT_FILES = ["README.md", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md", ".cursorrules", |
| 239 | + "docs/index.md", "docs/ARCHITECTURE.md", "docs/GETTING-STARTED.md"] |
| 240 | +HISTORY_LINE = re.compile(r"\bv\d+\.\d+\.\d+\b") |
| 241 | + |
| 242 | + |
| 243 | +def actual_counts(): |
| 244 | + m = json.loads(read(MANIFEST)) |
| 245 | + snippets = [p for p in (ROOT / "snippets").rglob("*") if p.is_file() and p.name != "README.md"] |
| 246 | + return { |
| 247 | + "skills": len(m.get("skills", [])), |
| 248 | + "rules": len(m.get("rules", [])), |
| 249 | + "snippets": len(snippets), |
| 250 | + "csharp": sum(1 for p in snippets if p.suffix == ".cs"), |
| 251 | + "shaders": sum(1 for p in snippets if p.suffix in (".shader", ".hlsl")), |
| 252 | + "templates": sum(1 for d in (ROOT / "templates").iterdir() if (d / "README.md").exists()), |
| 253 | + "tools": len(re.findall(r"^@mcp\.tool\(\)", read("mcp-server/server.py"), re.MULTILINE)), |
| 254 | + "workflows": len(list((ROOT / ".github" / "workflows").glob("*.yml"))), |
| 255 | + } |
| 256 | + |
| 257 | + |
| 258 | +COUNT_PATTERNS = [ |
| 259 | + (re.compile(r"\b(\d+) (?:\*\*)?skills\b|## Skills \((\d+)\)"), "skills"), |
| 260 | + (re.compile(r"\b(\d+) (?:\*\*)?rules\b|## Rules \((\d+)\)"), "rules"), |
| 261 | + (re.compile(r"\b(\d+) (?:code )?snippets\b|## Snippets \((\d+)\)"), "snippets"), |
| 262 | + (re.compile(r"C# \((\d+)\)"), "csharp"), |
| 263 | + (re.compile(r"Shaders \((\d+)\)"), "shaders"), |
| 264 | + (re.compile(r"\b(\d+) (?:starter )?templates\b|## Templates \((\d+)\)"), "templates"), |
| 265 | + (re.compile(r"\b(\d+) (?:MCP )?tools\b|Available Tools \((\d+)\)"), "tools"), |
| 266 | + (re.compile(r"\b(\d+) workflows\b"), "workflows"), |
| 267 | +] |
| 268 | + |
| 269 | + |
| 270 | +def check_counts(): |
| 271 | + errors = [] |
| 272 | + actual = actual_counts() |
| 273 | + for rel in COUNT_FILES: |
| 274 | + if not (ROOT / rel).exists(): |
| 275 | + continue |
| 276 | + for n, line in enumerate(read(rel).splitlines(), 1): |
| 277 | + if HISTORY_LINE.search(line): |
| 278 | + continue |
| 279 | + for pattern, key in COUNT_PATTERNS: |
| 280 | + for match in pattern.finditer(line): |
| 281 | + claimed = int(next(g for g in match.groups() if g)) |
| 282 | + if claimed != actual[key]: |
| 283 | + errors.append(f"{rel}:{n}: says {claimed} {key}, repo has {actual[key]}") |
| 284 | + print("Counts: " + ", ".join(f"{v} {k}" for k, v in actual.items())) |
| 285 | + return errors |
| 286 | + |
| 287 | + |
| 288 | +CHECKS = { |
| 289 | + "data": check_data, |
| 290 | + "manifest": check_manifest, |
| 291 | + "skills": check_skills, |
| 292 | + "rules": check_rules, |
| 293 | + "content": check_content, |
| 294 | + "templates": check_templates, |
| 295 | + "counts": check_counts, |
| 296 | +} |
| 297 | + |
| 298 | + |
| 299 | +def main(argv): |
| 300 | + names = argv or list(CHECKS) |
| 301 | + unknown = [n for n in names if n not in CHECKS] |
| 302 | + if unknown: |
| 303 | + print(f"Unknown check(s): {', '.join(unknown)}. Valid: {', '.join(CHECKS)}", file=sys.stderr) |
| 304 | + return 2 |
| 305 | + failed = False |
| 306 | + for name in names: |
| 307 | + print(f"== {name}") |
| 308 | + errors = CHECKS[name]() |
| 309 | + for e in errors: |
| 310 | + print(f"::error::{e}") |
| 311 | + failed |= bool(errors) |
| 312 | + print("FAILED" if failed else "All checks passed") |
| 313 | + return 1 if failed else 0 |
| 314 | + |
| 315 | + |
| 316 | +if __name__ == "__main__": |
| 317 | + sys.exit(main(sys.argv[1:])) |
0 commit comments