大改版,可以针对不同项目引擎进行初始化

This commit is contained in:
Nostars Developer
2026-07-27 15:09:13 +08:00
parent 6446261c69
commit 62efb3778c
112 changed files with 11020 additions and 899 deletions

View File

@@ -0,0 +1,726 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "tools" / "cursor_init.py"
PROJECT_ROOT = SCRIPT_PATH.parents[3]
sys.dont_write_bytecode = True
SPEC = importlib.util.spec_from_file_location("cursor_init_under_test", SCRIPT_PATH)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"无法加载 {SCRIPT_PATH}")
CURSOR_INIT = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = CURSOR_INIT
SPEC.loader.exec_module(CURSOR_INIT)
class CursorInitTests(unittest.TestCase):
def _write_text(self, root: Path, relative: str, content: str) -> None:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="\n")
def _write_json(self, root: Path, relative: str, data: object) -> None:
self._write_text(
root,
relative,
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
)
def _fixture(self) -> tuple[tempfile.TemporaryDirectory[str], Path]:
temporary = tempfile.TemporaryDirectory()
root = Path(temporary.name)
bootstrap = ".cursor/bootstrap"
self._write_json(
root,
f"{bootstrap}/catalog.json",
{
"schema_version": 1,
"skill_version": 5,
"common_manifest": "common.manifest.json",
"skills_catalog": "skills.json",
"types": [
{
"id": "general",
"label": "通用",
"status": "ready",
"manifest": "types/general/manifest.json",
},
{
"id": "godot",
"label": "Godot",
"status": "ready",
"manifest": "types/godot/manifest.json",
},
{
"id": "roblox",
"label": "Roblox",
"status": "ready",
"manifest": "types/roblox/manifest.json",
},
],
},
)
self._write_json(
root,
f"{bootstrap}/common.manifest.json",
{
"schema_version": 1,
"id": "common",
"version": 1,
"canonical_files": [
".cursor/rules/common/base.mdc",
".cursor/rules/project/.gitkeep",
".cursor/skills/epee-orchestrator/SKILL.md",
".cursor/pitfalls/pitfalls.md",
],
"skills": ["epee-orchestrator"],
"local_env_fields": [
{
"key": "shell",
"example": "powershell",
"default": "powershell",
"required": True,
"prompt": "shell",
},
{
"key": "changelog-author",
"example": "<handle>",
"default": "",
"required": True,
"prompt": "author",
},
],
"scan": {
"included_names": [],
"included_extensions": [".json"],
"excluded_directories": [".git", ".cursor"],
},
"gitignore_fragments": ["fragments/common.gitignore"],
"gitattributes_fragments": ["fragments/common.gitattributes"],
"reset_files": [
{
"source": "templates/pitfalls.md",
"target": ".cursor/pitfalls/pitfalls.md",
}
],
"cleanup_globs": [".cursor/changelog/entries/**/*.md"],
},
)
self._write_json(
root,
f"{bootstrap}/skills.json",
{
"schema_version": 1,
"skills": [
{
"id": "epee-orchestrator",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/epee-orchestrator",
"capability": "测试 common Skill",
"triggers": "测试",
"output": "测试输出",
},
{
"id": "godot-helper",
"scope": "godot",
"type": "项目级",
"target": ".cursor/skills/godot-helper",
"capability": "测试 Godot Skill",
"triggers": "Godot 测试",
"output": "Godot 测试输出",
},
{
"id": "roblox-helper",
"scope": "roblox",
"type": "项目级",
"target": ".cursor/skills/roblox-helper",
"capability": "测试 Roblox Skill",
"triggers": "Roblox 测试",
"output": "测试输出",
},
],
},
)
self._write_json(
root,
f"{bootstrap}/types/general/manifest.json",
{
"schema_version": 1,
"id": "general",
"version": 1,
"readiness": {"allow_empty_payload": True},
"files": [],
"trees": [],
"skills": [],
"local_env_fields": [],
"scan": {
"included_names": [],
"included_extensions": [],
"excluded_directories": [],
},
"gitignore_fragments": [],
"gitattributes_fragments": [],
},
)
self._write_json(
root,
f"{bootstrap}/types/godot/manifest.json",
{
"schema_version": 1,
"id": "godot",
"version": 1,
"readiness": {
"allow_empty_payload": False,
"require_scan": True,
"require_gitignore": True,
},
"files": [
{
"source": "payload/rules/godot.mdc",
"target": ".cursor/rules/project/godot.mdc",
}
],
"trees": [
{
"source": "payload/skills/godot-helper",
"target": ".cursor/skills/godot-helper",
}
],
"skills": ["godot-helper"],
"local_env_fields": [
{
"key": "godot4_path",
"example": "<godot>",
"default": "",
"required": False,
"prompt": "Godot",
}
],
"scan": {
"included_names": ["project.godot"],
"included_extensions": [".gd"],
"excluded_directories": [".godot"],
},
"gitignore_fragments": ["fragments/godot.gitignore"],
"gitattributes_fragments": ["fragments/godot.gitattributes"],
},
)
self._write_json(
root,
f"{bootstrap}/types/roblox/manifest.json",
{
"schema_version": 1,
"id": "roblox",
"version": 1,
"readiness": {
"allow_empty_payload": False,
"require_scan": True,
"require_gitignore": True,
},
"files": [
{
"source": "payload/rules/roblox.mdc",
"target": ".cursor/rules/project/roblox.mdc",
}
],
"trees": [
{
"source": "payload/skills/roblox-helper",
"target": ".cursor/skills/roblox-helper",
}
],
"skills": ["roblox-helper"],
"local_env_fields": [
{
"key": "roblox_open_cloud_api_key",
"example": "<roblox-api-key>",
"default": "",
"required": False,
"sensitive": True,
"collect_at_init": False,
"prompt": "Roblox API Key",
}
],
"scan": {
"included_names": ["default.project.json"],
"included_extensions": [".luau"],
"excluded_directories": ["Packages"],
},
"gitignore_fragments": ["fragments/roblox.gitignore"],
"gitattributes_fragments": ["fragments/roblox.gitattributes"],
},
)
self._write_text(
root,
f"{bootstrap}/fragments/common.gitignore",
".cursor/local-env.json\n",
)
self._write_text(
root,
f"{bootstrap}/fragments/common.gitattributes",
"*.json text eol=lf\n",
)
self._write_text(
root,
f"{bootstrap}/templates/pitfalls.md",
"# Pitfall Journal\n\n---\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/fragments/godot.gitignore",
".godot/\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/fragments/godot.gitattributes",
"*.gd text eol=lf\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/payload/rules/godot.mdc",
"# Godot\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/payload/skills/godot-helper/SKILL.md",
"---\nname: godot-helper\ndescription: test\n---\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/fragments/roblox.gitignore",
"Packages/\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/fragments/roblox.gitattributes",
"*.luau text eol=lf\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/payload/rules/roblox.mdc",
"# Roblox\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/payload/skills/roblox-helper/SKILL.md",
"---\nname: roblox-helper\ndescription: test\n---\n",
)
self._write_text(root, ".cursor/rules/common/base.mdc", "# Base\n")
self._write_text(root, ".cursor/rules/project/.gitkeep", "")
self._write_text(
root,
".cursor/skills/epee-orchestrator/SKILL.md",
"---\nname: epee-orchestrator\ndescription: test\n---\n",
)
self._write_text(root, ".cursor/pitfalls/pitfalls.md", "# Old pitfalls\n")
self._write_text(
root,
".cursor/changelog/entries/tester/old.md",
"# Old changelog\n",
)
return temporary, root
def _plan(self, root: Path, project_type: str):
context = CURSOR_INIT.load_context(root)
plan = CURSOR_INIT.build_plan(
context,
project_type,
values={"shell": "powershell", "changelog-author": "tester"},
)
return context, plan
def _apply(self, root: Path, project_type: str):
context, plan = self._plan(root, project_type)
self.assertEqual(plan.blockers, [])
CURSOR_INIT.apply_plan(context, plan, plan.digest)
return context, plan
def test_list_types_includes_ready_roblox(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context = CURSOR_INIT.load_context(root)
self.assertEqual(
CURSOR_INIT.list_ready_types(context),
[
{"id": "general", "label": "通用"},
{"id": "godot", "label": "Godot"},
{"id": "roblox", "label": "Roblox"},
],
)
def test_empty_roblox_bundle_cannot_be_marked_ready(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
manifest_path = root / ".cursor/bootstrap/types/roblox/manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["files"] = []
manifest["trees"] = []
manifest["skills"] = []
manifest["scan"] = {
"included_names": [],
"included_extensions": [],
"excluded_directories": [],
}
manifest["gitignore_fragments"] = []
manifest["gitattributes_fragments"] = []
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.load_context(root)
def test_general_apply_is_idempotent(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context, _ = self._apply(root, "general")
second_plan = CURSOR_INIT.build_plan(
context,
"general",
values={"shell": "powershell", "changelog-author": "tester"},
)
self.assertEqual(second_plan.blockers, [])
self.assertEqual(second_plan.actions, [])
self.assertEqual(CURSOR_INIT.check_project(context), [])
def test_godot_to_general_removes_type_files(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
self.assertTrue((root / ".cursor/rules/project/godot.mdc").is_file())
self.assertTrue((root / ".cursor/skills/godot-helper/SKILL.md").is_file())
self._apply(root, "general")
self.assertFalse((root / ".cursor/rules/project/godot.mdc").exists())
self.assertFalse((root / ".cursor/skills/godot-helper/SKILL.md").exists())
scan = json.loads(
(root / ".cursor/hooks/changelog-scan.json").read_text(encoding="utf-8")
)
self.assertEqual(scan["project_type"], "general")
def test_roblox_materializes_only_for_roblox_and_switch_removes_it(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "roblox")
self.assertTrue((root / ".cursor/rules/project/roblox.mdc").is_file())
self.assertTrue(
(root / ".cursor/skills/roblox-helper/SKILL.md").is_file()
)
registry = (
root / ".cursor/skills/epee-orchestrator/registry.md"
).read_text(encoding="utf-8")
self.assertIn("roblox-helper", registry)
example = json.loads(
(root / ".cursor/local-env.example.json").read_text(encoding="utf-8")
)
self.assertEqual(
example["roblox_open_cloud_api_key"],
"<roblox-api-key>",
)
local_env = json.loads(
(root / ".cursor/local-env.json").read_text(encoding="utf-8")
)
self.assertEqual(local_env["roblox_open_cloud_api_key"], "")
self._apply(root, "general")
self.assertFalse((root / ".cursor/rules/project/roblox.mdc").exists())
self.assertFalse((root / ".cursor/skills/roblox-helper/SKILL.md").exists())
general_example = json.loads(
(root / ".cursor/local-env.example.json").read_text(encoding="utf-8")
)
self.assertNotIn("roblox_open_cloud_api_key", general_example)
def test_godot_and_roblox_cross_switches_remove_old_bundle(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
self._apply(root, "roblox")
self.assertFalse((root / ".cursor/rules/project/godot.mdc").exists())
self.assertFalse((root / ".cursor/skills/godot-helper/SKILL.md").exists())
self.assertTrue((root / ".cursor/rules/project/roblox.mdc").is_file())
self._apply(root, "godot")
self.assertFalse((root / ".cursor/rules/project/roblox.mdc").exists())
self.assertFalse((root / ".cursor/skills/roblox-helper/SKILL.md").exists())
self.assertTrue((root / ".cursor/rules/project/godot.mdc").is_file())
def test_sensitive_value_cannot_be_passed_with_set(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context = CURSOR_INIT.load_context(root)
fields = CURSOR_INIT._all_env_fields(
context,
context.types["roblox"],
)
with self.assertRaisesRegex(CURSOR_INIT.InitError, "敏感字段"):
CURSOR_INIT._validate_input_values(
fields,
{"roblox_open_cloud_api_key": "do-not-accept"},
)
def test_sensitive_local_value_is_preserved_and_not_drift(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_json(
root,
".cursor/local-env.json",
{
"shell": "powershell",
"changelog-author": "tester",
"roblox_open_cloud_api_key": "local-secret",
},
)
self._apply(root, "roblox")
local_env_path = root / ".cursor/local-env.json"
local_env = json.loads(local_env_path.read_text(encoding="utf-8"))
self.assertEqual(local_env["roblox_open_cloud_api_key"], "local-secret")
local_env["roblox_open_cloud_api_key"] = "rotated-secret"
self._write_json(root, ".cursor/local-env.json", local_env)
context = CURSOR_INIT.load_context(root)
self.assertEqual(CURSOR_INIT.check_project(context, "roblox"), [])
def test_sensitive_value_rotation_does_not_change_plan_digest(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_json(
root,
".cursor/local-env.json",
{"roblox_open_cloud_api_key": "first-secret"},
)
context, first_plan = self._plan(root, "roblox")
self._write_json(
root,
".cursor/local-env.json",
{"roblox_open_cloud_api_key": "rotated-secret"},
)
context, rotated_plan = self._plan(root, "roblox")
self.assertEqual(first_plan.digest, rotated_plan.digest)
serialized = json.dumps(CURSOR_INIT.plan_as_dict(rotated_plan))
self.assertNotIn("first-secret", serialized)
self.assertNotIn("rotated-secret", serialized)
self.assertIn("<local-sensitive-content>", serialized)
def test_plan_digest_mismatch_is_rejected(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context, plan = self._plan(root, "general")
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.apply_plan(context, plan, "incorrect")
self.assertFalse((root / ".cursor/.init-done").exists())
def test_foreign_rule_blocks_apply(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_text(root, ".cursor/rules/project/foreign.mdc", "# Foreign\n")
context, plan = self._plan(root, "general")
self.assertTrue(plan.foreign)
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.apply_plan(context, plan, plan.digest)
self.assertFalse((root / ".cursor/.init-done").exists())
def test_modified_managed_file_blocks_type_switch(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
self._write_text(
root,
".cursor/rules/project/godot.mdc",
"# User modified\n",
)
_, plan = self._plan(root, "general")
self.assertTrue(
any("拒绝删除" in blocker for blocker in plan.blockers),
plan.blockers,
)
def test_apply_resets_state_and_removes_history(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "general")
self.assertEqual(
(root / ".cursor/pitfalls/pitfalls.md").read_text(encoding="utf-8"),
"# Pitfall Journal\n\n---\n",
)
self.assertFalse(
(root / ".cursor/changelog/entries/tester/old.md").exists()
)
def test_missing_common_canonical_blocks_apply(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
(root / ".cursor/rules/common/base.mdc").unlink()
context, plan = self._plan(root, "general")
self.assertTrue(
any("缺少 common canonical" in blocker for blocker in plan.blockers),
plan.blockers,
)
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.apply_plan(context, plan, plan.digest)
self.assertFalse((root / ".cursor/.init-done").exists())
def test_skip_git_files_preserves_user_files(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_text(root, ".gitignore", "# user\n")
context = CURSOR_INIT.load_context(root)
plan = CURSOR_INIT.build_plan(
context,
"general",
values={"shell": "powershell", "changelog-author": "tester"},
skip_git_files=True,
)
self.assertEqual(plan.blockers, [])
CURSOR_INIT.apply_plan(context, plan, plan.digest)
self.assertEqual((root / ".gitignore").read_text(encoding="utf-8"), "# user\n")
sentinel = (root / ".cursor/.init-done").read_text(encoding="utf-8")
self.assertIn("gitignore_generated: false", sentinel)
def test_generated_text_is_utf8_without_bom_and_uses_lf(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
generated = [
".cursor/.init-done",
".cursor/.init-state.json",
".cursor/hooks/changelog-scan.json",
".cursor/local-env.example.json",
".cursor/local-env.json",
".cursor/skills/epee-orchestrator/registry.md",
".gitignore",
".gitattributes",
]
for relative in generated:
with self.subTest(path=relative):
content = (root / relative).read_bytes()
self.assertFalse(content.startswith(b"\xef\xbb\xbf"))
self.assertNotIn(b"\r\n", content)
content.decode("utf-8")
def test_repository_roblox_bundle_is_ready_clean_and_complete(self) -> None:
context = CURSOR_INIT.load_context(PROJECT_ROOT)
roblox = context.types["roblox"]
self.assertEqual(roblox.status, "ready")
self.assertEqual(len(roblox.manifest["skills"]), 10)
specs = CURSOR_INIT._expand_manifest_files(roblox)
targets = {spec.target for spec in specs}
self.assertIn(
".cursor/rules/project/roblox-luau-guardrails.mdc",
targets,
)
for skill_id in roblox.manifest["skills"]:
with self.subTest(skill=skill_id):
self.assertIn(
f".cursor/skills/{skill_id}/SKILL.md",
targets,
)
payload_root = (
PROJECT_ROOT / ".cursor/bootstrap/types/roblox/payload"
)
forbidden = ("Codex", ".codex", "xi.chen", "SwordnWizard")
for path in payload_root.rglob("*"):
if not path.is_file():
continue
with self.subTest(path=path.relative_to(PROJECT_ROOT)):
self.assertNotEqual(path.name, "openai.yaml")
content = path.read_bytes()
self.assertFalse(content.startswith(b"\xef\xbb\xbf"))
self.assertNotIn(b"\r\n", content)
text = content.decode("utf-8")
for marker in forbidden:
self.assertNotIn(marker, text)
def test_repository_payload_applies_and_cross_switches_in_temp_target(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
shutil.copytree(PROJECT_ROOT / ".cursor", root / ".cursor")
local_env_path = root / ".cursor/local-env.json"
if local_env_path.exists():
local_env_path.unlink()
for name in (".gitignore", ".gitattributes"):
source = PROJECT_ROOT / name
if source.is_file():
shutil.copy2(source, root / name)
for project_type in ("roblox", "general", "godot", "roblox"):
context = CURSOR_INIT.load_context(root)
plan = CURSOR_INIT.build_plan(
context,
project_type,
values={
"shell": "powershell",
"changelog-author": "integration-test",
},
)
self.assertEqual(plan.blockers, [], project_type)
CURSOR_INIT.apply_plan(context, plan, plan.digest)
checked_context = CURSOR_INIT.load_context(root)
self.assertEqual(
CURSOR_INIT.check_project(checked_context, project_type),
[],
project_type,
)
roblox_skill = (
root
/ ".cursor/skills/roblox-project-initializer/SKILL.md"
)
if project_type == "roblox":
self.assertTrue(roblox_skill.is_file())
example = json.loads(
(
root / ".cursor/local-env.example.json"
).read_text(encoding="utf-8")
)
self.assertEqual(
example["roblox_open_cloud_api_key"],
"<your-open-cloud-api-key>",
)
else:
self.assertFalse(roblox_skill.exists())
example = json.loads(
(
root / ".cursor/local-env.example.json"
).read_text(encoding="utf-8")
)
self.assertNotIn("roblox_open_cloud_api_key", example)
if __name__ == "__main__":
unittest.main()