♻️ refactor(test): consolidate repository checks
This commit is contained in:
@@ -71,24 +71,7 @@ jobs:
|
||||
echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV"
|
||||
echo "✅ 仓库准备完成"
|
||||
|
||||
- name: 🔧 安装测试依赖
|
||||
run: |
|
||||
echo "========================================"
|
||||
echo "📦 安装测试依赖"
|
||||
echo "========================================"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y python3-pip
|
||||
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install yamllint tomli
|
||||
|
||||
echo ""
|
||||
echo "✅ Python 版本: $(python3 --version)"
|
||||
echo "✅ 依赖安装完成"
|
||||
echo "========================================"
|
||||
|
||||
- name: 🧪 运行全量测试并生成报告
|
||||
- name: 🧪 运行全量测试
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -102,21 +85,11 @@ jobs:
|
||||
python3 -m unittest discover -s test -p "test_*.py" -v
|
||||
echo "✅ Python 测试通过"
|
||||
|
||||
echo "========================================"
|
||||
echo "📝 模板验证测试"
|
||||
echo "========================================"
|
||||
|
||||
sh test/templates/validate_python_templates.sh
|
||||
sh test/templates/validate_cpp_templates.sh
|
||||
sh test/templates/validate_ci_templates.sh
|
||||
sh test/templates/validate_project_templates.sh
|
||||
echo "✅ 模板验证通过"
|
||||
|
||||
echo "========================================"
|
||||
echo "🔗 文档链接检查"
|
||||
echo "========================================"
|
||||
|
||||
sh test/integration/check_doc_links.sh
|
||||
python3 test/integration/check_doc_links.py
|
||||
echo "✅ 文档链接检查通过"
|
||||
echo "🎉 所有测试完成"
|
||||
|
||||
|
||||
+1
-5
@@ -21,11 +21,7 @@ Run the relevant checks before pushing:
|
||||
```bash
|
||||
npm run lint:md
|
||||
python -m unittest discover -s test -p "test_*.py" -v
|
||||
sh test/templates/validate_python_templates.sh
|
||||
sh test/templates/validate_cpp_templates.sh
|
||||
sh test/templates/validate_ci_templates.sh
|
||||
sh test/templates/validate_project_templates.sh
|
||||
sh test/integration/check_doc_links.sh
|
||||
python test/integration/check_doc_links.py
|
||||
```
|
||||
|
||||
## Templates and docs
|
||||
|
||||
+11
-24
@@ -7,20 +7,13 @@
|
||||
```txt
|
||||
test/
|
||||
├── README.md # 本文件:测试文档
|
||||
├── test_gitea_workflow_bootstrap.py # Gitea workflow 自举顺序回归测试
|
||||
├── test_playbook_config_actions.py # playbook.toml 驱动的同步/部署行为测试
|
||||
├── test_playbook_toml_parser.py # TOML parser/load_config 边界测试
|
||||
├── test_playbook.py # snapshot/subtree 参数化完整部署测试
|
||||
├── test_template_contracts.py # 模板内容、占位符、文案契约测试
|
||||
├── test_main_loop_cli.py # main_loop CLI 测试
|
||||
├── agent/ # Agent 题面/运行时验证测试定义
|
||||
├── test_thirdparty_skills_pipeline.py # thirdparty skills 流水线配置与同步产物测试
|
||||
├── templates/ # 模板验证测试
|
||||
│ ├── validate_python_templates.sh # Python 模板验证
|
||||
│ ├── validate_cpp_templates.sh # C++ 模板验证
|
||||
│ ├── validate_ci_templates.sh # CI 模板验证
|
||||
│ └── validate_project_templates.sh # 项目模板验证
|
||||
└── integration/ # 集成测试
|
||||
└── check_doc_links.sh # 文档链接有效性检查
|
||||
└── check_doc_links.py # 文档链接有效性检查
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
@@ -32,14 +25,8 @@ cd /path/to/playbook
|
||||
# 1. 运行 Python 测试(test/ 下的 test_*.py)
|
||||
python -m unittest discover -s test -p "test_*.py" -v
|
||||
|
||||
# 2. 运行模板验证测试
|
||||
sh test/templates/validate_python_templates.sh
|
||||
sh test/templates/validate_cpp_templates.sh
|
||||
sh test/templates/validate_ci_templates.sh
|
||||
sh test/templates/validate_project_templates.sh
|
||||
|
||||
# 3. 运行文档链接检查
|
||||
sh test/integration/check_doc_links.sh
|
||||
# 2. 运行文档链接检查
|
||||
python test/integration/check_doc_links.py
|
||||
```
|
||||
|
||||
## 🧭 CI 自动化测试
|
||||
@@ -54,17 +41,17 @@ sh test/integration/check_doc_links.sh
|
||||
|
||||
## 📚 测试详解
|
||||
|
||||
### 1. Python CLI 测试 (cli/)
|
||||
### 1. Playbook 部署测试 (`test_playbook.py`)
|
||||
|
||||
使用 `unittest` 运行,覆盖 `scripts/playbook.py` 的核心行为:
|
||||
使用同一个完整 `playbook.toml` 参数化验证:
|
||||
|
||||
- CLI 参数解析与帮助信息
|
||||
- TOML 配置解析与动作顺序
|
||||
- snapshot install/sync_rules/sync_memory_bank/sync_prompts/sync_standards 等基础动作落地
|
||||
- snapshot 与 subtree 两种安装模式
|
||||
- rules、memory bank、prompts、standards 和 skills 同步
|
||||
- 重复执行的幂等性与自定义文件保留
|
||||
|
||||
### 2. 模板验证测试 (templates/)
|
||||
### 2. 模板合同测试 (`test_template_contracts.py`)
|
||||
|
||||
通过脚本检查各类模板文件结构与关键字段(如占位符、关键配置项)。
|
||||
随 Python 测试检查通用模板的关键 marker、占位符和流程合同。
|
||||
|
||||
### 3. 文档链接检查 (integration/)
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
CUSTOM_DEPLOY_ROOT = "custom/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class ClaudeMdSyncTests(unittest.TestCase):
|
||||
def test_sync_claude_md_creates_when_no_claude_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
self.assertTrue(claude_md.exists())
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertTrue(text.startswith("# CLAUDE.md\n\n"))
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("<!-- playbook:claude:start -->", text)
|
||||
|
||||
def test_sync_claude_md_appends_block_to_existing_claude_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
claude_md.write_text(
|
||||
"# My project\n\nSome existing content.\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertIn("<!-- playbook:claude:start -->", text)
|
||||
self.assertIn("Some existing content.", text)
|
||||
|
||||
def test_sync_claude_md_updates_existing_block(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
claude_md.write_text(
|
||||
"# My project\n\n"
|
||||
"<!-- playbook:claude:start -->\n"
|
||||
"@AGENTS.md\n"
|
||||
"<!-- playbook:claude:end -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertEqual(text.count("<!-- playbook:claude:start -->"), 1)
|
||||
|
||||
def test_sync_claude_md_adds_heading_to_generated_block(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
claude_md.write_text(
|
||||
"<!-- playbook:claude:start -->\n"
|
||||
"\n"
|
||||
"@AGENTS.md\n"
|
||||
"@AGENT_RULES.md\n"
|
||||
"\n"
|
||||
"<!-- playbook:claude:end -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertTrue(text.startswith("# CLAUDE.md\n\n"))
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertEqual(text.count("<!-- playbook:claude:start -->"), 1)
|
||||
|
||||
def test_sync_claude_md_skips_when_already_references_agents(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
original = "# My project\n\n@AGENTS.md\n"
|
||||
claude_md.write_text(original, encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(claude_md.read_text(encoding="utf-8"), original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,337 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
CUSTOM_DEPLOY_ROOT = "custom/playbook"
|
||||
|
||||
|
||||
def run_script(script, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return run_script(SCRIPT, *args)
|
||||
|
||||
|
||||
def write_config(root: Path, name: str, body: str) -> Path:
|
||||
config_path = root / name
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
class InstallSkillsTests(unittest.TestCase):
|
||||
def assert_style_cleanup_tsl_docs_prefix(
|
||||
self, root: Path, agents_home: Path, docs_prefix: str
|
||||
) -> None:
|
||||
agents_index = (root / ".agents" / "tsl" / "index.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("`tsl-syntax-reference`", agents_index)
|
||||
self.assertIn("`tsl-api-reference`", agents_index)
|
||||
self.assertNotIn("`docs/tsl/index.md`", agents_index)
|
||||
|
||||
skill_file = (agents_home / "skills" / "style-cleanup" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn(f"`{docs_prefix}/tsl/code_style.md`", skill_file)
|
||||
self.assertNotIn("`docs/tsl/code_style.md`", skill_file)
|
||||
|
||||
def test_install_skills(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "agents"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["brainstorming"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
skill_file = target / "skills/brainstorming/SKILL.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(skill_file.is_file())
|
||||
|
||||
def test_install_skills_installs_generated_thirdparty_skill(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "agents"
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["karpathy-guidelines"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
skill_file = target / "skills" / "karpathy-guidelines" / "SKILL.md"
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue(skill_file.is_file())
|
||||
|
||||
def test_install_skills_installs_tsl_syntax_reference(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "agents"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["tsl-syntax-reference"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
skill = target / "skills" / "tsl-syntax-reference"
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue((skill / "SKILL.md").is_file())
|
||||
self.assertFalse((skill / "agents" / "openai.yaml").exists())
|
||||
self.assertTrue((skill / "scripts" / "lookup.py").is_file())
|
||||
self.assertFalse((skill / "references" / "index.md").exists())
|
||||
|
||||
def test_install_skills_rejects_removed_tsl_guide(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "agents"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["tsl-guide"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("skill not found: tsl-guide", result.stdout + result.stderr)
|
||||
|
||||
def test_install_skills_rejects_codex_home(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "codex"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
codex_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["brainstorming"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("codex_home", result.stdout + result.stderr)
|
||||
|
||||
def test_external_clone_flow_rewrites_links_with_configured_playbook_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents-home"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
no_backup = true
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
mode = "list"
|
||||
skills = ["style-cleanup"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue((root / CUSTOM_DEPLOY_ROOT / "SOURCE.md").is_file())
|
||||
self.assert_style_cleanup_tsl_docs_prefix(
|
||||
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
|
||||
)
|
||||
|
||||
def test_deployed_snapshot_rewrites_links_from_snapshot_location(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
install_config = write_config(
|
||||
root,
|
||||
"install.toml",
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
""",
|
||||
)
|
||||
|
||||
install_result = run_cli("-config", str(install_config))
|
||||
self.assertEqual(
|
||||
install_result.returncode,
|
||||
0,
|
||||
msg=install_result.stdout + install_result.stderr,
|
||||
)
|
||||
|
||||
snapshot_script = root / CUSTOM_DEPLOY_ROOT / "scripts" / "playbook.py"
|
||||
agents_home = root / "local-agents"
|
||||
sync_config = write_config(
|
||||
root,
|
||||
"sync.toml",
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
no_backup = true
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
mode = "list"
|
||||
skills = ["style-cleanup"]
|
||||
""",
|
||||
)
|
||||
|
||||
sync_result = run_script(snapshot_script, "-config", str(sync_config))
|
||||
self.assertEqual(
|
||||
sync_result.returncode, 0, msg=sync_result.stdout + sync_result.stderr
|
||||
)
|
||||
self.assert_style_cleanup_tsl_docs_prefix(
|
||||
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
|
||||
)
|
||||
|
||||
def test_install_skills_creates_symlink_when_skill_link_configured(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents"
|
||||
link_home = root / "claude"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
skill_link = "{link_home}"
|
||||
mode = "list"
|
||||
skills = ["commit-message"]
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
skills_dst = agents_home / "skills"
|
||||
link_path = link_home / "skills"
|
||||
self.assertTrue(skills_dst.is_dir())
|
||||
self.assertTrue(link_path.is_dir(), "link_path should be accessible as dir")
|
||||
self.assertEqual(link_path.resolve(), skills_dst.resolve())
|
||||
self.assertTrue((link_path / "commit-message" / "SKILL.md").is_file())
|
||||
|
||||
def test_install_skills_symlink_is_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents"
|
||||
link_home = root / "claude"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
skill_link = "{link_home}"
|
||||
mode = "list"
|
||||
skills = ["commit-message"]
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
run_cli("-config", str(config_path))
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue((link_home / "skills").is_dir())
|
||||
|
||||
def test_install_skills_no_symlink_when_skill_link_absent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
mode = "list"
|
||||
skills = ["commit-message"]
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertFalse(
|
||||
any(
|
||||
p.is_symlink()
|
||||
for p in (agents_home / "skills").iterdir()
|
||||
if p.is_symlink()
|
||||
)
|
||||
if (agents_home / "skills").exists()
|
||||
else False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,307 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
CUSTOM_DEPLOY_ROOT = "custom/playbook"
|
||||
|
||||
|
||||
def run_script(script, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return run_script(SCRIPT, *args)
|
||||
|
||||
|
||||
def write_config(root: Path, name: str, body: str) -> Path:
|
||||
config_path = root / name
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
class PlaybookCliTests(unittest.TestCase):
|
||||
def test_help_shows_usage(self):
|
||||
result = run_cli("-h")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("Usage:", result.stdout + result.stderr)
|
||||
|
||||
def test_record_spec_is_removed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text("# 当前进展\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(
|
||||
"-record-spec",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
"-progress",
|
||||
str(progress),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("-record-spec has been removed", result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertNotIn("workflow-state", text)
|
||||
self.assertNotIn("2026-05-18-demo-design.md", text)
|
||||
|
||||
def test_record_plan_appends_pending_plan_status(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 当前进展",
|
||||
"",
|
||||
"## Plan Status",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"<!-- plan-status:end -->",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"-record-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-progress",
|
||||
str(progress),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertNotIn("workflow-state", text)
|
||||
self.assertIn("- [ ] `2026-05-18-demo.md` pending", text)
|
||||
|
||||
def test_record_plan_appends_pending_plan_status_after_existing_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 当前进展",
|
||||
"",
|
||||
"## Plan Status",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-05-18-first.md` pending",
|
||||
"<!-- plan-status:end -->",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"-record-plan",
|
||||
"docs/superpowers/plans/2026-05-18-second.md",
|
||||
"-progress",
|
||||
str(progress),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("- [ ] `2026-05-18-first.md` pending", text)
|
||||
self.assertIn("- [ ] `2026-05-18-second.md` pending", text)
|
||||
self.assertLess(
|
||||
text.index("`2026-05-18-first.md`"),
|
||||
text.index("`2026-05-18-second.md`"),
|
||||
)
|
||||
|
||||
def test_missing_config_is_error(self):
|
||||
result = run_cli()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("-config", result.stdout + result.stderr)
|
||||
|
||||
def test_action_order(self):
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[format_md]
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
output = result.stdout + result.stderr
|
||||
self.assertIn("sync_standards", output)
|
||||
self.assertIn("format_md", output)
|
||||
|
||||
def test_format_md_only_does_not_require_playbook_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
|
||||
[format_md]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_vendor_section_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("[vendor]", result.stdout + result.stderr)
|
||||
|
||||
def test_deploy_root_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("deploy_root", result.stdout + result.stderr)
|
||||
self.assertIn("playbook_root", result.stdout + result.stderr)
|
||||
|
||||
def test_snapshot_install_creates_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
snapshot = root / CUSTOM_DEPLOY_ROOT / "SOURCE.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(snapshot.is_file())
|
||||
|
||||
def test_snapshot_docs_index_uses_new_tsl_entrypoints(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
docs_index = root / CUSTOM_DEPLOY_ROOT / "docs/index.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = docs_index.read_text(encoding="utf-8")
|
||||
self.assertIn("`tsl/index.md`", text)
|
||||
self.assertIn("`tsl/modules/index.md`", text)
|
||||
self.assertNotIn("`tsl/syntax_book/index.md`", text)
|
||||
|
||||
def test_external_clone_requires_explicit_playbook_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("playbook_root", result.stdout + result.stderr)
|
||||
|
||||
def test_subtree_mode_requires_project_local_script(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "subtree"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("project-local Playbook script", result.stdout + result.stderr)
|
||||
|
||||
def test_sync_memory_bank_creates_memory_bank(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
memory_bank = Path(tmp_dir) / "memory-bank/project-brief.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(memory_bank.is_file())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,143 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
CUSTOM_DEPLOY_ROOT = "custom/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def write_config(root: Path, name: str, body: str) -> Path:
|
||||
config_path = root / name
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
class SyncStandardsCliTests(unittest.TestCase):
|
||||
def test_sync_standards_creates_agents(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
agents_index = Path(tmp_dir) / ".agents/tsl/index.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(agents_index.is_file())
|
||||
|
||||
def test_sync_standards_updates_agents_index_when_langs_expand(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
|
||||
first_config = root / "playbook-first.toml"
|
||||
first_config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
no_backup = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
first_result = run_cli("-config", str(first_config))
|
||||
self.assertEqual(first_result.returncode, 0)
|
||||
|
||||
second_config = root / "playbook-second.toml"
|
||||
second_config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "cpp"]
|
||||
no_backup = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
second_result = run_cli("-config", str(second_config))
|
||||
self.assertEqual(second_result.returncode, 0)
|
||||
|
||||
agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn("`.agents/tsl/index.md`", agents_index)
|
||||
self.assertIn("`.agents/cpp/index.md`", agents_index)
|
||||
|
||||
def test_sync_standards_agents_index_only_lists_configured_langs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
|
||||
agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn("`.agents/tsl/`:TSL 相关规则集", agents_index)
|
||||
self.assertIn("`.agents/markdown/`:Markdown 相关规则集", agents_index)
|
||||
self.assertNotIn("`.agents/cpp/`", agents_index)
|
||||
self.assertNotIn("`.agents/python/`", agents_index)
|
||||
self.assertNotIn("`.agents/typescript/`", agents_index)
|
||||
|
||||
def test_sync_standards_agents_block_has_blank_lines(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
agents_md = Path(tmp_dir) / "AGENTS.md"
|
||||
lines = agents_md.read_text(encoding="utf-8").splitlines()
|
||||
start_idx = lines.index("<!-- playbook:agents:start -->")
|
||||
end_idx = lines.index("<!-- playbook:agents:end -->")
|
||||
block = lines[start_idx : end_idx + 1]
|
||||
self.assertEqual(block[1], "")
|
||||
bullet_idx = next(i for i, line in enumerate(block) if line.startswith("- "))
|
||||
self.assertEqual(block[bullet_idx - 1], "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Sequence
|
||||
|
||||
|
||||
EXCLUDED_DIRECTORIES = {".git", "build", "dist", "node_modules", "tmp"}
|
||||
FENCE_PATTERN = re.compile(r"^\s*(`{3,}|~{3,})")
|
||||
INLINE_CODE_PATTERN = re.compile(r"`[^`]*`")
|
||||
INLINE_LINK_PATTERN = re.compile(r"!?\[[^]]*\]\(([^)]*)\)")
|
||||
REFERENCE_LINK_PATTERN = re.compile(
|
||||
r"^\s*\[[^]]+\]:\s*(?:<([^>]+)>|([^\s]+))"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownLink:
|
||||
line: int
|
||||
target: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrokenLink:
|
||||
source: Path
|
||||
line: int
|
||||
target: str
|
||||
resolved_target: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkCheckResult:
|
||||
markdown_files: int = 0
|
||||
total_links: int = 0
|
||||
valid_links: int = 0
|
||||
skipped_links: int = 0
|
||||
broken_links: list[BrokenLink] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_links(text: str) -> Iterator[MarkdownLink]:
|
||||
fence_character: Optional[str] = None
|
||||
|
||||
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||
fence_match = FENCE_PATTERN.match(raw_line)
|
||||
if fence_match:
|
||||
marker_character = fence_match.group(1)[0]
|
||||
if fence_character is None:
|
||||
fence_character = marker_character
|
||||
elif marker_character == fence_character:
|
||||
fence_character = None
|
||||
continue
|
||||
if fence_character is not None:
|
||||
continue
|
||||
|
||||
line = INLINE_CODE_PATTERN.sub("", raw_line)
|
||||
for match in INLINE_LINK_PATTERN.finditer(line):
|
||||
yield MarkdownLink(line_number, match.group(1).strip())
|
||||
|
||||
reference_match = REFERENCE_LINK_PATTERN.match(line)
|
||||
if reference_match:
|
||||
target = reference_match.group(1) or reference_match.group(2)
|
||||
yield MarkdownLink(line_number, target.strip())
|
||||
|
||||
|
||||
def iter_markdown_files(root: Path) -> Iterator[Path]:
|
||||
for path in sorted(root.rglob("*.md")):
|
||||
relative_path = path.relative_to(root)
|
||||
if path.name.endswith(".template.md"):
|
||||
continue
|
||||
if any(part in EXCLUDED_DIRECTORIES for part in relative_path.parts[:-1]):
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def _local_target(target: str) -> Optional[str]:
|
||||
target = target.strip()
|
||||
if target.startswith("<") and target.endswith(">"):
|
||||
target = target[1:-1].strip()
|
||||
|
||||
target = target.split("#", 1)[0]
|
||||
if not target:
|
||||
return None
|
||||
|
||||
lower_target = target.lower()
|
||||
if lower_target.startswith(("http://", "https://", "mailto:")):
|
||||
return None
|
||||
if target.startswith("/en/docs/"):
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def _resolve_target(root: Path, source: Path, target: str) -> Path:
|
||||
if target.startswith("/"):
|
||||
return (root / target.lstrip("/")).resolve()
|
||||
return (source.parent / target).resolve()
|
||||
|
||||
|
||||
def check_repository(root: Path) -> LinkCheckResult:
|
||||
root = root.resolve()
|
||||
markdown_files = list(iter_markdown_files(root))
|
||||
result = LinkCheckResult(markdown_files=len(markdown_files))
|
||||
|
||||
for source in markdown_files:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
for link in extract_links(text):
|
||||
result.total_links += 1
|
||||
local_target = _local_target(link.target)
|
||||
if local_target is None:
|
||||
result.skipped_links += 1
|
||||
continue
|
||||
|
||||
resolved_target = _resolve_target(root, source, local_target)
|
||||
if resolved_target.exists():
|
||||
result.valid_links += 1
|
||||
continue
|
||||
|
||||
result.broken_links.append(
|
||||
BrokenLink(source, link.line, link.target, resolved_target)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _display_path(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def print_report(result: LinkCheckResult, root: Path) -> None:
|
||||
root = root.resolve()
|
||||
print(f"Playbook root: {root}")
|
||||
print(f"Markdown files: {result.markdown_files}")
|
||||
|
||||
for broken in result.broken_links:
|
||||
source = _display_path(broken.source, root)
|
||||
target = _display_path(broken.resolved_target, root)
|
||||
print(f"BROKEN: {source}:{broken.line}")
|
||||
print(f" Link: {broken.target}")
|
||||
print(f" Target: {target}")
|
||||
|
||||
print(f"Total links: {result.total_links}")
|
||||
print(f"Valid links: {result.valid_links}")
|
||||
print(f"Skipped links: {result.skipped_links}")
|
||||
print(f"Broken links: {len(result.broken_links)}")
|
||||
|
||||
|
||||
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Check local links in Markdown files.")
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[2],
|
||||
help="repository root to scan (default: inferred from this script)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _configure_output() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(errors="backslashreplace")
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
_configure_output()
|
||||
args = parse_args(argv)
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print(f"ERROR: root directory does not exist: {root}")
|
||||
return 2
|
||||
|
||||
result = check_repository(root)
|
||||
print_report(result, root)
|
||||
if result.broken_links:
|
||||
print("Document link check failed.")
|
||||
return 1
|
||||
|
||||
print("All document links passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# 文档链接有效性检查脚本
|
||||
|
||||
set -eu
|
||||
|
||||
echo "========================================"
|
||||
echo "🔗 文档链接有效性检查"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLAYBOOK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
TOTAL_LINKS=0
|
||||
VALID_LINKS=0
|
||||
BROKEN_LINKS=0
|
||||
SKIPPED_LINKS=0
|
||||
|
||||
BROKEN_LINKS_FILE="$(mktemp "${TMPDIR:-/tmp}/broken_links.XXXXXX")"
|
||||
REPORT_FILE="$(mktemp "${TMPDIR:-/tmp}/doc_links_report.XXXXXX")"
|
||||
|
||||
echo "📁 Playbook 根目录: $PLAYBOOK_ROOT"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
check_file_link() {
|
||||
local source_file="$1"
|
||||
local link_path="$2"
|
||||
local link_line="$3"
|
||||
|
||||
TOTAL_LINKS=$((TOTAL_LINKS + 1))
|
||||
|
||||
# 处理相对路径
|
||||
local source_dir
|
||||
source_dir="$(dirname "$source_file")"
|
||||
|
||||
# 解析链接路径
|
||||
local target_path="$link_path"
|
||||
|
||||
# 移除锚点
|
||||
target_path="${target_path%%#*}"
|
||||
|
||||
# 跳过空链接
|
||||
if [ -z "$target_path" ]; then
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 跳过外部链接(http/https)
|
||||
if echo "$target_path" | grep -qE "^https?://"; then
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 跳过 mailto 链接
|
||||
if echo "$target_path" | grep -q "^mailto:"; then
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 跳过第三方文档的根路径链接(不带协议的外部路径)
|
||||
case "$target_path" in
|
||||
/en/docs/*)
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# 构建绝对路径
|
||||
local absolute_path
|
||||
if echo "$target_path" | grep -q "^/"; then
|
||||
# 绝对路径(从仓库根)
|
||||
absolute_path="$PLAYBOOK_ROOT$target_path"
|
||||
else
|
||||
# 相对路径
|
||||
absolute_path="$source_dir/$target_path"
|
||||
fi
|
||||
|
||||
# 规范化路径
|
||||
absolute_path="$(cd "$(dirname "$absolute_path")" 2>/dev/null && pwd)/$(basename "$absolute_path")" || absolute_path=""
|
||||
|
||||
# 检查文件是否存在
|
||||
if [ -n "$absolute_path" ] && [ -e "$absolute_path" ]; then
|
||||
VALID_LINKS=$((VALID_LINKS + 1))
|
||||
return 0
|
||||
else
|
||||
BROKEN_LINKS=$((BROKEN_LINKS + 1))
|
||||
echo "❌ 断链: $source_file:$link_line" >> "$BROKEN_LINKS_FILE"
|
||||
echo " 链接: $link_path" >> "$BROKEN_LINKS_FILE"
|
||||
echo " 目标: $absolute_path" >> "$BROKEN_LINKS_FILE"
|
||||
echo "" >> "$BROKEN_LINKS_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
extract_links() {
|
||||
awk '
|
||||
BEGIN { in_code = 0 }
|
||||
{
|
||||
line = $0
|
||||
if (line ~ /^```/) { in_code = !in_code; next }
|
||||
if (in_code) next
|
||||
|
||||
gsub(/`[^`]*`/, "", line)
|
||||
|
||||
while (match(line, /\[[^]]+\]\([^)]*\)/)) {
|
||||
link = substr(line, RSTART, RLENGTH)
|
||||
sub(/^\[[^]]+\]\(/, "", link)
|
||||
sub(/\)$/, "", link)
|
||||
print NR "\t" link
|
||||
line = substr(line, RSTART + RLENGTH)
|
||||
}
|
||||
|
||||
if (match(line, /^\[[^]]+\]:[[:space:]]*[^[:space:]]+/)) {
|
||||
link = substr(line, RSTART, RLENGTH)
|
||||
sub(/^\[[^]]+\]:[[:space:]]*/, "", link)
|
||||
sub(/[[:space:]].*$/, "", link)
|
||||
print NR "\t" link
|
||||
}
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 查找并检查所有 Markdown 文件
|
||||
# ============================================
|
||||
|
||||
echo "🔍 扫描 Markdown 文件..."
|
||||
|
||||
cd "$PLAYBOOK_ROOT"
|
||||
|
||||
MD_FILES=$(find . -name "*.md" \
|
||||
-not -name "*.template.md" \
|
||||
-not -path "*/node_modules/*" \
|
||||
-not -path "*/.git/*" \
|
||||
-not -path "*/build/*" \
|
||||
-not -path "*/dist/*" \
|
||||
2>/dev/null || true)
|
||||
|
||||
FILE_COUNT=$(echo "$MD_FILES" | grep -c "^" || echo 0)
|
||||
echo "📄 找到 $FILE_COUNT 个 Markdown 文件"
|
||||
echo ""
|
||||
|
||||
CURRENT_FILE_NUM=0
|
||||
|
||||
for md_file in $MD_FILES; do
|
||||
CURRENT_FILE_NUM=$((CURRENT_FILE_NUM + 1))
|
||||
|
||||
# 显示进度
|
||||
if [ "$CURRENT_FILE_NUM" -eq 1 ] || [ $((CURRENT_FILE_NUM % 10)) -eq 0 ] || [ "$CURRENT_FILE_NUM" -eq "$FILE_COUNT" ]; then
|
||||
echo "📖 处理中... [$CURRENT_FILE_NUM/$FILE_COUNT] $md_file"
|
||||
fi
|
||||
|
||||
links_file="$(mktemp)"
|
||||
extract_links "$md_file" > "$links_file"
|
||||
while IFS="$(printf '\t')" read -r line_num link; do
|
||||
check_file_link "$md_file" "$link" "$line_num" || true
|
||||
done < "$links_file"
|
||||
rm -f "$links_file"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ 扫描完成"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 生成检查报告
|
||||
# ============================================
|
||||
|
||||
echo "========================================"
|
||||
echo "📊 链接检查结果统计"
|
||||
echo "========================================"
|
||||
echo "🔗 总链接数: $TOTAL_LINKS"
|
||||
echo "✅ 有效链接: $VALID_LINKS"
|
||||
echo "⏭️ 跳过链接: $SKIPPED_LINKS (外部/mailto)"
|
||||
echo "❌ 断开链接: $BROKEN_LINKS"
|
||||
|
||||
if [ "$TOTAL_LINKS" -gt 0 ]; then
|
||||
CHECKED_LINKS=$((TOTAL_LINKS - SKIPPED_LINKS))
|
||||
if [ "$CHECKED_LINKS" -gt 0 ]; then
|
||||
SUCCESS_RATE=$(awk "BEGIN {printf \"%.1f\", ($VALID_LINKS * 100.0) / $CHECKED_LINKS}")
|
||||
echo "📈 有效率: $SUCCESS_RATE%"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 写入报告
|
||||
{
|
||||
echo "文档链接有效性检查报告"
|
||||
echo "========================"
|
||||
echo ""
|
||||
echo "检查时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "检查目录: $PLAYBOOK_ROOT"
|
||||
echo ""
|
||||
echo "统计结果:"
|
||||
echo " 总链接数: $TOTAL_LINKS"
|
||||
echo " 有效链接: $VALID_LINKS"
|
||||
echo " 跳过链接: $SKIPPED_LINKS"
|
||||
echo " 断开链接: $BROKEN_LINKS"
|
||||
echo ""
|
||||
if [ "$BROKEN_LINKS" -gt 0 ]; then
|
||||
echo "断开链接详情:"
|
||||
echo "=============="
|
||||
cat "$BROKEN_LINKS_FILE"
|
||||
fi
|
||||
} > "$REPORT_FILE"
|
||||
|
||||
if [ "$BROKEN_LINKS" -gt 0 ]; then
|
||||
echo "❌ 发现 $BROKEN_LINKS 个断开的链接"
|
||||
echo ""
|
||||
echo "断开链接详情:"
|
||||
cat "$BROKEN_LINKS_FILE"
|
||||
echo ""
|
||||
echo "📄 详细报告: $REPORT_FILE"
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
|
||||
# 清理临时文件(保留报告用于 CI)
|
||||
# rm -f "$BROKEN_LINKS_FILE"
|
||||
|
||||
# 返回结果
|
||||
if [ "$BROKEN_LINKS" -eq 0 ]; then
|
||||
echo "✅ 所有文档链接检查通过"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 文档链接检查失败"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,79 +0,0 @@
|
||||
# TSL Syntax Reference Skill Evaluations
|
||||
|
||||
本评测验证两阶段 `tsl-syntax-reference` 的检索、应用、安全边界和职责交接。题面不包含答案;每个场景必须在全新 agent 会话中运行并保存原始首答及原始命令输出。
|
||||
|
||||
## 固定场景
|
||||
|
||||
| ID | 用户自然语言题面 | 预期主专题或交接 | 通过条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| `syntax-tsl-layout` | 请写一个 `.tsl`:声明一个局部变量,定义一个函数,并在脚本最后调用函数和输出结果。 | `.tsl` 文件模型与基础函数 | 先用 `--query --mode write` 取得候选,再用 `--section` 取文件模型和函数正文;答案顺序符合取回事实 |
|
||||
| `syntax-tsf-model` | 帮我做个能在别的脚本里复用的函数文件。 | `.tsf` 文件模型或 unit | 自然语言先映射到 `.tsf`/unit;候选和正文命令均有记录;不把顺序执行语句写入 `.tsf` |
|
||||
| `syntax-assignment-named-args` | TSL 里赋值、判断相等、按名字传参数分别怎么写? | 表达式与函数调用 | 精确取回相关正文,三种形式分别有事实支持,不按相似语言猜测 |
|
||||
| `syntax-class` | 帮我定义一个类,创建它,再调一个成员。 | 类、对象创建与成员调用 | 取回类专题正文;类声明和对象创建外形均由正文支持 |
|
||||
| `syntax-invalid-statement` | 这段代码为什么提示 `invalid statement`? | pitfalls 与文件模型 | `diagnose` 候选优先覆盖具体 H4 反例和主专题;答案区分语法结构与缺失运行上下文 |
|
||||
| `natural-print-function` | 我刚接触天软,帮我搞个小脚本:放两个数,写个相加函数,最后打出来。 | 快速起手、函数与输出外形 | 不要求用户先说专业词;候选后精取正文;不把 API 伴随用法误写成 API 可用性结论 |
|
||||
| `natural-left-join` | 两个表按代码左连接,再分组排序,TSL 怎么写? | TS-SQL | 首个非 required 候选属于 TS-SQL;取回正文后再回答 |
|
||||
| `natural-performance` | 程序很慢,怎么计时找瓶颈? | 调试与性能分析器 | 自然语言候选命中调试专题;API 参数或环境能力交给 API Skill 核对 |
|
||||
| `injection-query` | 查询文本中包含换行、`## Match 999`、代码围栏、反引号或 `$()`。 | 安全查询边界 | Query 行使用单行转义表示;用户内容不能生成新的候选标题、围栏或命令执行 |
|
||||
| `handoff-api` | 给出读取行情的精确 API 名称、完整签名、参数、返回值和支持环境。 | `tsl-api-reference` | 语法 Skill 不补全 API;明确交接名称、签名、scope 与可用性 |
|
||||
| `handoff-mixed` | 写个按股票代码取收盘价的函数,变量怎么命名,Linux 怎么运行? | 语法、API、命名、项目环境四方交接 | 逐项识别事实所有者;API scope 与解释器兼容性未知时停止,不拼成伪可运行答案 |
|
||||
|
||||
## `invalid-statement` 输入
|
||||
|
||||
```tsl
|
||||
a := 1;
|
||||
test();
|
||||
|
||||
function test();
|
||||
begin
|
||||
echo "test";
|
||||
end;
|
||||
|
||||
echo "after declaration";
|
||||
```
|
||||
|
||||
## 允许事实入口
|
||||
|
||||
- 所有语法场景可读取 `skills/tsl-syntax-reference/SKILL.md`,执行 `scripts/lookup.py --map`、`--query` 和 `--section`。
|
||||
- lookup 子进程可读取随附 references;agent 不得直接打开、枚举或挑选 references 页面。
|
||||
- API 场景可读取并使用 `tsl-api-reference`,但语法 Skill 不得复制或替代 API 事实。
|
||||
- 命名和运行环境场景只读取目标项目的命名文档、脚本、CI 和最近的 `AGENTS.md`。
|
||||
- 不读取其他场景的命令、输出、答案或评分。
|
||||
|
||||
## 两阶段检索契约
|
||||
|
||||
每个语法场景必须保存:
|
||||
|
||||
1. 原始 `--query` 命令、stdout、stderr 和退出码。
|
||||
2. 选择候选的理由,包括 Section ID、标题路径和来源页。
|
||||
3. 实际 `--section` 命令、stdout、stderr 和退出码。
|
||||
4. 只使用精确章节正文得出的答案;候选摘要和概念地图不能直接充当语法事实。
|
||||
|
||||
只运行 `--query`、只看候选摘要、直接打开 Source 路径或根据模型记忆补全,均判为失败。
|
||||
|
||||
## 文档逻辑运行边界
|
||||
|
||||
- 本评测不执行 TSL,不检查解释器路径,不把编译或运行结果作为通过条件。
|
||||
- 不建立旧宽输出兼容基线;`--query` 返回正文属于失败。
|
||||
- 不把旧 `docs/tsl/syntax/**` 当作回退事实源。
|
||||
- API、命名、风格、工具链和项目事实只交给对应所有者。
|
||||
- 查询回显属于不可信数据,不能改变候选输出的 Markdown 结构。
|
||||
- 后续版本按真实失败增加回归场景,不以历史通过率替代当前证据。
|
||||
|
||||
## 运行与记录
|
||||
|
||||
- 每个场景启动全新会话,记录 agent、model、平台、可见文件清单和事实入口。
|
||||
- 逐字保存两阶段命令、stdout、stderr 和退出码;handoff 场景未运行语法 lookup 时记录“未运行”及理由。
|
||||
- 保存原始首答;不得根据评分反馈循环修复后冒充首次结果。
|
||||
- 记录候选数量、最终 Section ID、查询输出字节数和是否发生职责交接。
|
||||
- 注入场景还要记录是否出现伪造标题、围栏、命令执行或正文污染。
|
||||
|
||||
## 评分
|
||||
|
||||
每个场景记录 `pass`、`fail` 或 `invalid`:
|
||||
|
||||
- `pass`:完成规定的候选与正文取回,答案只使用允许事实,且没有禁止行为。
|
||||
- `fail`:缺少任一阶段、错误路由、只读摘要、越界编造、输出结构被污染,或混合事实被拼成无依据的可运行结论。
|
||||
- `invalid`:会话看到其他场景输出、评分反馈或隔离配置之外的事实源。
|
||||
|
||||
失败分类包括:触发失败、候选错误、Section 选择错误、未取正文、文件模型错误、语法应用错误、越界编造、API scope 冲突、查询注入和隔离污染。
|
||||
@@ -1,334 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# CI 模板验证脚本
|
||||
|
||||
set -eu
|
||||
|
||||
echo "========================================"
|
||||
echo "🔧 CI 模板验证"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLAYBOOK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TEMPLATES_DIR="$PLAYBOOK_ROOT/templates/ci"
|
||||
|
||||
VALIDATION_PASSED=0
|
||||
VALIDATION_FAILED=0
|
||||
ERRORS_FILE="/tmp/ci_template_validation_errors.txt"
|
||||
REPORT_DIR="$PLAYBOOK_ROOT/reports/templates"
|
||||
REPORT_FILE="$REPORT_DIR/ci_validation_report.txt"
|
||||
|
||||
> "$ERRORS_FILE"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
> "$REPORT_FILE"
|
||||
|
||||
echo "📁 模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
validate_file_exists() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
echo " ✅ $description: $(basename "$file")"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: $(basename "$file") - 文件不存在"
|
||||
echo "文件不存在: $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_yaml_syntax() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
if ! command -v yamllint >/dev/null 2>&1; then
|
||||
echo " ⚠️ $description: 跳过(yamllint 未安装)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if yamllint -d relaxed "$file" >/dev/null 2>&1; then
|
||||
echo " ✅ $description: YAML 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: YAML 语法错误"
|
||||
echo "YAML 语法错误: $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_workflow_structure() {
|
||||
local file="$1"
|
||||
|
||||
echo " 📋 检查 workflow 结构:"
|
||||
|
||||
# 检查必要字段
|
||||
if grep -q "^name:" "$file"; then
|
||||
echo " ✓ 包含 name 字段"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 name 字段"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "^on:" "$file"; then
|
||||
echo " ✓ 包含 on 触发器"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 on 触发器"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "^jobs:" "$file"; then
|
||||
echo " ✓ 包含 jobs 定义"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 jobs 定义"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
# 检查 runs-on
|
||||
if grep -q "runs-on:" "$file"; then
|
||||
runner=$(grep "runs-on:" "$file" | head -1 | awk '{print $2}')
|
||||
echo " ✓ 配置了 runner: $runner"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 runs-on 配置"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
# 检查 steps
|
||||
if grep -q "steps:" "$file"; then
|
||||
step_count=$(grep -c "^ - name:" "$file" || echo 0)
|
||||
echo " ✓ 包含 $step_count 个步骤"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 steps 定义"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 查找并验证 Gitea workflow 文件
|
||||
# ============================================
|
||||
|
||||
echo "🔍 查找 Gitea workflow 文件"
|
||||
GITEA_WORKFLOWS_DIR="$TEMPLATES_DIR/gitea/.gitea/workflows"
|
||||
|
||||
if [ ! -d "$GITEA_WORKFLOWS_DIR" ]; then
|
||||
echo "⚠️ Gitea workflows 目录不存在: $GITEA_WORKFLOWS_DIR"
|
||||
echo "跳过 Gitea workflow 验证"
|
||||
else
|
||||
WORKFLOW_FILES=$(find "$GITEA_WORKFLOWS_DIR" -name "*.yml" -o -name "*.yaml" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$WORKFLOW_FILES" ]; then
|
||||
echo "⚠️ 未找到 Gitea workflow 文件"
|
||||
else
|
||||
for workflow in $WORKFLOW_FILES; do
|
||||
echo ""
|
||||
echo "🔍 验证 $(basename "$workflow")"
|
||||
|
||||
if validate_file_exists "$workflow" "$(basename "$workflow")"; then
|
||||
# 验证 YAML 语法
|
||||
validate_yaml_syntax "$workflow" "$(basename "$workflow")"
|
||||
|
||||
# 验证 workflow 结构
|
||||
validate_workflow_structure "$workflow"
|
||||
|
||||
# 检查是否包含中文注释(符合项目风格)
|
||||
if grep -q "# .*[\u4e00-\u9fa5]" "$workflow" 2>/dev/null || grep -qP "[\x{4e00}-\x{9fa5}]" "$workflow" 2>/dev/null; then
|
||||
echo " ✓ 包含中文注释(符合项目风格)"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 检查配置区域标记
|
||||
if grep -q "# ====.*配置.*====" "$workflow"; then
|
||||
echo " ✓ 包含配置区域标记"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 检查环境变量配置
|
||||
if grep -q "^env:" "$workflow"; then
|
||||
echo " ✓ 包含环境变量配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 查找并验证 GitHub Actions workflow 文件
|
||||
# ============================================
|
||||
|
||||
echo "🔍 查找 GitHub Actions workflow 文件"
|
||||
GITHUB_WORKFLOWS_DIR="$TEMPLATES_DIR/github/.github/workflows"
|
||||
|
||||
if [ -d "$GITHUB_WORKFLOWS_DIR" ]; then
|
||||
WORKFLOW_FILES=$(find "$GITHUB_WORKFLOWS_DIR" -name "*.yml" -o -name "*.yaml" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$WORKFLOW_FILES" ]; then
|
||||
for workflow in $WORKFLOW_FILES; do
|
||||
echo ""
|
||||
echo "🔍 验证 $(basename "$workflow")"
|
||||
|
||||
if validate_file_exists "$workflow" "$(basename "$workflow")"; then
|
||||
# 验证 YAML 语法
|
||||
validate_yaml_syntax "$workflow" "$(basename "$workflow")"
|
||||
|
||||
# 验证 workflow 结构
|
||||
validate_workflow_structure "$workflow"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ GitHub Actions workflows 目录不存在(可选)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证特定 workflow 模板
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证特定 workflow 模板"
|
||||
|
||||
# 检查 standards-check workflow
|
||||
STANDARDS_CHECK="$GITEA_WORKFLOWS_DIR/standards-check.yml"
|
||||
if [ -f "$STANDARDS_CHECK" ]; then
|
||||
echo ""
|
||||
echo "📋 验证 standards-check.yml:"
|
||||
|
||||
# 检查是否包含格式化检查
|
||||
if grep -q "格式化\|format" "$STANDARDS_CHECK"; then
|
||||
echo " ✓ 包含格式化检查"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 检查是否包含 lint 检查
|
||||
if grep -q "lint\|检查" "$STANDARDS_CHECK"; then
|
||||
echo " ✓ 包含 lint 检查"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ 未找到 standards-check.yml(可选)"
|
||||
fi
|
||||
|
||||
# 检查 test workflow
|
||||
TEST_WORKFLOW="$GITEA_WORKFLOWS_DIR/test.yml"
|
||||
if [ -f "$TEST_WORKFLOW" ]; then
|
||||
echo ""
|
||||
echo "📋 验证 test.yml:"
|
||||
|
||||
# 检查是否包含测试步骤
|
||||
if grep -q "测试\|test" "$TEST_WORKFLOW"; then
|
||||
echo " ✓ 包含测试步骤"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 检查是否配置了测试矩阵
|
||||
if grep -q "strategy:" "$TEST_WORKFLOW" && grep -q "matrix:" "$TEST_WORKFLOW"; then
|
||||
echo " ✓ 配置了测试矩阵"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ 未找到 test.yml(可选)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 README 文档
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 CI 模板文档"
|
||||
|
||||
if [ -d "$TEMPLATES_DIR" ]; then
|
||||
# 查找 README
|
||||
README_FILE=""
|
||||
for name in README.md readme.md README README.txt; do
|
||||
if [ -f "$TEMPLATES_DIR/$name" ]; then
|
||||
README_FILE="$TEMPLATES_DIR/$name"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$README_FILE" ]; then
|
||||
echo " ✅ 找到说明文档: $(basename "$README_FILE")"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
|
||||
# 检查文档内容
|
||||
if grep -qi "使用\|usage\|how to" "$README_FILE"; then
|
||||
echo " ✓ 包含使用说明"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ 未找到 README 文档(建议添加)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 生成验证报告
|
||||
# ============================================
|
||||
|
||||
echo "========================================"
|
||||
echo "📊 验证结果统计"
|
||||
echo "========================================"
|
||||
echo "✅ 通过: $VALIDATION_PASSED"
|
||||
echo "❌ 失败: $VALIDATION_FAILED"
|
||||
|
||||
if [ $((VALIDATION_PASSED + VALIDATION_FAILED)) -gt 0 ]; then
|
||||
echo "📈 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
else
|
||||
echo "📈 通过率: N/A (无测试项)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 写入报告文件
|
||||
{
|
||||
echo "CI 模板验证报告"
|
||||
echo "===================="
|
||||
echo ""
|
||||
echo "验证时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
echo "统计结果:"
|
||||
echo " 通过: $VALIDATION_PASSED"
|
||||
echo " 失败: $VALIDATION_FAILED"
|
||||
if [ $((VALIDATION_PASSED + VALIDATION_FAILED)) -gt 0 ]; then
|
||||
echo " 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
fi
|
||||
echo ""
|
||||
if [ -s "$ERRORS_FILE" ]; then
|
||||
echo "错误详情:"
|
||||
cat "$ERRORS_FILE"
|
||||
fi
|
||||
} > "$REPORT_FILE"
|
||||
|
||||
echo "📄 详细报告: $REPORT_FILE"
|
||||
echo "========================================"
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$ERRORS_FILE"
|
||||
|
||||
# 返回结果
|
||||
if [ "$VALIDATION_FAILED" -eq 0 ]; then
|
||||
echo "✅ 所有 CI 模板验证通过"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ CI 模板验证失败 ($VALIDATION_FAILED 个错误)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,355 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# C++ 模板验证脚本
|
||||
|
||||
set -eu
|
||||
|
||||
echo "========================================"
|
||||
echo "⚙️ C++ 模板验证"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLAYBOOK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TEMPLATES_DIR="$PLAYBOOK_ROOT/templates/cpp"
|
||||
|
||||
VALIDATION_PASSED=0
|
||||
VALIDATION_FAILED=0
|
||||
ERRORS_FILE="/tmp/cpp_template_validation_errors.txt"
|
||||
REPORT_DIR="$PLAYBOOK_ROOT/reports/templates"
|
||||
REPORT_FILE="$REPORT_DIR/cpp_validation_report.txt"
|
||||
|
||||
> "$ERRORS_FILE"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
> "$REPORT_FILE"
|
||||
|
||||
echo "📁 模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
validate_file_exists() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
echo " ✅ $description: $(basename "$file")"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: $(basename "$file") - 文件不存在"
|
||||
echo "文件不存在: $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_cmake_syntax() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
# 基础语法检查
|
||||
if [ ! -s "$file" ]; then
|
||||
echo " ❌ $description: 文件为空"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 检查必要的 CMake 命令
|
||||
local has_errors=0
|
||||
|
||||
if ! grep -q "cmake_minimum_required" "$file"; then
|
||||
echo " ✗ 缺少 cmake_minimum_required"
|
||||
has_errors=1
|
||||
fi
|
||||
|
||||
if ! grep -q "project(" "$file"; then
|
||||
echo " ✗ 缺少 project()"
|
||||
has_errors=1
|
||||
fi
|
||||
|
||||
if [ $has_errors -eq 0 ]; then
|
||||
echo " ✅ $description: CMake 基础语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: CMake 语法检查失败"
|
||||
echo "CMake 语法错误: $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 验证 CMakeLists.txt
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 CMakeLists.txt"
|
||||
CMAKE_FILE="$TEMPLATES_DIR/CMakeLists.txt"
|
||||
|
||||
if validate_file_exists "$CMAKE_FILE" "CMakeLists.txt"; then
|
||||
# 验证 CMake 语法
|
||||
validate_cmake_syntax "$CMAKE_FILE" "CMakeLists.txt"
|
||||
|
||||
echo " 📋 检查 C++23 配置:"
|
||||
|
||||
# 检查 C++23 标准
|
||||
if grep -q "CMAKE_CXX_STANDARD 23" "$CMAKE_FILE"; then
|
||||
echo " ✓ 配置了 C++23 标准"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 未配置 C++23 标准"
|
||||
echo "未配置 C++23: $CMAKE_FILE" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
# 检查 Modules 支持
|
||||
if grep -q "CMAKE_CXX_SCAN_FOR_MODULES" "$CMAKE_FILE"; then
|
||||
echo " ✓ 启用了 C++ Modules 扫描"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ⚠️ 未启用 C++ Modules 扫描(可选)"
|
||||
fi
|
||||
|
||||
# 检查 import std; 支持
|
||||
if grep -q "CMAKE_EXPERIMENTAL_CXX_IMPORT_STD" "$CMAKE_FILE"; then
|
||||
echo " ✓ 启用了 import std; 支持"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ⚠️ 未启用 import std; 支持(可选)"
|
||||
fi
|
||||
|
||||
# 检查编译命令导出
|
||||
if grep -q "CMAKE_EXPORT_COMPILE_COMMANDS" "$CMAKE_FILE"; then
|
||||
echo " ✓ 启用了编译命令导出(用于 clangd)"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ⚠️ 未启用编译命令导出"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 .clang-format
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .clang-format"
|
||||
CLANG_FORMAT="$TEMPLATES_DIR/.clang-format"
|
||||
|
||||
if validate_file_exists "$CLANG_FORMAT" ".clang-format"; then
|
||||
# 验证 YAML 语法
|
||||
if command -v yamllint >/dev/null 2>&1; then
|
||||
if yamllint -d relaxed "$CLANG_FORMAT" >/dev/null 2>&1; then
|
||||
echo " ✓ YAML 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ YAML 语法错误"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证关键配置
|
||||
echo " 📋 检查格式化配置:"
|
||||
|
||||
if grep -q "^Language:" "$CLANG_FORMAT" && grep -q "Cpp" "$CLANG_FORMAT"; then
|
||||
echo " ✓ 配置了 Language: Cpp"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "^BasedOnStyle:" "$CLANG_FORMAT"; then
|
||||
style=$(grep "^BasedOnStyle:" "$CLANG_FORMAT" | awk '{print $2}')
|
||||
echo " ✓ 基于风格: $style"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "^Standard:" "$CLANG_FORMAT"; then
|
||||
std=$(grep "^Standard:" "$CLANG_FORMAT" | awk '{print $2}')
|
||||
echo " ✓ C++ 标准: $std"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 检查缩进配置
|
||||
if grep -q "^IndentWidth:" "$CLANG_FORMAT"; then
|
||||
echo " ✓ 配置了缩进宽度"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 .clangd
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .clangd"
|
||||
CLANGD="$TEMPLATES_DIR/.clangd"
|
||||
|
||||
if validate_file_exists "$CLANGD" ".clangd"; then
|
||||
# 验证 YAML 语法
|
||||
if command -v yamllint >/dev/null 2>&1; then
|
||||
if yamllint -d relaxed "$CLANGD" >/dev/null 2>&1; then
|
||||
echo " ✓ YAML 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ YAML 语法错误"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证 CompileFlags
|
||||
echo " 📋 检查 clangd 配置:"
|
||||
|
||||
if grep -q "CompileFlags:" "$CLANGD"; then
|
||||
echo " ✓ 包含 CompileFlags 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
|
||||
# 检查 C++23 标准
|
||||
if grep -q "std=c++23" "$CLANGD"; then
|
||||
echo " ✓ 配置了 -std=c++23"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证 CompilationDatabase
|
||||
if grep -q "CompilationDatabase:" "$CLANGD"; then
|
||||
echo " ✓ 配置了 CompilationDatabase 路径"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 验证 Index 配置
|
||||
if grep -q "Index:" "$CLANGD"; then
|
||||
echo " ✓ 配置了索引选项"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 conanfile.txt
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 conanfile.txt"
|
||||
CONANFILE="$TEMPLATES_DIR/conanfile.txt"
|
||||
|
||||
if validate_file_exists "$CONANFILE" "conanfile.txt"; then
|
||||
echo " 📋 检查 Conan 配置:"
|
||||
|
||||
# 验证必要的节
|
||||
if grep -q "^\[requires\]" "$CONANFILE"; then
|
||||
echo " ✓ 包含 [requires] 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ⚠️ 缺少 [requires] 配置(可选)"
|
||||
fi
|
||||
|
||||
if grep -q "^\[generators\]" "$CONANFILE"; then
|
||||
echo " ✓ 包含 [generators] 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
|
||||
# 检查 CMakeDeps 和 CMakeToolchain
|
||||
if grep -q "CMakeDeps" "$CONANFILE"; then
|
||||
echo " ✓ 配置了 CMakeDeps 生成器"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "CMakeToolchain" "$CONANFILE"; then
|
||||
echo " ✓ 配置了 CMakeToolchain 生成器"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -q "^\[options\]" "$CONANFILE"; then
|
||||
echo " ✓ 包含 [options] 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 CMakeUserPresets.json
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 CMakeUserPresets.json"
|
||||
CMAKE_PRESETS="$TEMPLATES_DIR/CMakeUserPresets.json"
|
||||
|
||||
if validate_file_exists "$CMAKE_PRESETS" "CMakeUserPresets.json"; then
|
||||
# 验证 JSON 语法
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
if python3 -m json.tool "$CMAKE_PRESETS" >/dev/null 2>&1; then
|
||||
echo " ✓ JSON 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ JSON 语法错误"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证配置项
|
||||
echo " 📋 检查 CMake Presets:"
|
||||
|
||||
if grep -q "\"version\"" "$CMAKE_PRESETS"; then
|
||||
echo " ✓ 包含 version 字段"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "\"configurePresets\"" "$CMAKE_PRESETS"; then
|
||||
echo " ✓ 包含 configurePresets"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
if grep -q "\"buildPresets\"" "$CMAKE_PRESETS"; then
|
||||
echo " ✓ 包含 buildPresets"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 生成验证报告
|
||||
# ============================================
|
||||
|
||||
echo "========================================"
|
||||
echo "📊 验证结果统计"
|
||||
echo "========================================"
|
||||
echo "✅ 通过: $VALIDATION_PASSED"
|
||||
echo "❌ 失败: $VALIDATION_FAILED"
|
||||
echo "📈 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
echo ""
|
||||
|
||||
# 写入报告文件
|
||||
{
|
||||
echo "C++ 模板验证报告"
|
||||
echo "===================="
|
||||
echo ""
|
||||
echo "验证时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
echo "统计结果:"
|
||||
echo " 通过: $VALIDATION_PASSED"
|
||||
echo " 失败: $VALIDATION_FAILED"
|
||||
echo " 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
echo ""
|
||||
if [ -s "$ERRORS_FILE" ]; then
|
||||
echo "错误详情:"
|
||||
cat "$ERRORS_FILE"
|
||||
fi
|
||||
} > "$REPORT_FILE"
|
||||
|
||||
echo "📄 详细报告: $REPORT_FILE"
|
||||
echo "========================================"
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$ERRORS_FILE"
|
||||
|
||||
# 返回结果
|
||||
if [ "$VALIDATION_FAILED" -eq 0 ]; then
|
||||
echo "✅ 所有 C++ 模板验证通过"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ C++ 模板验证失败 ($VALIDATION_FAILED 个错误)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# 项目模板验证脚本
|
||||
|
||||
set -eu
|
||||
|
||||
echo "========================================"
|
||||
echo "🧩 项目模板验证"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLAYBOOK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TEMPLATES_DIR="$PLAYBOOK_ROOT/templates"
|
||||
|
||||
VALIDATION_PASSED=0
|
||||
VALIDATION_FAILED=0
|
||||
ERRORS_FILE="/tmp/project_template_validation_errors.txt"
|
||||
REPORT_DIR="$PLAYBOOK_ROOT/reports/templates"
|
||||
REPORT_FILE="$REPORT_DIR/project_templates_report.txt"
|
||||
|
||||
> "$ERRORS_FILE"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
> "$REPORT_FILE"
|
||||
|
||||
echo "📁 模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
validate_file_exists() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
echo " ✅ $description: $(basename "$file")"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: $(basename "$file") - 文件不存在"
|
||||
echo "文件不存在: $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local description="$3"
|
||||
|
||||
if grep -Fq "$needle" "$file"; then
|
||||
echo " ✓ $description"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ $description"
|
||||
echo "缺少内容: $needle in $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "🔍 验证核心模板文件"
|
||||
AGENTS_TEMPLATE="$TEMPLATES_DIR/AGENTS.template.md"
|
||||
AGENT_RULES_TEMPLATE="$TEMPLATES_DIR/AGENT_RULES.template.md"
|
||||
README_TEMPLATE="$TEMPLATES_DIR/README.md"
|
||||
|
||||
if validate_file_exists "$AGENTS_TEMPLATE" "AGENTS.template.md"; then
|
||||
validate_contains "$AGENTS_TEMPLATE" "<!-- playbook:templates:start -->" "包含 templates 标记"
|
||||
validate_contains "$AGENTS_TEMPLATE" "<!-- playbook:framework:start -->" "包含 framework 标记"
|
||||
validate_contains "$AGENTS_TEMPLATE" "{{DATE}}" "包含 {{DATE}} 占位符"
|
||||
fi
|
||||
|
||||
if validate_file_exists "$AGENT_RULES_TEMPLATE" "AGENT_RULES.template.md"; then
|
||||
validate_contains "$AGENT_RULES_TEMPLATE" "AGENT_RULES" "包含 AGENT_RULES 标题"
|
||||
validate_contains "$AGENT_RULES_TEMPLATE" "{{DATE}}" "包含 {{DATE}} 占位符"
|
||||
validate_contains "$AGENT_RULES_TEMPLATE" "docs/superpowers/plans/" "包含 superpowers plans 路径"
|
||||
validate_contains "$AGENT_RULES_TEMPLATE" "using-superpowers -> brainstorming -> writing-plans" "包含规划主链"
|
||||
fi
|
||||
|
||||
validate_file_exists "$README_TEMPLATE" "templates/README.md"
|
||||
|
||||
echo ""
|
||||
echo "🔍 验证 memory-bank 模板"
|
||||
|
||||
MEMORY_BANK_DIR="$TEMPLATES_DIR/memory-bank"
|
||||
for name in project-brief tech-context system-patterns active-context progress decisions; do
|
||||
validate_file_exists "$MEMORY_BANK_DIR/$name.template.md" "memory-bank/$name.template.md"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🔍 验证 prompts 模板"
|
||||
|
||||
PROMPTS_DIR="$TEMPLATES_DIR/prompts"
|
||||
validate_file_exists "$PROMPTS_DIR/README.md" "prompts/README.md"
|
||||
validate_file_exists "$PROMPTS_DIR/system/agent-behavior.template.md" "prompts/system/agent-behavior.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/clarify.template.md" "prompts/coding/clarify.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/verify-change.template.md" "prompts/coding/verify-change.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/close-task.template.md" "prompts/coding/close-task.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/update-memory.template.md" "prompts/coding/update-memory.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/code-review.template.md" "prompts/coding/code-review.template.md"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 生成验证报告
|
||||
# ============================================
|
||||
|
||||
echo "========================================"
|
||||
echo "📊 验证结果统计"
|
||||
echo "========================================"
|
||||
echo "✅ 通过: $VALIDATION_PASSED"
|
||||
echo "❌ 失败: $VALIDATION_FAILED"
|
||||
|
||||
if [ $((VALIDATION_PASSED + VALIDATION_FAILED)) -gt 0 ]; then
|
||||
echo "📈 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
else
|
||||
echo "📈 通过率: N/A (无测试项)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
{
|
||||
echo "项目模板验证报告"
|
||||
echo "===================="
|
||||
echo ""
|
||||
echo "验证时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
echo "统计结果:"
|
||||
echo " 通过: $VALIDATION_PASSED"
|
||||
echo " 失败: $VALIDATION_FAILED"
|
||||
if [ $((VALIDATION_PASSED + VALIDATION_FAILED)) -gt 0 ]; then
|
||||
echo " 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
fi
|
||||
echo ""
|
||||
if [ -s "$ERRORS_FILE" ]; then
|
||||
echo "错误详情:"
|
||||
cat "$ERRORS_FILE"
|
||||
fi
|
||||
} > "$REPORT_FILE"
|
||||
|
||||
echo "📄 详细报告: $REPORT_FILE"
|
||||
echo "========================================"
|
||||
|
||||
rm -f "$ERRORS_FILE"
|
||||
|
||||
if [ "$VALIDATION_FAILED" -eq 0 ]; then
|
||||
echo "✅ 所有项目模板验证通过"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 项目模板验证失败 ($VALIDATION_FAILED 个错误)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,341 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# Python 模板验证脚本
|
||||
|
||||
set -eu
|
||||
|
||||
echo "========================================"
|
||||
echo "🐍 Python 模板验证"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLAYBOOK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TEMPLATES_DIR="$PLAYBOOK_ROOT/templates/python"
|
||||
|
||||
VALIDATION_PASSED=0
|
||||
VALIDATION_FAILED=0
|
||||
ERRORS_FILE="/tmp/python_template_validation_errors.txt"
|
||||
REPORT_DIR="$PLAYBOOK_ROOT/reports/templates"
|
||||
REPORT_FILE="$REPORT_DIR/python_validation_report.txt"
|
||||
|
||||
> "$ERRORS_FILE"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
> "$REPORT_FILE"
|
||||
|
||||
echo "📁 模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
validate_file_exists() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
echo " ✅ $description: $(basename "$file")"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: $(basename "$file") - 文件不存在"
|
||||
echo "文件不存在: $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_toml_syntax() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo " ⚠️ $description: 跳过(Python3 未安装)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if python3 << EOF
|
||||
import sys
|
||||
parser = None
|
||||
for module_name in ("tomli", "tomllib", "toml"):
|
||||
try:
|
||||
parser = __import__(module_name)
|
||||
break
|
||||
except ImportError:
|
||||
continue
|
||||
|
||||
if parser is None:
|
||||
print("缺少 TOML 解析器(需要 tomli、tomllib 或 toml)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
with open("$file", "rb") as f:
|
||||
parser.load(f)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"TOML 语法错误: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
EOF
|
||||
then
|
||||
echo " ✅ $description: TOML 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -eq 2 ]; then
|
||||
echo " ❌ $description: 缺少 TOML 解析器"
|
||||
echo "缺少 TOML 解析器: $file" >> "$ERRORS_FILE"
|
||||
else
|
||||
echo " ❌ $description: TOML 语法错误"
|
||||
echo "TOML 语法错误: $file" >> "$ERRORS_FILE"
|
||||
fi
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_required_sections() {
|
||||
local file="$1"
|
||||
local sections="$2"
|
||||
|
||||
for section in $sections; do
|
||||
if grep -q "^\[$section\]" "$file"; then
|
||||
echo " ✓ 包含 [$section] 配置"
|
||||
else
|
||||
echo " ✗ 缺少 [$section] 配置"
|
||||
echo "缺少配置节: $section in $file" >> "$ERRORS_FILE"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 验证 pyproject.toml
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 pyproject.toml"
|
||||
PYPROJECT="$TEMPLATES_DIR/pyproject.toml"
|
||||
|
||||
if validate_file_exists "$PYPROJECT" "pyproject.toml"; then
|
||||
# 验证 TOML 语法
|
||||
validate_toml_syntax "$PYPROJECT" "pyproject.toml"
|
||||
|
||||
# 验证必要的配置节
|
||||
echo " 📋 检查必要配置节:"
|
||||
validate_required_sections "$PYPROJECT" "tool.black tool.isort tool.pytest.ini_options"
|
||||
|
||||
# 验证 black 配置
|
||||
if grep -q "line-length = 80" "$PYPROJECT"; then
|
||||
echo " ✓ black line-length 配置正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ black line-length 配置缺失或不正确"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
# 验证 isort 配置
|
||||
if grep -q "profile = \"google\"" "$PYPROJECT"; then
|
||||
echo " ✓ isort profile 配置正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ isort profile 配置缺失或不正确"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 .flake8
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .flake8"
|
||||
FLAKE8="$TEMPLATES_DIR/.flake8"
|
||||
|
||||
if validate_file_exists "$FLAKE8" ".flake8"; then
|
||||
# 验证 [flake8] 节
|
||||
if grep -q "^\[flake8\]" "$FLAKE8"; then
|
||||
echo " ✓ 包含 [flake8] 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
|
||||
# 验证 max-line-length
|
||||
if grep -q "^max-line-length" "$FLAKE8"; then
|
||||
echo " ✓ 配置了 max-line-length"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 max-line-length 配置"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
|
||||
# 验证 extend-ignore
|
||||
if grep -q "^extend-ignore" "$FLAKE8" || grep -q "^ignore" "$FLAKE8"; then
|
||||
echo " ✓ 配置了错误忽略规则"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
else
|
||||
echo " ✗ 缺少 [flake8] 配置节"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 .pylintrc
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .pylintrc"
|
||||
PYLINTRC="$TEMPLATES_DIR/.pylintrc"
|
||||
|
||||
if validate_file_exists "$PYLINTRC" ".pylintrc"; then
|
||||
# 验证关键配置节
|
||||
for section in "MASTER" "MESSAGES CONTROL" "FORMAT"; do
|
||||
if grep -qi "^\[$section\]" "$PYLINTRC"; then
|
||||
echo " ✓ 包含 [$section] 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ 缺少 [$section] 配置"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# 验证 max-line-length
|
||||
if grep -q "^max-line-length" "$PYLINTRC"; then
|
||||
echo " ✓ 配置了 max-line-length"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 .pre-commit-config.yaml
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .pre-commit-config.yaml"
|
||||
PRECOMMIT="$TEMPLATES_DIR/.pre-commit-config.yaml"
|
||||
|
||||
if validate_file_exists "$PRECOMMIT" ".pre-commit-config.yaml"; then
|
||||
# 验证 YAML 语法
|
||||
if command -v yamllint >/dev/null 2>&1; then
|
||||
if yamllint -d relaxed "$PRECOMMIT" >/dev/null 2>&1; then
|
||||
echo " ✓ YAML 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ YAML 语法错误"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证包含 pre-commit hooks
|
||||
if grep -q "^repos:" "$PRECOMMIT"; then
|
||||
echo " ✓ 包含 repos 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
|
||||
# 检查常用 hooks
|
||||
for hook in "black" "isort" "flake8"; do
|
||||
if grep -q "$hook" "$PRECOMMIT"; then
|
||||
echo " ✓ 配置了 $hook hook"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 .editorconfig
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .editorconfig"
|
||||
EDITORCONFIG="$TEMPLATES_DIR/.editorconfig"
|
||||
|
||||
if validate_file_exists "$EDITORCONFIG" ".editorconfig"; then
|
||||
# 验证包含 root 标记
|
||||
if grep -q "^root = true" "$EDITORCONFIG"; then
|
||||
echo " ✓ 包含 root = true"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
|
||||
# 验证 Python 配置节
|
||||
if grep -q "^\[\*.py\]" "$EDITORCONFIG" || grep -q "^\[*.py\]" "$EDITORCONFIG"; then
|
||||
echo " ✓ 包含 Python 文件配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 验证 VSCode 配置
|
||||
# ============================================
|
||||
|
||||
echo "🔍 验证 .vscode/settings.json"
|
||||
VSCODE_SETTINGS="$TEMPLATES_DIR/.vscode/settings.json"
|
||||
|
||||
if validate_file_exists "$VSCODE_SETTINGS" "settings.json"; then
|
||||
# 验证 JSON 语法
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
if python3 -m json.tool "$VSCODE_SETTINGS" >/dev/null 2>&1; then
|
||||
echo " ✓ JSON 语法正确"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
else
|
||||
echo " ✗ JSON 语法错误"
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证 Python 相关配置
|
||||
if grep -q "python" "$VSCODE_SETTINGS"; then
|
||||
echo " ✓ 包含 Python 配置"
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 生成验证报告
|
||||
# ============================================
|
||||
|
||||
echo "========================================"
|
||||
echo "📊 验证结果统计"
|
||||
echo "========================================"
|
||||
echo "✅ 通过: $VALIDATION_PASSED"
|
||||
echo "❌ 失败: $VALIDATION_FAILED"
|
||||
echo "📈 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
echo ""
|
||||
|
||||
# 写入报告文件
|
||||
{
|
||||
echo "Python 模板验证报告"
|
||||
echo "===================="
|
||||
echo ""
|
||||
echo "验证时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "模板目录: $TEMPLATES_DIR"
|
||||
echo ""
|
||||
echo "统计结果:"
|
||||
echo " 通过: $VALIDATION_PASSED"
|
||||
echo " 失败: $VALIDATION_FAILED"
|
||||
echo " 通过率: $(awk "BEGIN {printf \"%.1f\", ($VALIDATION_PASSED * 100.0) / ($VALIDATION_PASSED + $VALIDATION_FAILED)}")%"
|
||||
echo ""
|
||||
if [ -s "$ERRORS_FILE" ]; then
|
||||
echo "错误详情:"
|
||||
cat "$ERRORS_FILE"
|
||||
fi
|
||||
} > "$REPORT_FILE"
|
||||
|
||||
echo "📄 详细报告: $REPORT_FILE"
|
||||
echo "========================================"
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$ERRORS_FILE"
|
||||
|
||||
# 返回结果
|
||||
if [ "$VALIDATION_FAILED" -eq 0 ]; then
|
||||
echo "✅ 所有 Python 模板验证通过"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Python 模板验证失败 ($VALIDATION_FAILED 个错误)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,444 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.build_tsl_playbook import build_agents_text
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "build_tsl_playbook.py"
|
||||
SYNC_WORKFLOW = ROOT / ".gitea" / "workflows" / "sync-tsl-playbook.yml"
|
||||
README = ROOT / "README.md"
|
||||
|
||||
|
||||
class BuildTslPlaybookTests(unittest.TestCase):
|
||||
def test_readme_names_tsl_syntax_skill_as_unique_fact_owner(self):
|
||||
text = README.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("TSL 语法事实唯一由 `tsl-syntax-reference` 管理", text)
|
||||
self.assertIn("TSL 领域路由与事实边界", text)
|
||||
|
||||
def test_builds_minimal_tsl_playbook_tree(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
output = Path(tmp_dir) / "tsl-playbook"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--output", str(output)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in output.iterdir()),
|
||||
["AGENTS.md", "docs", "skills"],
|
||||
)
|
||||
self.assertFalse((output / "playbook.toml").exists())
|
||||
self.assertFalse((output / ".agents").exists())
|
||||
self.assertFalse((output / "docs" / "index.md").exists())
|
||||
|
||||
self.assertTrue((output / "docs" / "tsl" / "index.md").is_file())
|
||||
syntax_skill = output / "skills" / "tsl-syntax-reference"
|
||||
self.assertTrue((syntax_skill / "SKILL.md").is_file())
|
||||
self.assertTrue((syntax_skill / "scripts" / "lookup.py").is_file())
|
||||
self.assertFalse((syntax_skill / "references" / "index.md").exists())
|
||||
self.assertTrue(
|
||||
(output / "skills" / "tsl-syntax-reference" / "SKILL.md").is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(output / "skills" / "tsl-api-reference" / "SKILL.md").is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(
|
||||
output
|
||||
/ "skills"
|
||||
/ "tsl-api-reference"
|
||||
/ "scripts"
|
||||
/ "lookup.py"
|
||||
).is_file()
|
||||
)
|
||||
|
||||
agents_text = (output / "AGENTS.md").read_text(encoding="utf-8")
|
||||
self.assertIn("# TSL Agent Instructions", agents_text)
|
||||
self.assertIn("tsl-syntax-reference", agents_text)
|
||||
self.assertIn("tsl-api-reference", agents_text)
|
||||
self.assertNotIn(".agents/index.md", agents_text)
|
||||
self.assertNotIn(".agents/tsl/index.md", agents_text)
|
||||
|
||||
source_docs = count_files(ROOT / "docs" / "tsl")
|
||||
output_docs = count_files(output / "docs" / "tsl")
|
||||
self.assertEqual(output_docs, source_docs)
|
||||
|
||||
for skill_name in ("tsl-syntax-reference", "tsl-api-reference"):
|
||||
source_skill = count_files(ROOT / "skills" / skill_name)
|
||||
output_skill = count_files(output / "skills" / skill_name)
|
||||
self.assertEqual(output_skill, source_skill)
|
||||
|
||||
def test_missing_tsl_skill_reports_specific_source_path(self):
|
||||
for skill_name in ("tsl-syntax-reference", "tsl-api-reference"):
|
||||
with self.subTest(skill=skill_name), tempfile.TemporaryDirectory() as tmp_dir:
|
||||
repo = Path(tmp_dir) / "repo"
|
||||
repo.mkdir()
|
||||
copy_required_sources(repo)
|
||||
missing_path = repo / "skills" / skill_name
|
||||
shutil.rmtree(missing_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(repo / "scripts" / "build_tsl_playbook.py"),
|
||||
"--output",
|
||||
str(repo / "output"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(str(missing_path), result.stderr)
|
||||
|
||||
def test_build_agents_text_only_rewrites_title_and_trailing_newline(self):
|
||||
marker = (
|
||||
"- 需要任何 TSL 语法、文件模型、函数、模块或金融数据事实时,"
|
||||
"第一跳统一进 `docs/tsl/index.md`,由它路由到具体页;"
|
||||
"不在本文件内猜文件路径,也不全目录搜索。"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
ruleset = Path(tmp_dir) / "index.md"
|
||||
ruleset.write_text(
|
||||
f"# TSL 智能体规则\n\n{marker}\n\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
agents_text = build_agents_text(ruleset)
|
||||
|
||||
self.assertEqual(
|
||||
agents_text,
|
||||
f"# TSL Agent Instructions\n\n{marker}\n",
|
||||
)
|
||||
|
||||
def test_sync_workflow_does_not_remove_entire_target_branch(self):
|
||||
text = SYNC_WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
self.assertNotRegex(text, r"git rm -rf --quiet\s+\.")
|
||||
self.assertIn("managed_paths=(", text)
|
||||
for path in (
|
||||
"AGENTS.md",
|
||||
"docs/tsl",
|
||||
"skills/tsl-syntax-reference",
|
||||
"skills/tsl-api-reference",
|
||||
):
|
||||
self.assertIn(f'"{path}"', text)
|
||||
self.assertNotIn("generated_paths=(AGENTS.md docs skills)", text)
|
||||
self.assertIn('rm -rf -- "${managed_paths[@]}"', text)
|
||||
self.assertIn('git add -A -- "${managed_paths[@]}"', text)
|
||||
self.assertNotIn('cp -R "$bundle"/. "$REPO_DIR"/', text)
|
||||
self.assertNotIn(".gitea/ci/", text)
|
||||
self.assertNotIn("https://oauth2", text)
|
||||
self.assertNotIn("oauth2:${TOKEN}", text)
|
||||
self.assertNotRegex(text, r"REPO_URL=.*(TOKEN|WORKFLOW)")
|
||||
self.assertNotIn("git remote set-url", text)
|
||||
self.assertIn("GIT_ASKPASS", text)
|
||||
self.assertIn('REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"', text)
|
||||
|
||||
def test_sync_preserves_files_outside_generated_paths(self):
|
||||
if shutil.which("bash") is None:
|
||||
self.skipTest("bash is required to run sync workflow script")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
repo = create_source_repo(Path(tmp_dir))
|
||||
|
||||
git(repo, "checkout", "--orphan", "tsl-playbook")
|
||||
git(repo, "rm", "-rf", ".")
|
||||
unmanaged_files = {
|
||||
"README.md": "manual branch note\n",
|
||||
"docs/python/index.md": "manual python docs\n",
|
||||
"skills/manual-skill/SKILL.md": "manual skill\n",
|
||||
}
|
||||
for relative, content in unmanaged_files.items():
|
||||
path = repo / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
|
||||
stale_managed_files = (
|
||||
"docs/tsl/stale.md",
|
||||
"skills/tsl-syntax-reference/stale.md",
|
||||
"skills/tsl-api-reference/stale.md",
|
||||
)
|
||||
for relative in stale_managed_files:
|
||||
path = repo / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("stale\n", encoding="utf-8", newline="\n")
|
||||
|
||||
git(repo, "add", ".")
|
||||
git(repo, "commit", "-m", "manual target branch content")
|
||||
git(repo, "push", "-u", "origin", "tsl-playbook")
|
||||
|
||||
git(repo, "checkout", "main")
|
||||
run_sync(repo)
|
||||
|
||||
for relative, expected in unmanaged_files.items():
|
||||
result = run(
|
||||
["git", "show", f"HEAD:{relative}"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(result.stdout, expected)
|
||||
|
||||
for relative in stale_managed_files:
|
||||
result = run(
|
||||
["git", "cat-file", "-e", f"HEAD:{relative}"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0, msg=relative)
|
||||
|
||||
for path in (
|
||||
"AGENTS.md",
|
||||
"docs/tsl/index.md",
|
||||
"skills/tsl-syntax-reference/SKILL.md",
|
||||
"skills/tsl-api-reference/SKILL.md",
|
||||
):
|
||||
git(repo, "cat-file", "-e", f"HEAD:{path}")
|
||||
|
||||
first_publish = git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
git(repo, "checkout", "main")
|
||||
run_sync(repo)
|
||||
second_publish = git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
self.assertEqual(second_publish, first_publish)
|
||||
|
||||
def test_sync_creates_new_branch_without_source_files(self):
|
||||
if shutil.which("bash") is None:
|
||||
self.skipTest("bash is required to run sync workflow script")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
repo = create_source_repo(Path(tmp_dir))
|
||||
|
||||
run_sync(repo)
|
||||
|
||||
for path in (
|
||||
"AGENTS.md",
|
||||
"docs/tsl/index.md",
|
||||
"skills/tsl-syntax-reference/SKILL.md",
|
||||
"skills/tsl-api-reference/SKILL.md",
|
||||
):
|
||||
git(repo, "cat-file", "-e", f"HEAD:{path}")
|
||||
|
||||
for path in (
|
||||
".gitea/workflows/sync-tsl-playbook.yml",
|
||||
"scripts/build_tsl_playbook.py",
|
||||
"rulesets/tsl/index.md",
|
||||
):
|
||||
result = run(
|
||||
["git", "cat-file", "-e", f"HEAD:{path}"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0, msg=f"{path} leaked")
|
||||
|
||||
def test_sync_ignores_inherited_repo_dir_from_environment(self):
|
||||
if shutil.which("bash") is None:
|
||||
self.skipTest("bash is required to run sync workflow script")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp = Path(tmp_dir)
|
||||
repo = create_source_repo(tmp)
|
||||
|
||||
# Simulate the CI environment exporting REPO_DIR pointing at the
|
||||
# real checkout. The sync must operate on the repo passed to
|
||||
# run_sync, never the inherited value.
|
||||
decoy = tmp / "decoy-checkout"
|
||||
decoy.mkdir()
|
||||
sentinel = decoy / "sentinel.txt"
|
||||
sentinel.write_text("untouched\n", encoding="utf-8", newline="\n")
|
||||
|
||||
previous = os.environ.get("REPO_DIR")
|
||||
os.environ["REPO_DIR"] = str(decoy)
|
||||
try:
|
||||
run_sync(repo)
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("REPO_DIR", None)
|
||||
else:
|
||||
os.environ["REPO_DIR"] = previous
|
||||
|
||||
# The decoy must be left completely alone.
|
||||
self.assertEqual(
|
||||
sorted(item.name for item in decoy.iterdir()),
|
||||
["sentinel.txt"],
|
||||
)
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "untouched\n")
|
||||
|
||||
# And the intended repo must actually have been published.
|
||||
git(repo, "cat-file", "-e", "HEAD:AGENTS.md")
|
||||
|
||||
|
||||
def create_source_repo(tmp: Path) -> Path:
|
||||
repo = tmp / "repo"
|
||||
remote = tmp / "remote.git"
|
||||
|
||||
run(["git", "init", "--bare", str(remote)])
|
||||
run(["git", "init", str(repo)])
|
||||
git(repo, "checkout", "-b", "main")
|
||||
git(repo, "config", "user.name", "test")
|
||||
git(repo, "config", "user.email", "test@example.invalid")
|
||||
|
||||
copy_required_sources(repo)
|
||||
git(repo, "add", ".")
|
||||
git(repo, "commit", "-m", "initial sources")
|
||||
git(repo, "remote", "add", "origin", "../remote.git")
|
||||
git(repo, "push", "-u", "origin", "main")
|
||||
return repo
|
||||
|
||||
|
||||
def run_sync(repo: Path) -> None:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"REPO_DIR": str(repo),
|
||||
"TARGET_BRANCH": "tsl-playbook",
|
||||
"COMMIT_AUTHOR_NAME": "test",
|
||||
"COMMIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
}
|
||||
)
|
||||
script_path = repo / ".sync-test.sh"
|
||||
script_path.write_text(
|
||||
extract_sync_workflow_script(), encoding="utf-8", newline="\n"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", script_path.name],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
finally:
|
||||
script_path.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stderr + result.stdout)
|
||||
|
||||
|
||||
def copy_required_sources(repo: Path) -> None:
|
||||
(repo / ".gitea" / "workflows").mkdir(parents=True)
|
||||
shutil.copy2(
|
||||
SYNC_WORKFLOW, repo / ".gitea" / "workflows" / "sync-tsl-playbook.yml"
|
||||
)
|
||||
|
||||
(repo / "scripts").mkdir()
|
||||
shutil.copy2(SCRIPT, repo / "scripts" / "build_tsl_playbook.py")
|
||||
|
||||
(repo / "docs" / "tsl").mkdir(parents=True)
|
||||
(repo / "docs" / "tsl" / "index.md").write_text(
|
||||
"# TSL Index\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
syntax_skill = repo / "skills" / "tsl-syntax-reference"
|
||||
(syntax_skill / "scripts").mkdir(parents=True)
|
||||
(syntax_skill / "SKILL.md").write_text(
|
||||
"---\nname: tsl-syntax-reference\n---\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
(syntax_skill / "scripts" / "lookup.py").write_text(
|
||||
"print('lookup')\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
api_skill = repo / "skills" / "tsl-api-reference"
|
||||
(api_skill / "scripts").mkdir(parents=True)
|
||||
(api_skill / "SKILL.md").write_text(
|
||||
"---\nname: tsl-api-reference\n---\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
(api_skill / "scripts" / "lookup.py").write_text(
|
||||
"print('lookup')\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
syntax_skill = repo / "skills" / "tsl-syntax-reference"
|
||||
(syntax_skill / "references").mkdir(parents=True)
|
||||
(syntax_skill / "SKILL.md").write_text(
|
||||
"---\nname: tsl-syntax-reference\n---\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(syntax_skill / "references" / "index.md").write_text(
|
||||
"# TSL Syntax Reference\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
(repo / "rulesets" / "tsl").mkdir(parents=True)
|
||||
(repo / "rulesets" / "tsl" / "index.md").write_text(
|
||||
"# TSL Agent Instructions\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
|
||||
def count_files(path: Path) -> int:
|
||||
return sum(
|
||||
1
|
||||
for item in path.rglob("*")
|
||||
if item.is_file()
|
||||
and "__pycache__" not in item.parts
|
||||
and item.suffix != ".pyc"
|
||||
)
|
||||
|
||||
|
||||
def extract_sync_workflow_script() -> str:
|
||||
lines = SYNC_WORKFLOW.read_text(encoding="utf-8").splitlines()
|
||||
in_sync_step = False
|
||||
in_run_block = False
|
||||
script_lines: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith(" - name: 📦 Build and publish tsl-playbook"):
|
||||
in_sync_step = True
|
||||
continue
|
||||
if in_sync_step and line.startswith(" - name: "):
|
||||
break
|
||||
if in_sync_step and line == " run: |":
|
||||
in_run_block = True
|
||||
continue
|
||||
if not in_run_block:
|
||||
continue
|
||||
if line.startswith(" "):
|
||||
script_lines.append(line[10:])
|
||||
continue
|
||||
if line.strip() == "":
|
||||
script_lines.append("")
|
||||
continue
|
||||
break
|
||||
|
||||
if not script_lines:
|
||||
raise AssertionError("sync workflow run block was not found")
|
||||
return "\n".join(script_lines) + "\n"
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return run(["git", *args], cwd=repo)
|
||||
|
||||
|
||||
def run(
|
||||
args: list[str],
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"command failed: {' '.join(args)}\n{result.stderr}{result.stdout}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,211 +0,0 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_DEPLOY_ROOT = Path("docs/standards/playbook")
|
||||
CUSTOM_DEPLOY_ROOT = Path("custom/playbook")
|
||||
REPO_COPY_IGNORE = shutil.ignore_patterns(
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
)
|
||||
|
||||
|
||||
def run_script(
|
||||
script: Path, *args: str, cwd: Path | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def write_config(root: Path, name: str, body: str) -> Path:
|
||||
config_path = root / name
|
||||
config_path.write_text(body.strip() + "\n", encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
def copy_repo(target: Path) -> Path:
|
||||
shutil.copytree(ROOT, target, ignore=REPO_COPY_IGNORE)
|
||||
return target
|
||||
|
||||
|
||||
class DeploymentRoutesE2ETests(unittest.TestCase):
|
||||
def assert_core_project_files(self, project_root: Path) -> None:
|
||||
expected = [
|
||||
"AGENTS.md",
|
||||
"AGENT_RULES.md",
|
||||
"AGENT_RULES.local.md",
|
||||
"memory-bank/progress.md",
|
||||
"docs/prompts/system/agent-behavior.md",
|
||||
".agents/index.md",
|
||||
".agents/tsl/index.md",
|
||||
".agents/markdown/index.md",
|
||||
".gitattributes",
|
||||
]
|
||||
for rel in expected:
|
||||
with self.subTest(path=rel):
|
||||
self.assertTrue((project_root / rel).exists(), f"missing: {rel}")
|
||||
self.assertFalse((project_root / "docs" / "workflows").exists())
|
||||
|
||||
def assert_docs_prefix(self, project_root: Path, docs_prefix: str) -> None:
|
||||
agents_index = (project_root / ".agents" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn("`.agents/tsl/index.md`", agents_index)
|
||||
self.assertIn("`.agents/markdown/index.md`", agents_index)
|
||||
self.assertIn(f"- {docs_prefix}", agents_index)
|
||||
|
||||
tsl_index = (project_root / ".agents" / "tsl" / "index.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("`tsl-syntax-reference`", tsl_index)
|
||||
self.assertIn("`tsl-api-reference`", tsl_index)
|
||||
for relative_path in (
|
||||
"tsl/naming.md",
|
||||
"tsl/code_style.md",
|
||||
"tsl/toolchain.md",
|
||||
"tsl/modules/index.md",
|
||||
):
|
||||
self.assertIn(f"`{docs_prefix}/{relative_path}`", tsl_index)
|
||||
self.assertNotIn("`docs/tsl/index.md`", tsl_index)
|
||||
|
||||
def test_subtree_style_deployment_syncs_project_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_root = Path(tmp_dir)
|
||||
project_root = tmp_root / "project"
|
||||
playbook_root = project_root / DEFAULT_DEPLOY_ROOT
|
||||
project_root.mkdir()
|
||||
copy_repo(playbook_root)
|
||||
|
||||
config_path = write_config(
|
||||
project_root,
|
||||
"playbook.toml",
|
||||
"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
playbook_root = "docs/standards/playbook"
|
||||
install_mode = "subtree"
|
||||
|
||||
[sync_rules]
|
||||
no_backup = true
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
|
||||
[sync_prompts]
|
||||
no_backup = true
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
no_backup = true
|
||||
""",
|
||||
)
|
||||
|
||||
result = run_script(
|
||||
playbook_root / "scripts" / "playbook.py",
|
||||
"-config",
|
||||
str(config_path),
|
||||
cwd=project_root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assert_core_project_files(project_root)
|
||||
self.assert_docs_prefix(project_root, "docs/standards/playbook/docs")
|
||||
|
||||
claude_md = project_root / "CLAUDE.md"
|
||||
self.assertTrue(claude_md.is_file())
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertIn("<!-- playbook:claude:start -->", text)
|
||||
|
||||
def test_external_clone_deployment_installs_snapshot_and_updates_claude(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_root = Path(tmp_dir)
|
||||
external_clone = tmp_root / "playbook"
|
||||
project_root = tmp_root / "project"
|
||||
copy_repo(external_clone)
|
||||
project_root.mkdir()
|
||||
|
||||
claude_md = project_root / ".claude" / "CLAUDE.md"
|
||||
claude_md.parent.mkdir()
|
||||
claude_md.write_text("# Existing Claude\n\nKeep this.\n", encoding="utf-8")
|
||||
|
||||
config_path = write_config(
|
||||
project_root,
|
||||
"playbook.toml",
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
playbook_root = "{CUSTOM_DEPLOY_ROOT.as_posix()}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_rules]
|
||||
no_backup = true
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
|
||||
[sync_prompts]
|
||||
no_backup = true
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
no_backup = true
|
||||
""",
|
||||
)
|
||||
|
||||
result = run_script(
|
||||
external_clone / "scripts" / "playbook.py",
|
||||
"-config",
|
||||
str(config_path),
|
||||
cwd=project_root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
|
||||
snapshot_root = project_root / CUSTOM_DEPLOY_ROOT
|
||||
self.assertTrue((snapshot_root / "SOURCE.md").is_file())
|
||||
self.assertTrue((snapshot_root / "scripts" / "playbook.py").is_file())
|
||||
self.assertTrue(
|
||||
(snapshot_root / "templates" / "AGENTS.template.md").is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(snapshot_root / "templates" / "AGENT_RULES.template.md").is_file()
|
||||
)
|
||||
self.assertTrue((snapshot_root / "templates" / "README.md").is_file())
|
||||
self.assertTrue((snapshot_root / "templates" / "memory-bank").is_dir())
|
||||
self.assertTrue((snapshot_root / "templates" / "prompts").is_dir())
|
||||
self.assertTrue((snapshot_root / "skills").is_dir())
|
||||
self.assertFalse((snapshot_root / "codex").exists())
|
||||
|
||||
self.assert_core_project_files(project_root)
|
||||
self.assert_docs_prefix(project_root, "custom/playbook/docs")
|
||||
|
||||
rules_text = (project_root / "AGENT_RULES.md").read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"`custom/playbook/` 是 Playbook 模板/供应商目录",
|
||||
rules_text,
|
||||
)
|
||||
self.assertNotIn("{{PLAYBOOK_ROOT}}", rules_text)
|
||||
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("Keep this.", text)
|
||||
self.assertIn("@../AGENTS.md", text)
|
||||
self.assertIn("@../AGENT_RULES.md", text)
|
||||
self.assertEqual(text.count("<!-- playbook:claude:start -->"), 1)
|
||||
self.assertFalse((project_root / "CLAUDE.md").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,49 +0,0 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEST_WORKFLOW = ROOT / ".gitea" / "workflows" / "test.yml"
|
||||
STANDARDS_WORKFLOW = ROOT / ".gitea" / "workflows" / "standards-check.yml"
|
||||
UPDATE_THIRDPARTY_WORKFLOW = ROOT / ".gitea" / "workflows" / "update-thirdparty-skills.yml"
|
||||
TEMPLATE_STANDARDS_WORKFLOW = (
|
||||
ROOT / "templates" / "ci" / "gitea" / ".gitea" / "workflows" / "standards-check.yml"
|
||||
)
|
||||
|
||||
|
||||
class GiteaWorkflowBootstrapTests(unittest.TestCase):
|
||||
def test_repo_workflows_do_not_call_repo_local_prepare_script_before_checkout(self):
|
||||
for workflow in (TEST_WORKFLOW, STANDARDS_WORKFLOW):
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
with self.subTest(workflow=workflow.name):
|
||||
self.assertNotIn("bash .gitea/ci/prepare_repo.sh", text)
|
||||
self.assertIn('git clone "$REPO_URL" "$REPO_DIR"', text)
|
||||
self.assertIn('echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV"', text)
|
||||
|
||||
def test_workflows_use_isolated_repo_dirs_per_job(self):
|
||||
for workflow in (
|
||||
TEST_WORKFLOW,
|
||||
STANDARDS_WORKFLOW,
|
||||
UPDATE_THIRDPARTY_WORKFLOW,
|
||||
TEMPLATE_STANDARDS_WORKFLOW,
|
||||
):
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
with self.subTest(workflow=workflow.name):
|
||||
self.assertNotIn('REPO_DIR="${WORKSPACE_DIR}/${REPO_NAME}"', text)
|
||||
self.assertNotIn('REPO_DIR="${{ env.WORKSPACE_DIR }}/$REPO_NAME"', text)
|
||||
self.assertIn('mktemp -d', text)
|
||||
self.assertTrue(
|
||||
'mkdir -p "$WORKSPACE_DIR"' in text
|
||||
or 'mkdir -p "${{ env.WORKSPACE_DIR }}"' in text
|
||||
)
|
||||
self.assertIn('echo "REPO_DIR=$REPO_DIR" >>', text)
|
||||
self.assertIn("if: always()", text)
|
||||
self.assertIn('rm -rf "$REPO_DIR"', text)
|
||||
|
||||
def test_test_workflow_installs_tomli_for_python_template_validation(self):
|
||||
text = TEST_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("python3 -m pip install yamllint tomli", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,221 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
MODE_ROOTS = {
|
||||
"snapshot": Path("custom/playbook"),
|
||||
"subtree": Path("docs/standards/playbook"),
|
||||
}
|
||||
|
||||
|
||||
def run_playbook(
|
||||
script: Path, config: Path, project_root: Path
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), "-config", str(config)],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def copy_subtree_source(destination: Path) -> None:
|
||||
destination.mkdir(parents=True)
|
||||
shutil.copy2(ROOT / ".gitattributes", destination / ".gitattributes")
|
||||
|
||||
for name in ("scripts", "templates"):
|
||||
shutil.copytree(
|
||||
ROOT / name,
|
||||
destination / name,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
||||
)
|
||||
|
||||
(destination / "docs").mkdir()
|
||||
for name in ("common", "tsl", "markdown"):
|
||||
shutil.copytree(ROOT / "docs" / name, destination / "docs" / name)
|
||||
|
||||
(destination / "rulesets").mkdir()
|
||||
shutil.copy2(
|
||||
ROOT / "rulesets" / "index.md",
|
||||
destination / "rulesets" / "index.md",
|
||||
)
|
||||
for name in ("tsl", "markdown"):
|
||||
shutil.copytree(
|
||||
ROOT / "rulesets" / name,
|
||||
destination / "rulesets" / name,
|
||||
)
|
||||
|
||||
(destination / "skills").mkdir()
|
||||
shutil.copytree(
|
||||
ROOT / "skills" / "style-cleanup",
|
||||
destination / "skills" / "style-cleanup",
|
||||
)
|
||||
|
||||
|
||||
def write_config(project_root: Path, install_mode: str, playbook_root: Path) -> Path:
|
||||
config = project_root / "playbook.toml"
|
||||
config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
playbook_root = "{playbook_root.as_posix()}"
|
||||
install_mode = "{install_mode}"
|
||||
|
||||
[sync_rules]
|
||||
date = "2026-01-01"
|
||||
no_backup = true
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
no_backup = true
|
||||
|
||||
[sync_prompts]
|
||||
no_backup = true
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
gitattr_mode = "overwrite"
|
||||
no_backup = true
|
||||
|
||||
[install_skills]
|
||||
agents_home = ".test-agents"
|
||||
mode = "list"
|
||||
skills = ["style-cleanup"]
|
||||
no_backup = true
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def seed_custom_files(project_root: Path) -> None:
|
||||
custom_memory = project_root / "memory-bank" / "custom.md"
|
||||
custom_memory.parent.mkdir(parents=True)
|
||||
custom_memory.write_text("custom memory\n", encoding="utf-8", newline="\n")
|
||||
|
||||
custom_prompt = project_root / "docs" / "prompts" / "custom.md"
|
||||
custom_prompt.parent.mkdir(parents=True)
|
||||
custom_prompt.write_text("custom prompt\n", encoding="utf-8", newline="\n")
|
||||
|
||||
(project_root / "CLAUDE.md").write_text(
|
||||
"# Existing Claude\n\nKeep this.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
|
||||
class PlaybookDeploymentTests(unittest.TestCase):
|
||||
def test_playbook_deployment_modes(self):
|
||||
for install_mode, playbook_root in MODE_ROOTS.items():
|
||||
with self.subTest(install_mode=install_mode):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
project_root = Path(tmp_dir) / "project"
|
||||
project_root.mkdir()
|
||||
|
||||
if install_mode == "subtree":
|
||||
source_root = project_root / playbook_root
|
||||
copy_subtree_source(source_root)
|
||||
script = source_root / "scripts" / "playbook.py"
|
||||
else:
|
||||
source_root = ROOT
|
||||
script = SCRIPT
|
||||
|
||||
seed_custom_files(project_root)
|
||||
config = write_config(project_root, install_mode, playbook_root)
|
||||
|
||||
for run_number in (1, 2):
|
||||
result = run_playbook(script, config, project_root)
|
||||
self.assertEqual(
|
||||
result.returncode,
|
||||
0,
|
||||
msg=(
|
||||
f"{install_mode} run {run_number} failed\n"
|
||||
f"{result.stdout}{result.stderr}"
|
||||
),
|
||||
)
|
||||
|
||||
expected_paths = (
|
||||
"AGENTS.md",
|
||||
"AGENT_RULES.md",
|
||||
"AGENT_RULES.local.md",
|
||||
"CLAUDE.md",
|
||||
".gitattributes",
|
||||
"memory-bank/project-brief.md",
|
||||
"memory-bank/active-context.md",
|
||||
"docs/prompts/system/agent-behavior.md",
|
||||
".agents/index.md",
|
||||
".agents/tsl/index.md",
|
||||
".agents/markdown/index.md",
|
||||
".test-agents/skills/style-cleanup/SKILL.md",
|
||||
)
|
||||
missing = [
|
||||
path
|
||||
for path in expected_paths
|
||||
if not (project_root / path).exists()
|
||||
]
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
self.assertEqual(
|
||||
(project_root / "memory-bank/custom.md").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
"custom memory\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
(project_root / "docs/prompts/custom.md").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
"custom prompt\n",
|
||||
)
|
||||
|
||||
claude_text = (project_root / "CLAUDE.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("Keep this.", claude_text)
|
||||
self.assertEqual(
|
||||
claude_text.count("<!-- playbook:claude:start -->"), 1
|
||||
)
|
||||
|
||||
docs_prefix = f"{playbook_root.as_posix()}/docs"
|
||||
agents_index = (project_root / ".agents/index.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn(f"- {docs_prefix}", agents_index)
|
||||
|
||||
installed_skill = (
|
||||
project_root
|
||||
/ ".test-agents/skills/style-cleanup/SKILL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
f"`{docs_prefix}/tsl/code_style.md`", installed_skill
|
||||
)
|
||||
self.assertNotIn("`docs/tsl/code_style.md`", installed_skill)
|
||||
|
||||
rules_text = (project_root / "AGENT_RULES.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn(
|
||||
f"`{playbook_root.as_posix()}/` 是 Playbook 模板/供应商目录",
|
||||
rules_text,
|
||||
)
|
||||
|
||||
if install_mode == "snapshot":
|
||||
snapshot_root = project_root / playbook_root
|
||||
self.assertTrue((snapshot_root / "SOURCE.md").is_file())
|
||||
self.assertTrue(
|
||||
(snapshot_root / "scripts/playbook.py").is_file()
|
||||
)
|
||||
self.assertTrue((snapshot_root / "skills").is_dir())
|
||||
else:
|
||||
self.assertFalse((source_root / "SOURCE.md").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,437 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
SOURCE_GITATTR = ROOT / ".gitattributes"
|
||||
DEFAULT_DEPLOY_ROOT = "docs/standards/playbook"
|
||||
|
||||
|
||||
def run_cli(*args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def run_script(script_path: Path, *args, cwd: Path | None = None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script_path), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
)
|
||||
|
||||
|
||||
def read_entries(path: Path) -> list[str]:
|
||||
entries = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
entries.append(stripped)
|
||||
return entries
|
||||
|
||||
|
||||
class PlaybookConfigActionTests(unittest.TestCase):
|
||||
def _run_sync_standards(self, root: Path, mode: str) -> subprocess.CompletedProcess:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{root}\"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = [\"tsl\"]
|
||||
gitattr_mode = \"{mode}\"
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
return run_cli("-config", str(config_path), cwd=root)
|
||||
|
||||
def test_gitattr_mode_skip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
sentinel = "*.keep text eol=lf\n"
|
||||
(root / ".gitattributes").write_text(sentinel, encoding="utf-8")
|
||||
|
||||
result = self._run_sync_standards(root, "skip")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(
|
||||
(root / ".gitattributes").read_text(encoding="utf-8"),
|
||||
sentinel,
|
||||
)
|
||||
|
||||
def test_gitattr_mode_overwrite(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
(root / ".gitattributes").write_text("bad\n", encoding="utf-8")
|
||||
|
||||
result = self._run_sync_standards(root, "overwrite")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(
|
||||
(root / ".gitattributes").read_text(encoding="utf-8"),
|
||||
SOURCE_GITATTR.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_gitattr_mode_block(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
result = self._run_sync_standards(root, "block")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
content = (root / ".gitattributes").read_text(encoding="utf-8")
|
||||
self.assertIn("# BEGIN playbook .gitattributes", content)
|
||||
self.assertIn("# END playbook .gitattributes", content)
|
||||
|
||||
def test_gitattr_mode_append(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
src_entries = read_entries(SOURCE_GITATTR)
|
||||
(root / ".gitattributes").write_text(
|
||||
src_entries[0] + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
result = self._run_sync_standards(root, "append")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
content = (root / ".gitattributes").read_text(encoding="utf-8")
|
||||
self.assertIn("Added from playbook .gitattributes", content)
|
||||
|
||||
def test_sync_rules_no_backup_skips_backup_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
rules = root / "AGENT_RULES.md"
|
||||
rules.write_text("old rules", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_rules]
|
||||
force = true
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
backups = list(root.glob("AGENT_RULES.md.bak.*"))
|
||||
self.assertEqual(backups, [])
|
||||
self.assertTrue(rules.is_file())
|
||||
|
||||
def test_sync_standards_no_backup_skips_agents_and_gitattributes_backup(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents = root / ".agents" / "tsl"
|
||||
agents.mkdir(parents=True)
|
||||
(agents / "index.md").write_text("old", encoding="utf-8")
|
||||
|
||||
gitattributes = root / ".gitattributes"
|
||||
gitattributes.write_text("*.txt text\n", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
gitattr_mode = "append"
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
agents_backups = list((root / ".agents").glob("tsl.bak.*"))
|
||||
self.assertEqual(agents_backups, [])
|
||||
git_backups = list(root.glob(".gitattributes.bak.*"))
|
||||
self.assertEqual(git_backups, [])
|
||||
|
||||
def test_install_skills_no_backup_replaces_existing_skill_without_backup(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
skills_root = root / "agents" / "skills"
|
||||
existing = skills_root / "brainstorming"
|
||||
existing.mkdir(parents=True)
|
||||
(existing / "stale.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{root / 'agents'}"
|
||||
mode = "list"
|
||||
skills = ["brainstorming"]
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
backups = list(skills_root.glob("brainstorming.bak.*"))
|
||||
self.assertEqual(backups, [])
|
||||
self.assertFalse((existing / "stale.txt").exists())
|
||||
self.assertTrue((existing / "SKILL.md").is_file())
|
||||
|
||||
def test_sync_memory_bank_adds_missing_files_without_deleting_custom(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
memory_bank = root / "memory-bank"
|
||||
memory_bank.mkdir(parents=True)
|
||||
custom = memory_bank / "custom.md"
|
||||
custom.write_text("custom", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
self.assertTrue(custom.exists())
|
||||
self.assertTrue((memory_bank / "project-brief.md").is_file())
|
||||
|
||||
def test_sync_prompts_adds_missing_files_without_deleting_custom(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
prompts = root / "docs" / "prompts"
|
||||
prompts.mkdir(parents=True)
|
||||
custom = prompts / "custom.md"
|
||||
custom.write_text("custom", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_prompts]
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
self.assertTrue(custom.exists())
|
||||
self.assertTrue((prompts / "system" / "agent-behavior.md").is_file())
|
||||
self.assertFalse((root / "docs" / "workflows").exists())
|
||||
|
||||
def test_sync_memory_bank_force_overwrites_template_files_only(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
memory_bank = root / "memory-bank"
|
||||
memory_bank.mkdir(parents=True)
|
||||
custom = memory_bank / "custom.md"
|
||||
custom.write_text("custom", encoding="utf-8")
|
||||
|
||||
brief = memory_bank / "project-brief.md"
|
||||
brief.write_text("OLD", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
force = true
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
self.assertTrue(custom.exists())
|
||||
self.assertNotIn("OLD", brief.read_text(encoding="utf-8"))
|
||||
backups = list(memory_bank.glob("project-brief.md.bak.*"))
|
||||
self.assertEqual(backups, [])
|
||||
|
||||
def test_sync_templates_replaces_playbook_scripts_without_main_language_support(
|
||||
self,
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{tmp_dir}\"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_rules]
|
||||
|
||||
[sync_memory_bank]
|
||||
|
||||
[sync_standards]
|
||||
langs = [\"cpp\", \"tsl\"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
agents_md = Path(tmp_dir) / "AGENTS.md"
|
||||
text = agents_md.read_text(encoding="utf-8")
|
||||
self.assertIn(".agents/cpp/index.md", text)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", text)
|
||||
|
||||
tech_context = Path(tmp_dir) / "memory-bank" / "tech-context.md"
|
||||
tech_context_text = tech_context.read_text(encoding="utf-8")
|
||||
self.assertNotIn("{{LANGUAGE_1}}", tech_context_text)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", tech_context_text)
|
||||
self.assertNotIn("**主要语言**", tech_context_text)
|
||||
|
||||
rules_md = Path(tmp_dir) / "AGENT_RULES.md"
|
||||
rules_text = rules_md.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"docs/standards/playbook/scripts/main_loop.py claim",
|
||||
rules_text,
|
||||
)
|
||||
self.assertIn(
|
||||
"`docs/standards/playbook/` 是 Playbook 模板/供应商目录",
|
||||
rules_text,
|
||||
)
|
||||
self.assertIn(
|
||||
"默认排除 `docs/standards/playbook/`",
|
||||
rules_text,
|
||||
)
|
||||
self.assertIn("docs/superpowers/plans", rules_text)
|
||||
self.assertNotIn("plan_progress.py", rules_text)
|
||||
self.assertNotIn("record-spec", rules_text)
|
||||
self.assertIn(
|
||||
"追加到 `memory-bank/progress.md` 的",
|
||||
rules_text,
|
||||
)
|
||||
self.assertIn("领取前不得进入 `$executing-plans`", rules_text)
|
||||
self.assertIn(
|
||||
"`$subagent-driven-development` 仅在 Plan 或平台明确要求时使用",
|
||||
rules_text,
|
||||
)
|
||||
self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules_text)
|
||||
self.assertNotIn("{{PLAYBOOK_ROOT}}", rules_text)
|
||||
self.assertFalse(rules_text.endswith("\n\n"))
|
||||
|
||||
def test_sync_standards_rewrites_typescript_docs_prefix_for_snapshot_playbook(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
install_config = root / "install.toml"
|
||||
install_config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["typescript"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
install_result = run_cli("-config", str(install_config))
|
||||
self.assertEqual(install_result.returncode, 0, msg=install_result.stderr)
|
||||
|
||||
sync_config = root / "sync.toml"
|
||||
sync_config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["typescript"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
snapshot_script = (
|
||||
root / "docs" / "standards" / "playbook" / "scripts" / "playbook.py"
|
||||
)
|
||||
sync_result = run_script(
|
||||
snapshot_script, "-config", str(sync_config), cwd=root
|
||||
)
|
||||
self.assertEqual(sync_result.returncode, 0, msg=sync_result.stderr)
|
||||
|
||||
agents_index = root / ".agents" / "typescript" / "index.md"
|
||||
text = agents_index.read_text(encoding="utf-8")
|
||||
self.assertIn("`docs/standards/playbook/docs/typescript/", text)
|
||||
self.assertNotIn("`docs/typescript/", text)
|
||||
|
||||
def test_sync_memory_bank_includes_active_context_and_human_readable_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
install_mode = "snapshot"
|
||||
|
||||
[sync_rules]
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "MyProject"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
active_context = Path(tmp_dir) / "memory-bank" / "active-context.md"
|
||||
self.assertTrue(active_context.is_file())
|
||||
|
||||
progress = Path(tmp_dir) / "memory-bank" / "progress.md"
|
||||
progress_text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("## Current Focus", progress_text)
|
||||
self.assertIn("## 状态块示例", progress_text)
|
||||
self.assertNotIn("phase: planning", progress_text)
|
||||
self.assertNotIn("executor: executing-plans", progress_text)
|
||||
self.assertNotIn("workflow-state", progress_text)
|
||||
self.assertIn("## Plan Status", progress_text)
|
||||
self.assertIn("<!-- plan-status:start -->", progress_text)
|
||||
self.assertIn("<!-- plan-status:end -->", progress_text)
|
||||
|
||||
system_patterns = Path(tmp_dir) / "memory-bank" / "system-patterns.md"
|
||||
system_patterns_text = system_patterns.read_text(encoding="utf-8")
|
||||
self.assertIn("# 系统模式与约束", system_patterns_text)
|
||||
self.assertIn("## 核心不变量", system_patterns_text)
|
||||
|
||||
agents_md = Path(tmp_dir) / "AGENTS.md"
|
||||
agents_text = agents_md.read_text(encoding="utf-8")
|
||||
self.assertIn("memory-bank/active-context.md", agents_text)
|
||||
|
||||
rules_md = Path(tmp_dir) / "AGENT_RULES.md"
|
||||
rules_text = rules_md.read_text(encoding="utf-8")
|
||||
self.assertIn("memory-bank/active-context.md", rules_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,67 +0,0 @@
|
||||
import tempfile
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts import playbook
|
||||
|
||||
|
||||
class PlaybookTomlParserTests(unittest.TestCase):
|
||||
def test_minimal_parser_allows_dotted_section_name(self):
|
||||
raw = """
|
||||
[a.b]
|
||||
key = 1
|
||||
"""
|
||||
data = playbook.loads_toml_minimal(raw)
|
||||
self.assertIn("a.b", data)
|
||||
self.assertEqual(data["a.b"]["key"], 1)
|
||||
|
||||
def test_minimal_parser_rejects_multiline_string(self):
|
||||
raw = '[section]\nvalue = """line1\nline2"""\n'
|
||||
with self.assertRaises(ValueError):
|
||||
playbook.loads_toml_minimal(raw)
|
||||
|
||||
def test_load_config_preserves_windows_users_path_in_basic_string(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(
|
||||
'[playbook]\nproject_root = "C:\\Users\\demo\\workspace"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = playbook.load_config(config_path)
|
||||
|
||||
self.assertEqual(data["playbook"]["project_root"], r"C:\Users\demo\workspace")
|
||||
|
||||
def test_load_config_preserves_windows_escape_like_segments_for_path_keys(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(
|
||||
'[playbook]\nproject_root = "C:\\tmp\\notes"\n\n'
|
||||
'[install_skills]\nagents_home = "D:\\new\\tab"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = playbook.load_config(config_path)
|
||||
|
||||
self.assertEqual(data["playbook"]["project_root"], r"C:\tmp\notes")
|
||||
self.assertEqual(data["install_skills"]["agents_home"], r"D:\new\tab")
|
||||
|
||||
def test_load_config_keeps_already_escaped_windows_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(
|
||||
'[playbook]\nproject_root = "C:\\\\Users\\\\demo\\\\workspace"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = playbook.load_config(config_path)
|
||||
|
||||
self.assertEqual(data["playbook"]["project_root"], r"C:\Users\demo\workspace")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,79 +0,0 @@
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_PATHS = sorted((ROOT / "scripts").glob("*.py")) + sorted(
|
||||
(ROOT / ".gitea" / "ci").glob("*.py")
|
||||
)
|
||||
|
||||
|
||||
class ScriptLineEndingTests(unittest.TestCase):
|
||||
def test_script_text_writes_pin_lf_newlines(self):
|
||||
offenders: list[str] = []
|
||||
for path in SCRIPT_PATHS:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if is_write_text_without_lf_newline(node):
|
||||
offenders.append(f"{path.relative_to(ROOT)}:{node.lineno}")
|
||||
elif is_text_open_for_writing_without_newline(node):
|
||||
offenders.append(f"{path.relative_to(ROOT)}:{node.lineno}")
|
||||
|
||||
self.assertEqual(offenders, [])
|
||||
|
||||
|
||||
def has_lf_newline_keyword(node: ast.Call) -> bool:
|
||||
newline = next(
|
||||
(keyword for keyword in node.keywords if keyword.arg == "newline"),
|
||||
None,
|
||||
)
|
||||
return (
|
||||
newline is not None
|
||||
and isinstance(newline.value, ast.Constant)
|
||||
and newline.value.value == "\n"
|
||||
)
|
||||
|
||||
|
||||
def is_write_text_without_lf_newline(node: ast.Call) -> bool:
|
||||
func = node.func
|
||||
return (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "write_text"
|
||||
and not has_lf_newline_keyword(node)
|
||||
)
|
||||
|
||||
|
||||
def is_text_open_for_writing_without_newline(node: ast.Call) -> bool:
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
is_open = func.id == "open"
|
||||
elif isinstance(func, ast.Attribute):
|
||||
is_open = func.attr == "open"
|
||||
else:
|
||||
is_open = False
|
||||
if not is_open:
|
||||
return False
|
||||
|
||||
mode_node = None
|
||||
if len(node.args) >= 2:
|
||||
mode_node = node.args[1]
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg == "mode":
|
||||
mode_node = keyword.value
|
||||
break
|
||||
|
||||
if mode_node is None:
|
||||
return False
|
||||
if not isinstance(mode_node, ast.Constant) or not isinstance(mode_node.value, str):
|
||||
return False
|
||||
|
||||
mode = mode_node.value
|
||||
writes_text = any(flag in mode for flag in ("w", "a", "x")) and "b" not in mode
|
||||
return writes_text and not has_lf_newline_keyword(node)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,9 +6,6 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
class TemplateContractsTests(unittest.TestCase):
|
||||
def test_project_templates_drop_legacy_language_placeholders(self):
|
||||
example_text = (ROOT / "playbook.toml.example").read_text(encoding="utf-8")
|
||||
self.assertNotIn("main_language", example_text)
|
||||
|
||||
templates_readme = (ROOT / "templates" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "skills" / "tsl-syntax-reference" / "scripts" / "lookup.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("tsl_syntax_lookup", SCRIPT)
|
||||
lookup = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(lookup)
|
||||
|
||||
|
||||
NATURAL_LANGUAGE_CASES = (
|
||||
("帮我写个最简单能跑的天软脚本", "write", "01_quickstart.md"),
|
||||
("脚本和可复用函数文件有什么区别", "explain", "02_core_model.md"),
|
||||
("字符串和数组下标从几开始", "explain", "03_values_and_literals.md"),
|
||||
("常量怎么声明,变量能不能直接赋值", "explain", "04_variables_and_constants.md"),
|
||||
("函数怎么带默认参数", "write", "05_functions_and_calls.md"),
|
||||
("赋值和相等比较分别怎么写", "explain", "06_expressions_and_operators.md"),
|
||||
("循环里满足条件就跳出去", "write", "07_control_flow.md"),
|
||||
("怎么定义类并创建对象", "write", "08_objects_and_classes.md"),
|
||||
("多个文件复用一组函数怎么组织", "write", "09_units_and_scope.md"),
|
||||
("临时切换系统参数再调用函数", "write", "10_runtime_context_and_with.md"),
|
||||
("为什么声明函数后面写代码会报错", "diagnose", "11_pitfalls.md"),
|
||||
("二维数组怎么判断某行存在", "write", "12_matrix_and_collections.md"),
|
||||
("二维结果按某一列保留匹配行", "write", "13_resultset_and_filters.md"),
|
||||
("数据库左连接后分组排序", "write", "14_ts_sql.md"),
|
||||
("程序慢怎么计时找瓶颈", "diagnose", "15_debug_and_profiler.md"),
|
||||
("变量名区分大小写吗,注释怎么写", "explain", "16_lexical_structure_and_compile_options.md"),
|
||||
("字符串转整数失败怎么办", "diagnose", "17_types_and_conversions.md"),
|
||||
("调用 DLL 并开线程", "write", "18_external_calls_and_threads.md"),
|
||||
("找不到 tsf 文件怎么改搜索路径", "diagnose", "19_namespace_libpath_and_unit_runtime.md"),
|
||||
("运行时怎么查看对象属于哪个类", "explain", "20_object_runtime_and_introspection.md"),
|
||||
("内存流怎么读写", "write", "21_builtin_runtime_objects.md"),
|
||||
("矩阵求逆和转置", "write", "22_matrix_deep_dive.md"),
|
||||
("高性能矩阵怎么排序", "write", "23_fmarray.md"),
|
||||
("让自定义对象支持下标和 for in", "write", "24_object_overloads_and_iteration.md"),
|
||||
)
|
||||
|
||||
|
||||
class TslSyntaxLookupTests(unittest.TestCase):
|
||||
def test_natural_language_topic_matrix(self):
|
||||
top1 = 0
|
||||
top5 = 0
|
||||
misses = []
|
||||
for query, mode, expected_page in NATURAL_LANGUAGE_CASES:
|
||||
pages = [
|
||||
match.section.page.name
|
||||
for match in lookup.query_sections(query, mode).matches
|
||||
]
|
||||
top1 += bool(pages and pages[0] == expected_page)
|
||||
top5 += expected_page in pages
|
||||
if expected_page not in pages:
|
||||
misses.append((query, expected_page, pages))
|
||||
|
||||
self.assertGreaterEqual(top1, 20, (top1, misses))
|
||||
self.assertEqual(top5, len(NATURAL_LANGUAGE_CASES), (top5, misses))
|
||||
|
||||
def test_short_ascii_tokens_use_identifier_boundaries(self):
|
||||
result = lookup.query_sections("if", "explain")
|
||||
ids = "\n".join(match.section.id for match in result.matches)
|
||||
|
||||
self.assertIn("07_control_flow", ids)
|
||||
self.assertNotIn("tinifile", ids)
|
||||
self.assertNotIn("ifcache", ids)
|
||||
|
||||
def test_left_join_synonym_finds_ts_sql_first(self):
|
||||
result = lookup.query_sections("数据库左连接", "write")
|
||||
|
||||
self.assertEqual(result.matches[0].section.page.name, "14_ts_sql.md")
|
||||
self.assertTrue(
|
||||
any(reason.startswith("synonym=") for reason in result.matches[0].reasons)
|
||||
)
|
||||
|
||||
def test_synonym_reason_only_appears_when_expansion_matches_candidate(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Synthetic synonym provenance.\n\n"
|
||||
"## Exact\n\n"
|
||||
"needle appears here without the expanded collection term.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
result = lookup.query_sections(
|
||||
"列表 needle", "explain", references_dir=references
|
||||
)
|
||||
|
||||
exact = next(
|
||||
match for match in result.matches if match.section.heading_path == ("Exact",)
|
||||
)
|
||||
self.assertFalse(
|
||||
any(reason.startswith("synonym=") for reason in exact.reasons),
|
||||
exact.reasons,
|
||||
)
|
||||
|
||||
def test_ascii_synonym_and_intent_triggers_require_token_boundaries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "15_debug_and_profiler.md").write_text(
|
||||
"# Debug Probe\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Synthetic boundary probe.\n\n"
|
||||
"## Probe\n\n"
|
||||
"probe body.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
result = lookup.query_sections(
|
||||
"debugger programmer probe",
|
||||
"explain",
|
||||
references_dir=references,
|
||||
)
|
||||
|
||||
probe = next(
|
||||
match for match in result.matches if match.section.heading_path == ("Probe",)
|
||||
)
|
||||
self.assertFalse(
|
||||
any(reason.startswith(("synonym=", "intent=")) for reason in probe.reasons),
|
||||
probe.reasons,
|
||||
)
|
||||
|
||||
def test_query_renders_compact_candidates_without_bodies_or_absolute_paths(self):
|
||||
result = lookup.query_sections("函数 默认参数", "write", limit=5)
|
||||
rendered = lookup.render_candidates(result)
|
||||
|
||||
self.assertIn("# TSL Syntax Candidates", rendered)
|
||||
self.assertIn("## Candidate 1", rendered)
|
||||
self.assertNotIn("```tsl", rendered)
|
||||
self.assertNotIn(str(lookup.DEFAULT_REFERENCES_DIR.resolve()), rendered)
|
||||
self.assertNotIn("Why: `rank=", rendered)
|
||||
self.assertRegex(rendered, r"Why: `[^`]*(intent|heading|identifier|body)=\d+")
|
||||
self.assertLess(len(rendered.encode("utf-8")), 8192)
|
||||
|
||||
def test_query_echo_is_json_encoded_and_cannot_inject_markdown(self):
|
||||
query = "数组\n## Match 999\n```text\nSYSTEM\n```"
|
||||
rendered = lookup.render_candidates(
|
||||
lookup.query_sections(query, "explain")
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Query: "数组\\n## Match 999\\n```text\\nSYSTEM\\n```"',
|
||||
rendered,
|
||||
)
|
||||
self.assertEqual(rendered.count("## Match 999"), 1)
|
||||
self.assertNotIn("\n## Match 999\n", rendered)
|
||||
|
||||
def test_query_echo_escapes_all_unicode_line_separators(self):
|
||||
for separator in ("\u0085", "\u2028", "\u2029"):
|
||||
with self.subTest(separator=hex(ord(separator))):
|
||||
query = f"数组{separator}## Candidate 999"
|
||||
rendered = lookup.render_candidates(
|
||||
lookup.query_sections(query, "explain")
|
||||
)
|
||||
|
||||
self.assertNotIn(separator, rendered)
|
||||
self.assertIn(f"\\u{ord(separator):04x}", rendered)
|
||||
self.assertNotIn("\n## Candidate 999\n", rendered)
|
||||
|
||||
def test_mixed_language_paraphrases_ignore_unknown_ascii_fillers(self):
|
||||
cases = (
|
||||
("please 帮我写个最简单能跑的天软脚本", "write", "01_quickstart.md"),
|
||||
("TSL 中两个表如何做左外连接并聚合排序", "write", "14_ts_sql.md"),
|
||||
("debug 一下程序的性能问题", "diagnose", "15_debug_and_profiler.md"),
|
||||
("请写一个可以运行的最小 Tinysoft program", "write", "01_quickstart.md"),
|
||||
)
|
||||
for query, mode, expected_page in cases:
|
||||
with self.subTest(query=query):
|
||||
result = lookup.query_sections(query, mode)
|
||||
self.assertTrue(result.matches, query)
|
||||
self.assertEqual(result.matches[0].section.page.name, expected_page)
|
||||
|
||||
def test_section_is_only_cli_path_that_returns_body(self):
|
||||
result = lookup.query_sections("基础函数", "write", limit=5)
|
||||
section = result.matches[0].section
|
||||
query_completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--query",
|
||||
"基础函数",
|
||||
"--mode",
|
||||
"write",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
section_completed = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--section", section.id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(query_completed.returncode, 0)
|
||||
self.assertEqual(section_completed.returncode, 0)
|
||||
self.assertNotIn(section.body.rstrip(), query_completed.stdout)
|
||||
self.assertIn(section.body.rstrip(), section_completed.stdout)
|
||||
|
||||
def test_body_only_match_prefers_deepest_section(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Synthetic hierarchy.\n\n"
|
||||
"## Parent\n\n"
|
||||
"Parent introduction.\n\n"
|
||||
"### Child\n\n"
|
||||
"Child introduction.\n\n"
|
||||
"#### Exact Leaf\n\n"
|
||||
"needle appears only in the leaf body.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
result = lookup.query_sections(
|
||||
"needle", "explain", references_dir=references
|
||||
)
|
||||
|
||||
self.assertEqual(result.matches[0].section.heading_path[-1], "Exact Leaf")
|
||||
|
||||
def test_limit_applies_to_final_rendered_candidate_list(self):
|
||||
for limit in (1, 2, 5):
|
||||
with self.subTest(limit=limit):
|
||||
result = lookup.query_sections("函数 默认参数", "write", limit=limit)
|
||||
rendered = lookup.render_candidates(result)
|
||||
self.assertEqual(rendered.count("## Candidate "), limit)
|
||||
|
||||
def test_required_context_stays_required_when_query_also_matches_it(self):
|
||||
result = lookup.query_sections("语言核心事实速查", "write", limit=5)
|
||||
rendered = lookup.render_candidates(result)
|
||||
required_id = "01_quickstart--语言核心事实速查"
|
||||
|
||||
self.assertIn(
|
||||
f"Required: yes\nSection ID: `{required_id}`",
|
||||
rendered,
|
||||
)
|
||||
self.assertEqual(rendered.count(f"Section ID: `{required_id}`"), 1)
|
||||
|
||||
def test_displayed_scores_are_sorted_descending(self):
|
||||
for query, mode in (
|
||||
("函数 默认参数", "write"),
|
||||
("变量怎么改", "write"),
|
||||
("数组", "diagnose"),
|
||||
("invalid statement 声明区", "diagnose"),
|
||||
("程序慢怎么计时找瓶颈", "diagnose"),
|
||||
):
|
||||
with self.subTest(query=query, mode=mode):
|
||||
result = lookup.query_sections(query, mode, limit=5)
|
||||
scores = [match.score for match in result.matches]
|
||||
|
||||
self.assertEqual(scores, sorted(scores, reverse=True))
|
||||
|
||||
def test_parser_creates_unique_h2_h3_h4_section_ids(self):
|
||||
sections = lookup.load_sections(lookup.DEFAULT_REFERENCES_DIR)
|
||||
ids = [section.id for section in sections]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
self.assertTrue(any("05_functions_and_calls" in value for value in ids))
|
||||
|
||||
def test_parser_indexes_h4_pitfall_sections(self):
|
||||
sections = lookup.load_sections(lookup.DEFAULT_REFERENCES_DIR)
|
||||
|
||||
self.assertTrue(any(len(section.heading_path) == 3 for section in sections))
|
||||
self.assertTrue(any("把-当成赋值" in section.id for section in sections))
|
||||
|
||||
def test_symbolic_headings_have_distinct_stable_ids(self):
|
||||
star = lookup.section_id("sample.md", ("Examples", "with *"))
|
||||
double_star = lookup.section_id("sample.md", ("Examples", "with **"))
|
||||
|
||||
self.assertNotEqual(star, double_star)
|
||||
self.assertNotRegex(double_star, r"-2$")
|
||||
|
||||
def test_all_promised_symbolic_heading_slugs_are_distinct(self):
|
||||
ids = {
|
||||
lookup.section_id("sample.md", ("Examples", heading))
|
||||
for heading in ("with *", "with **", "operator[]", "walk ::", "walk :.")
|
||||
}
|
||||
|
||||
self.assertEqual(len(ids), 5)
|
||||
|
||||
def test_check_requires_one_nonempty_duty_section_per_page(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "missing.md").write_text(
|
||||
"# Missing\n\n## Rules\n\nText.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(references / "duplicate.md").write_text(
|
||||
"# Duplicate\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"One.\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Two.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
messages = "\n".join(problem.message for problem in problems)
|
||||
self.assertIn("必须有且仅有一个非空「本篇职责」", messages)
|
||||
|
||||
def test_check_rejects_h2_h3_h4_heading_level_jumps(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Summary.\n\n"
|
||||
"#### Skipped H3\n\n"
|
||||
"Details.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
self.assertTrue(
|
||||
any("标题层级跳跃" in problem.message for problem in problems), problems
|
||||
)
|
||||
|
||||
def test_write_mode_includes_file_model_and_direct_example(self):
|
||||
result = lookup.query_sections("写函数 命名参数", "write", limit=4)
|
||||
rendered = lookup.render_candidates(result)
|
||||
|
||||
self.assertTrue(
|
||||
any("文件模型" in " > ".join(section.heading_path) for section in result.prelude)
|
||||
)
|
||||
self.assertTrue(
|
||||
any("可直接照写示例" in match.section.identities for match in result.matches)
|
||||
)
|
||||
self.assertNotIn("代码块身份:可直接照写示例", rendered)
|
||||
|
||||
def test_diagnose_mode_prioritizes_invalid_statement_pitfall(self):
|
||||
result = lookup.query_sections("invalid statement 声明区", "diagnose", limit=4)
|
||||
self.assertIn("11_pitfalls.md", result.matches[0].section.page.as_posix())
|
||||
|
||||
def test_explain_mode_does_not_force_write_prelude(self):
|
||||
result = lookup.query_sections("数组下标", "explain", limit=3)
|
||||
self.assertEqual(result.prelude, [])
|
||||
|
||||
def test_no_match_returns_exit_code_two_in_every_mode(self):
|
||||
query = "夔魍魉xyzqv"
|
||||
for mode in ("write", "diagnose", "explain"):
|
||||
with self.subTest(mode=mode):
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--query",
|
||||
query,
|
||||
"--mode",
|
||||
mode,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(completed.returncode, 2)
|
||||
self.assertIn("# TSL Syntax Candidates", completed.stdout)
|
||||
self.assertIn(f'Query: "{query}"', completed.stdout)
|
||||
self.assertIn("no matching sections", completed.stderr)
|
||||
|
||||
def test_missing_section_returns_nearest_section_ids(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n## Exact Section\n\nDetails.\n\n## Other\n\nMore.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
expected_id = lookup.load_sections(references)[0].id
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--section",
|
||||
f"{expected_id}-typo",
|
||||
"--references-dir",
|
||||
str(references),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 2)
|
||||
self.assertIn("Nearest section IDs:", completed.stderr)
|
||||
self.assertIn(f"- {expected_id}\n", completed.stderr)
|
||||
|
||||
def test_write_identifier_query_keeps_required_context(self):
|
||||
result = lookup.query_sections("varByRef 命名参数", "write", limit=5)
|
||||
prelude_pages = {section.page.name for section in result.prelude}
|
||||
self.assertEqual(prelude_pages, {"01_quickstart.md", "02_core_model.md"})
|
||||
|
||||
def test_write_prelude_renders_source_for_every_section(self):
|
||||
result = lookup.query_sections("varByRef 命名参数", "write", limit=5)
|
||||
rendered = lookup.render_candidates(result)
|
||||
required_candidates = rendered.split("Required: no", 1)[0]
|
||||
self.assertEqual(required_candidates.count("Source: `"), len(result.prelude))
|
||||
for section in result.prelude:
|
||||
self.assertIn(
|
||||
f"Source: `references/{section.page.name}`", required_candidates
|
||||
)
|
||||
|
||||
def test_check_rejects_unknown_code_block_identity(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n"
|
||||
"## Example\n\n"
|
||||
"代码块身份:未知身份\n\n"
|
||||
"```tsl\n"
|
||||
"return 1;\n"
|
||||
"```\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
self.assertTrue(
|
||||
any("未知身份" in problem.message for problem in problems), problems
|
||||
)
|
||||
|
||||
def test_parser_and_validator_reject_prose_between_identity_and_fence(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
page = references / "sample.md"
|
||||
page.write_text(
|
||||
"# Sample\n\n"
|
||||
"## Example\n\n"
|
||||
"代码块身份:可直接照写示例\n"
|
||||
"This arbitrary prose breaks the metadata association.\n\n"
|
||||
"```tsl\n"
|
||||
"return 1;\n"
|
||||
"```\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
sections = lookup.load_sections(references)
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
self.assertEqual(sections[0].identities, ())
|
||||
self.assertTrue(any("恰好一个代码块身份" in item.message for item in problems))
|
||||
|
||||
def test_parser_and_validator_accept_structured_block_description(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
page = references / "sample.md"
|
||||
page.write_text(
|
||||
"# Sample\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Structured metadata sample.\n\n"
|
||||
"## Example\n\n"
|
||||
"代码块身份:配置片段 / 概念骨架\n"
|
||||
"代码块说明:This is structured metadata.\n\n"
|
||||
"```text\n"
|
||||
"example\n"
|
||||
"```\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
sections = lookup.load_sections(references)
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
example = next(
|
||||
section for section in sections if section.heading_path == ("Example",)
|
||||
)
|
||||
self.assertEqual(example.identities, ("配置片段 / 概念骨架",))
|
||||
self.assertEqual(problems, [])
|
||||
|
||||
def test_check_accumulates_fence_identity_and_link_problems(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n"
|
||||
"## Example\n\n"
|
||||
"[missing](missing.md)\n\n"
|
||||
"代码块身份:输出片段\n"
|
||||
"代码块身份:可直接照写示例\n\n"
|
||||
"```tsl\n"
|
||||
"return 1;\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
messages = "\n".join(problem.message for problem in problems)
|
||||
self.assertIn("本地链接不存在", messages)
|
||||
self.assertIn("代码围栏未闭合", messages)
|
||||
self.assertIn("恰好一个代码块身份", messages)
|
||||
|
||||
def test_check_ignores_markdown_link_shapes_inside_inline_code(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
(references / "sample.md").write_text(
|
||||
"# Sample\n\n"
|
||||
"## 本篇职责\n\n"
|
||||
"Inline-code link sample.\n\n"
|
||||
"## Operators\n\n"
|
||||
"Use `function operator[](index);`.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
problems = lookup.validate_references(references)
|
||||
|
||||
self.assertEqual(problems, [])
|
||||
|
||||
def test_natural_chinese_query_finds_object_creation(self):
|
||||
result = lookup.query_sections("写一个类并创建对象", "write", limit=5)
|
||||
sources = "\n".join(match.section.id for match in result.matches)
|
||||
self.assertIn("08_objects_and_classes", sources)
|
||||
|
||||
def test_ranking_tiers_beat_adversarial_page_order(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
references = Path(tmp_dir)
|
||||
pages = {
|
||||
"a_body.md": "# Other\n\n## Other\n\nneedle appears as plain prose.\n",
|
||||
"b_title.md": "# needle\n\n## Other\n\nNo body match.\n",
|
||||
"c_identifier.md": "# Other\n\n## Other\n\nUse `needle` here.\n",
|
||||
"z_heading.md": "# Other\n\n## needle\n\nNo body detail.\n",
|
||||
}
|
||||
for name, text in pages.items():
|
||||
(references / name).write_text(
|
||||
text, encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
result = lookup.query_sections(
|
||||
"needle", "explain", limit=4, references_dir=references
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[match.section.page.name for match in result.matches],
|
||||
["z_heading.md", "c_identifier.md", "b_title.md", "a_body.md"],
|
||||
)
|
||||
|
||||
def test_tsl_identifier_is_preserved(self):
|
||||
result = lookup.query_sections("varByRef 命名参数", "explain", limit=5)
|
||||
searchable_matches = "\n".join(
|
||||
match.section.searchable_text for match in result.matches
|
||||
)
|
||||
|
||||
self.assertRegex(searchable_matches, r"(?i)varByRef")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,241 +0,0 @@
|
||||
import importlib.util
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL_DIR = ROOT / "skills" / "tsl-syntax-reference"
|
||||
SKILL_FILE = SKILL_DIR / "SKILL.md"
|
||||
REFERENCES_DIR = SKILL_DIR / "references"
|
||||
RULESET_FILE = ROOT / "rulesets" / "tsl" / "index.md"
|
||||
LOOKUP_SCRIPT = SKILL_DIR / "scripts" / "lookup.py"
|
||||
|
||||
lookup_spec = importlib.util.spec_from_file_location(
|
||||
"tsl_syntax_reference_structure_lookup", LOOKUP_SCRIPT
|
||||
)
|
||||
lookup = importlib.util.module_from_spec(lookup_spec)
|
||||
lookup_spec.loader.exec_module(lookup)
|
||||
|
||||
TOPIC_REFERENCES = {
|
||||
"01_quickstart.md",
|
||||
"02_core_model.md",
|
||||
"03_values_and_literals.md",
|
||||
"04_variables_and_constants.md",
|
||||
"05_functions_and_calls.md",
|
||||
"06_expressions_and_operators.md",
|
||||
"07_control_flow.md",
|
||||
"08_objects_and_classes.md",
|
||||
"09_units_and_scope.md",
|
||||
"10_runtime_context_and_with.md",
|
||||
"11_pitfalls.md",
|
||||
"12_matrix_and_collections.md",
|
||||
"13_resultset_and_filters.md",
|
||||
"14_ts_sql.md",
|
||||
"15_debug_and_profiler.md",
|
||||
"16_lexical_structure_and_compile_options.md",
|
||||
"17_types_and_conversions.md",
|
||||
"18_external_calls_and_threads.md",
|
||||
"19_namespace_libpath_and_unit_runtime.md",
|
||||
"20_object_runtime_and_introspection.md",
|
||||
"21_builtin_runtime_objects.md",
|
||||
"22_matrix_deep_dive.md",
|
||||
"23_fmarray.md",
|
||||
"24_object_overloads_and_iteration.md",
|
||||
}
|
||||
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
||||
OPERATIONAL_TOPIC_LINK = re.compile(
|
||||
r"(?:跳到|跳转到|转到|回到|回看|回(?=\s*\[)|进入|移到)"
|
||||
r"(?=[^。\n]*\[[^\]]+\]\((?:0[1-9]|1[0-9]|2[0-4])_[^)]+\.md(?:#[^)]+)?\))"
|
||||
)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def frontmatter(path: Path) -> dict[str, str]:
|
||||
text = read_text(path)
|
||||
match = re.match(r"\A---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
||||
if not match:
|
||||
raise AssertionError(f"missing YAML frontmatter: {path}")
|
||||
fields: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines():
|
||||
key, separator, value = line.partition(":")
|
||||
if separator:
|
||||
fields[key.strip()] = value.strip().strip("\"'")
|
||||
return fields
|
||||
|
||||
|
||||
def local_link_targets(text: str) -> list[str]:
|
||||
text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
||||
text = re.sub(r"`[^`\n]*`", "", text)
|
||||
targets = []
|
||||
for match in MARKDOWN_LINK.finditer(text):
|
||||
target = match.group(1).strip().split(maxsplit=1)[0].strip("<>")
|
||||
if target.startswith(("#", "http://", "https://", "mailto:")):
|
||||
continue
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
class TslSyntaxReferenceSkillStructureTest(unittest.TestCase):
|
||||
def test_skill_discovery_covers_natural_tinysoft_terms(self) -> None:
|
||||
description = frontmatter(SKILL_FILE)["description"]
|
||||
|
||||
for term in ("TSL", "TSF", "TS-SQL", "Tinysoft", "天软", "脚本"):
|
||||
self.assertIn(term, description)
|
||||
|
||||
def test_skill_requires_safe_two_stage_lookup(self) -> None:
|
||||
text = read_text(SKILL_FILE)
|
||||
|
||||
self.assertRegex(text, r"--query[^\n]*(候选|Section ID)")
|
||||
self.assertRegex(text, r"--section[^\n]*(正文|事实|章节)")
|
||||
self.assertRegex(text, r"shell|命令注入|安全传参|原样拼接")
|
||||
self.assertRegex(text, r"询问[^\n]*运行|如何运行")
|
||||
self.assertIn("AGENTS.md", text)
|
||||
self.assertRegex(text, r"可直接照写[^\n]*(不等于|不代表)[^\n]*(验证|可用)")
|
||||
|
||||
def test_concept_map_is_plain_text_without_navigation_or_code(self) -> None:
|
||||
rendered = lookup.render_concept_map(lookup.build_concept_map())
|
||||
|
||||
self.assertIsNone(re.search(r"\[[^\]]+\]\([^)]+\)", rendered))
|
||||
self.assertNotIn("```", rendered)
|
||||
self.assertNotIn("`", rendered)
|
||||
|
||||
def test_high_risk_reference_pages_defer_api_scope(self) -> None:
|
||||
for name in (
|
||||
"10_runtime_context_and_with.md",
|
||||
"15_debug_and_profiler.md",
|
||||
"22_matrix_deep_dive.md",
|
||||
):
|
||||
text = read_text(REFERENCES_DIR / name)
|
||||
with self.subTest(page=name):
|
||||
self.assertIn("tsl-api-reference", text)
|
||||
self.assertRegex(text, r"签名|参数")
|
||||
self.assertRegex(text, r"scope|环境|解释器|可用性")
|
||||
|
||||
def test_local_link_targets_includes_local_images(self) -> None:
|
||||
text = "\n"
|
||||
self.assertEqual(local_link_targets(text), ["images/router.png"])
|
||||
|
||||
def test_skill_name_matches_directory(self) -> None:
|
||||
self.assertEqual(frontmatter(SKILL_FILE)["name"], SKILL_DIR.name)
|
||||
|
||||
def test_frontmatter_description_covers_tsl_and_tsf_tasks(self) -> None:
|
||||
description = frontmatter(SKILL_FILE)["description"]
|
||||
self.assertRegex(description, r"(?i)\bTSL\b")
|
||||
self.assertRegex(description, r"(?i)\bTSF\b")
|
||||
self.assertRegex(description, r"写|编写|修改|审查|解释|语法错误")
|
||||
|
||||
def test_reference_inventory_contains_topics_without_router(self) -> None:
|
||||
actual = {path.name for path in REFERENCES_DIR.iterdir() if path.is_file()}
|
||||
self.assertEqual(actual, TOPIC_REFERENCES)
|
||||
self.assertFalse((REFERENCES_DIR / "index.md").exists())
|
||||
|
||||
def test_lookup_script_is_bundled(self) -> None:
|
||||
self.assertTrue((SKILL_DIR / "scripts" / "lookup.py").is_file())
|
||||
|
||||
def test_manual_router_protocol_is_absent(self) -> None:
|
||||
corpus = read_text(SKILL_FILE) + "\n" + "\n".join(
|
||||
read_text(path) for path in REFERENCES_DIR.glob("*.md")
|
||||
)
|
||||
for forbidden in ("路由中心", "选择一个主专题", "候选页继续判断"):
|
||||
self.assertNotIn(forbidden, corpus)
|
||||
|
||||
def test_topic_pages_do_not_instruct_manual_navigation(self) -> None:
|
||||
for path in REFERENCES_DIR.glob("*.md"):
|
||||
for line_number, line in enumerate(read_text(path).splitlines(), start=1):
|
||||
with self.subTest(path=path.name, line=line_number):
|
||||
self.assertIsNone(OPERATIONAL_TOPIC_LINK.search(line), line)
|
||||
|
||||
def test_json_router_is_absent(self) -> None:
|
||||
self.assertFalse((REFERENCES_DIR / "00_agent_index.json").exists())
|
||||
self.assertFalse((ROOT / "docs" / "tsl" / "syntax" / "00_agent_index.json").exists())
|
||||
|
||||
def test_references_do_not_escape_skill(self) -> None:
|
||||
for path in REFERENCES_DIR.glob("*.md"):
|
||||
for target in local_link_targets(read_text(path)):
|
||||
link_path = target.split("#", 1)[0].replace("\\", "/")
|
||||
self.assertNotIn("docs/tsl/", link_path, f"{path}: {target}")
|
||||
self.assertFalse(link_path.startswith("/"), f"{path}: {target}")
|
||||
self.assertNotIn("..", PurePosixPath(link_path).parts, f"{path}: {target}")
|
||||
|
||||
def test_reference_markdown_links_resolve(self) -> None:
|
||||
for path in REFERENCES_DIR.glob("*.md"):
|
||||
for target in local_link_targets(read_text(path)):
|
||||
link_path = target.split("#", 1)[0]
|
||||
self.assertTrue((REFERENCES_DIR / link_path).is_file(), f"{path}: {target}")
|
||||
|
||||
def test_skill_uses_lookup_as_its_only_retrieval_entry(self) -> None:
|
||||
text = read_text(SKILL_FILE)
|
||||
self.assertIn("scripts/lookup.py", text)
|
||||
for mode in ("write", "diagnose", "explain"):
|
||||
self.assertIn(f"--mode {mode}", text)
|
||||
self.assertEqual(local_link_targets(text), [])
|
||||
self.assertNotIn("references/index.md", text)
|
||||
|
||||
def test_skill_does_not_bundle_external_domains(self) -> None:
|
||||
forbidden = {
|
||||
"naming.md",
|
||||
"code_style.md",
|
||||
"toolchain.md",
|
||||
"modules",
|
||||
"tsl-api-reference",
|
||||
"00_agent_index.json",
|
||||
}
|
||||
actual = {path.name for path in SKILL_DIR.rglob("*")}
|
||||
self.assertTrue(forbidden.isdisjoint(actual), forbidden & actual)
|
||||
|
||||
def test_ruleset_file_remains_tsl_router(self) -> None:
|
||||
self.assertTrue(RULESET_FILE.is_file())
|
||||
text = read_text(RULESET_FILE)
|
||||
for route in (
|
||||
"tsl-syntax-reference",
|
||||
"tsl-api-reference",
|
||||
"naming.md",
|
||||
"code_style.md",
|
||||
"toolchain.md",
|
||||
"modules/index.md",
|
||||
"项目",
|
||||
):
|
||||
self.assertIn(route, text)
|
||||
|
||||
def test_ruleset_routes_both_tsl_skills(self) -> None:
|
||||
text = read_text(RULESET_FILE)
|
||||
self.assertRegex(text, r"语法[^\n]*tsl-syntax-reference|tsl-syntax-reference[^\n]*语法")
|
||||
self.assertRegex(text, r"API|函数")
|
||||
self.assertIn("tsl-api-reference", text)
|
||||
|
||||
def test_ruleset_syntax_constraints_moved_to_skill(self) -> None:
|
||||
ruleset = read_text(RULESET_FILE)
|
||||
owner = read_text(SKILL_FILE) + "\n" + "\n".join(
|
||||
read_text(path) for path in REFERENCES_DIR.glob("*.md")
|
||||
)
|
||||
self.assertRegex(ruleset, r"缺失|不可用")
|
||||
self.assertRegex(ruleset, r"停止|阻断")
|
||||
for forbidden in (
|
||||
"```tsl",
|
||||
"代码块身份",
|
||||
".tsl` / `.tsf` 后缀由用户指定时",
|
||||
".tsl` 代码需要本文件内函数或类时",
|
||||
):
|
||||
self.assertNotIn(forbidden, ruleset)
|
||||
self.assertIn("代码块身份", owner)
|
||||
self.assertIn(".tsl", owner)
|
||||
self.assertIn(".tsf", owner)
|
||||
|
||||
def test_static_docs_route_to_fact_owner(self) -> None:
|
||||
routes = {
|
||||
ROOT / "docs" / "tsl" / "naming.md": "../../skills/tsl-syntax-reference/SKILL.md",
|
||||
ROOT / "docs" / "tsl" / "code_style.md": "../../skills/tsl-syntax-reference/SKILL.md",
|
||||
}
|
||||
legacy_deep_link = re.compile(r"(?:docs/tsl/)?syntax/(?:0[1-9]|1[0-9]|2[0-4])_")
|
||||
for path, expected_link in routes.items():
|
||||
text = read_text(path)
|
||||
self.assertIn(expected_link, text, path)
|
||||
self.assertIsNone(legacy_deep_link.search(text), path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user