lookup.py: exempt code-anchored ASCII identifiers from the mixed zh/en gate so exact hits are no longer dropped; dedup parent/child sections in results; validate write-prelude anchors in --check. references: correct interpreter-verified facts (case-as-expression, control-flow semicolons, __line__/__stack_frame, tslObjects order, destroy timing, ErrDefine, truncated outputs), fix headings, scope qualifiers and reversed quotes; strip dead preamble metadata from all 24 pages. packaging: exclude __pycache__/*.pyc from playbook and bundle copies; update build test file-count assertion to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
366 lines
13 KiB
Python
366 lines
13 KiB
Python
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("generated_paths=(AGENTS.md docs skills)", text)
|
|
self.assertIn('git add -A "${generated_paths[@]}"', 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", ".")
|
|
(repo / "README.md").write_text(
|
|
"manual branch note\n", encoding="utf-8", newline="\n"
|
|
)
|
|
git(repo, "add", "README.md")
|
|
git(repo, "commit", "-m", "manual target branch note")
|
|
git(repo, "push", "-u", "origin", "tsl-playbook")
|
|
|
|
git(repo, "checkout", "main")
|
|
run_sync(repo)
|
|
|
|
readme = run(
|
|
["git", "show", "HEAD:README.md"],
|
|
cwd=repo,
|
|
check=False,
|
|
)
|
|
self.assertEqual(readme.returncode, 0, msg=readme.stderr)
|
|
self.assertEqual(readme.stdout, "manual branch note\n")
|
|
|
|
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}")
|
|
|
|
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 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(
|
|
{
|
|
"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()
|