Skip to content

Commit e56c3ce

Browse files
TMHSDigitalclaude
andcommitted
fix: scope rules by file type and run CI checks from validate_plugin.py
Rule scoping (#33): - monobehaviour-lifecycle, naming-conventions, and performance-rules no longer use alwaysApply: true, which made Cursor ignore their **/*.cs globs and inject them into every conversation. - Visual scripting rule and skill match Script Graph / State Graph assets and VisualScripting/ folders instead of every .asset file. - Addressables, ScriptableObjects, UI, and security globs narrowed. - CI rejects alwaysApply: true combined with globs. Validation and docs (#37, #36): - New .github/scripts/validate_plugin.py replaces the inline Python in validate.yml, so the command in CLAUDE.md exists and contributors can run the CI checks locally. It also checks every count in README, CLAUDE.md, AGENTS.md, .cursorrules, and docs, and that every skill and rule file on disk is listed in the manifest. - Counts corrected: 19 snippets (the visual-scripting README is a guide), 7 workflows. - One contributing guide: root CONTRIBUTING.md (clone into the local plugins folder, validation, commit conventions); the docs page includes it. - Roadmap refreshed, .gitignore release-trigger comments removed, visual-scripting guide corrected (graphs are YAML, linearVelocity). - CLAUDE.md no longer implies a Claude Code plugin; mcp-server/README documents registering the server with Claude Code and other clients. - Security rule: no SOs for secrets (they ship in the build), IL2CPP instead of CoreCLR, no unverified package-signing claim. - Removed the unused mcp-server/tools stubs; CI lints with ruff. Closes #33 Closes #36 Closes #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpEiWjv38TrdFcgqU8ER5L
1 parent a07307d commit e56c3ce

31 files changed

Lines changed: 492 additions & 548 deletions

‎.cursorrules‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ You are working on Unity Developer Tools, a Cursor IDE plugin for Unity game dev
55
- `.cursor-plugin/plugin.json` - plugin manifest (version is auto-managed by CI)
66
- `skills/` - 18 SKILL.md files (project setup, MonoBehaviour, ScriptableObjects, physics, UI, shaders, animation, audio, input, networking, editor scripting, performance, render pipelines, ECS/DOTS, visual scripting, testing, addressables, platform targeting)
77
- `rules/` - 8 .mdc rule files (C# conventions, lifecycle, performance, naming, serialization, shaders, visual scripting, security)
8-
- `snippets/` - 20 code snippets organized by type (csharp, shaders, visual-scripting)
8+
- `snippets/` - 19 code snippets (csharp, shaders) plus a visual-scripting patterns guide
99
- `templates/` - 5 starter templates (2d-platformer, 3d-fps, ui-menu-system, scriptableobject-architecture, editor-tool)
1010
- `mcp-server/` - Python FastMCP server with 5 tools and JSON data files
1111
- `docs/` - architecture, roadmap, contributing, getting started
@@ -47,7 +47,7 @@ Never manually edit the version in `plugin.json` or the README version badge. Th
4747
## When editing the MCP server
4848

4949
- All tool logic lives in `mcp-server/server.py`.
50-
- Placeholder modules in `mcp-server/tools/` maintain directory structure.
50+
- Tests live in `mcp-server/tests/` (pytest, including an MCP stdio smoke test).
5151
- Data files in `mcp-server/data/` provide reference databases.
5252
- Dependencies are listed in `mcp-server/requirements.txt`.
5353

‎.github/scripts/refresh_unity_data.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
"""
1111

1212
import json
13-
import os
1413
import sys
1514
import urllib.request
1615
import urllib.error

‎.github/scripts/validate_plugin.py‎

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
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

Comments
 (0)