♻️ refactor(test): consolidate repository checks

This commit is contained in:
csh
2026-07-20 09:08:36 +08:00
parent 10ce779b1b
commit ff07c79818
24 changed files with 426 additions and 4596 deletions
+2 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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/)
-163
View File
@@ -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()
-337
View File
@@ -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()
-307
View File
@@ -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()
-143
View File
@@ -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()
+191
View File
@@ -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())
-232
View File
@@ -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
-79
View File
@@ -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 冲突、查询注入和隔离污染。
-334
View File
@@ -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
-355
View File
@@ -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
-341
View File
@@ -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
-444
View File
@@ -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()
-211
View File
@@ -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()
-49
View File
@@ -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()
+221
View File
@@ -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()
-437
View File
@@ -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()
-67
View File
@@ -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()
-79
View File
@@ -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()
-3
View File
@@ -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"
)
-563
View File
@@ -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()
-241
View File
@@ -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 = "![local diagram](images/router.png)\n![remote](https://example.com/router.png)"
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()