624 lines
22 KiB
Python
624 lines
22 KiB
Python
import importlib.util
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import textwrap
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
SKILL_ROOT = ROOT / "skills" / "tsl-syntax-reference"
|
||
LOOKUP_PATH = SKILL_ROOT / "scripts" / "lookup.py"
|
||
SKILL_PATH = SKILL_ROOT / "SKILL.md"
|
||
CI_PATH = ROOT / ".gitea" / "workflows" / "checks.yml"
|
||
PREPARE_PATH = ROOT / ".gitea" / "workflows" / "prepare.yml"
|
||
NODE_VERSION_PATH = ROOT / ".node-version"
|
||
|
||
|
||
def load_lookup_module():
|
||
spec = importlib.util.spec_from_file_location("tsl_syntax_lookup", LOOKUP_PATH)
|
||
if spec is None or spec.loader is None:
|
||
raise RuntimeError(f"cannot load {LOOKUP_PATH}")
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[spec.name] = module
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
lookup = load_lookup_module()
|
||
|
||
|
||
def write_reference(directory: Path, name: str, content: str) -> Path:
|
||
page = directory / name
|
||
page.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8", newline="\n")
|
||
return page
|
||
|
||
|
||
def run_lookup(*args: str) -> subprocess.CompletedProcess[str]:
|
||
return subprocess.run(
|
||
[sys.executable, str(LOOKUP_PATH), *args],
|
||
cwd=ROOT,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
|
||
|
||
class TslSyntaxReferenceTests(unittest.TestCase):
|
||
def test_bundled_references_pass_structural_check(self):
|
||
self.assertEqual([], lookup.validate_references())
|
||
|
||
def test_bundled_sections_use_unique_explicit_ids(self):
|
||
sections = lookup.load_sections()
|
||
ids = [section.id for section in sections]
|
||
|
||
self.assertEqual(len(ids), len(set(ids)))
|
||
self.assertTrue(ids)
|
||
for section_id in ids:
|
||
with self.subTest(section_id=section_id):
|
||
self.assertRegex(section_id, r"^syntax-\d{2}-\d{3}$")
|
||
|
||
def test_explicit_section_id_survives_heading_rename(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
page = write_reference(
|
||
references,
|
||
"99_fixture.md",
|
||
"""
|
||
# Fixture
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-99-001 -->
|
||
|
||
测试职责。
|
||
|
||
## 原标题
|
||
|
||
<!-- section-id: syntax-99-002 -->
|
||
|
||
事实正文。
|
||
""",
|
||
)
|
||
before = lookup.load_sections(references)[1].id
|
||
page.write_text(
|
||
page.read_text(encoding="utf-8").replace("## 原标题", "## 新标题"),
|
||
encoding="utf-8",
|
||
newline="\n",
|
||
)
|
||
after = lookup.load_sections(references)[1].id
|
||
|
||
self.assertEqual("syntax-99-002", before)
|
||
self.assertEqual(before, after)
|
||
|
||
def test_missing_explicit_section_id_fails_structure_check(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
write_reference(
|
||
references,
|
||
"99_fixture.md",
|
||
"""
|
||
# Fixture
|
||
|
||
## 本篇职责
|
||
|
||
测试职责。
|
||
""",
|
||
)
|
||
|
||
messages = [item.message for item in lookup.validate_references(references)]
|
||
|
||
self.assertTrue(any("缺少显式 section ID" in message for message in messages))
|
||
|
||
def test_duplicate_explicit_section_id_fails_check_and_loading(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
write_reference(
|
||
references,
|
||
"99_fixture.md",
|
||
"""
|
||
# Fixture
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-99-001 -->
|
||
|
||
测试职责。
|
||
|
||
## 重复 ID
|
||
|
||
<!-- section-id: syntax-99-001 -->
|
||
|
||
事实正文。
|
||
""",
|
||
)
|
||
|
||
messages = [item.message for item in lookup.validate_references(references)]
|
||
with self.assertRaisesRegex(lookup.ReferenceStructureError, "重复 section ID"):
|
||
lookup.load_sections(references)
|
||
|
||
self.assertTrue(any("重复 section ID" in message for message in messages))
|
||
|
||
def test_orphan_identity_line_fails_structure_check(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
write_reference(
|
||
references,
|
||
"99_fixture.md",
|
||
"""
|
||
# Fixture
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-99-001 -->
|
||
|
||
测试职责。
|
||
|
||
## 示例
|
||
|
||
<!-- section-id: syntax-99-002 -->
|
||
|
||
代码块身份:可直接照写示例
|
||
""",
|
||
)
|
||
|
||
messages = [item.message for item in lookup.validate_references(references)]
|
||
|
||
self.assertTrue(any("孤立的代码块身份" in message for message in messages))
|
||
|
||
def test_h5_and_h6_headings_fail_structure_check(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
write_reference(
|
||
references,
|
||
"99_fixture.md",
|
||
"""
|
||
# Fixture
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-99-001 -->
|
||
|
||
测试职责。
|
||
|
||
##### 不会被索引的标题
|
||
|
||
隐藏事实。
|
||
""",
|
||
)
|
||
|
||
messages = [item.message for item in lookup.validate_references(references)]
|
||
|
||
self.assertTrue(any("H5/H6" in message for message in messages))
|
||
|
||
def test_quickstart_rule_drift_fails_structure_check(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
write_reference(
|
||
references,
|
||
"01_quickstart.md",
|
||
"""
|
||
# Quickstart
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-01-001 -->
|
||
|
||
派生摘要。
|
||
|
||
## 语言核心事实速查
|
||
|
||
<!-- section-id: syntax-01-002 -->
|
||
|
||
<!-- quickstart-rule: assignment -->
|
||
- 普通赋值使用 `=`。
|
||
""",
|
||
)
|
||
write_reference(
|
||
references,
|
||
"02_topic.md",
|
||
"""
|
||
# Topic
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-02-001 -->
|
||
|
||
完整事实源。
|
||
|
||
## 核心规则
|
||
|
||
<!-- section-id: syntax-02-002 -->
|
||
|
||
<!-- quickstart-rule: assignment -->
|
||
- 普通赋值使用 `:=`。
|
||
""",
|
||
)
|
||
|
||
messages = [item.message for item in lookup.validate_references(references)]
|
||
|
||
self.assertTrue(
|
||
any("派生摘要规则与专题事实不一致" in message for message in messages)
|
||
)
|
||
|
||
def test_complete_quickstart_contract_detects_rule_deleted_from_both_pages(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
quickstart = write_reference(
|
||
references,
|
||
"01_quickstart.md",
|
||
"""
|
||
# Quickstart
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-01-001 -->
|
||
|
||
派生摘要。
|
||
|
||
## 语言核心事实速查
|
||
|
||
<!-- section-id: syntax-01-002 -->
|
||
|
||
没有任何规则。
|
||
""",
|
||
)
|
||
pages = {quickstart: quickstart.read_text(encoding="utf-8").splitlines()}
|
||
|
||
messages = [
|
||
item.message
|
||
for item in lookup._quickstart_rule_problems(
|
||
pages,
|
||
require_complete=True,
|
||
)
|
||
]
|
||
|
||
self.assertTrue(any("assignment" in message for message in messages))
|
||
|
||
def test_quickstart_owner_drift_fails_structure_check(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
write_reference(
|
||
references,
|
||
"01_quickstart.md",
|
||
"""
|
||
# Quickstart
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-01-001 -->
|
||
|
||
派生摘要。
|
||
|
||
## 语言核心事实速查
|
||
|
||
<!-- section-id: syntax-01-002 -->
|
||
|
||
<!-- quickstart-rule: assignment -->
|
||
- 普通赋值使用 `:=`。
|
||
Owner Section:`syntax-02-999`
|
||
""",
|
||
)
|
||
write_reference(
|
||
references,
|
||
"02_topic.md",
|
||
"""
|
||
# Topic
|
||
|
||
## 本篇职责
|
||
|
||
<!-- section-id: syntax-02-001 -->
|
||
|
||
完整事实源。
|
||
|
||
## 核心规则
|
||
|
||
<!-- section-id: syntax-02-002 -->
|
||
|
||
<!-- quickstart-rule: assignment -->
|
||
- 普通赋值使用 `:=`。
|
||
""",
|
||
)
|
||
|
||
messages = [item.message for item in lookup.validate_references(references)]
|
||
|
||
self.assertTrue(any("Owner Section 不存在" in message for message in messages))
|
||
|
||
def test_quickstart_routes_every_derived_rule_to_its_owner_section(self):
|
||
quickstart = run_lookup("--section", "syntax-01-002")
|
||
|
||
self.assertEqual(0, quickstart.returncode, quickstart.stderr)
|
||
owner_ids = set(
|
||
re.findall(r"Owner Section:`(syntax-\d{2}-\d{3})`", quickstart.stdout)
|
||
)
|
||
self.assertEqual(
|
||
{
|
||
"syntax-02-002",
|
||
"syntax-03-002",
|
||
"syntax-03-004",
|
||
"syntax-05-002",
|
||
"syntax-06-004",
|
||
"syntax-08-002",
|
||
"syntax-09-002",
|
||
},
|
||
owner_ids,
|
||
)
|
||
for owner_id in owner_ids:
|
||
with self.subTest(owner_id=owner_id):
|
||
owner = run_lookup("--section", owner_id)
|
||
self.assertEqual(0, owner.returncode, owner.stderr)
|
||
|
||
def test_oversized_leaf_section_fails_structure_check(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
references = Path(temp_dir)
|
||
long_body = "\n".join(f"事实行 {index}" for index in range(181))
|
||
write_reference(
|
||
references,
|
||
"99_fixture.md",
|
||
"# Fixture\n\n"
|
||
"## 本篇职责\n\n"
|
||
"<!-- section-id: syntax-99-001 -->\n\n"
|
||
"测试职责。\n\n"
|
||
"## 过长事实段\n\n"
|
||
"<!-- section-id: syntax-99-002 -->\n\n"
|
||
f"{long_body}\n",
|
||
)
|
||
|
||
result = run_lookup(
|
||
"--check",
|
||
"--references-dir",
|
||
str(references),
|
||
)
|
||
|
||
self.assertEqual(1, result.returncode)
|
||
self.assertIn("叶子 Section 正文超过 180 行", result.stderr)
|
||
|
||
def test_missing_references_are_installation_errors_for_every_action(self):
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
missing = Path(temp_dir) / "missing"
|
||
actions = (
|
||
("--map",),
|
||
("--query", "数组下标", "--mode", "explain"),
|
||
("--section", "syntax-03-002"),
|
||
("--check",),
|
||
)
|
||
for action in actions:
|
||
with self.subTest(action=action):
|
||
result = run_lookup(
|
||
*action,
|
||
"--references-dir",
|
||
str(missing),
|
||
)
|
||
self.assertEqual(1, result.returncode)
|
||
self.assertEqual("", result.stdout)
|
||
self.assertIn("参考目录不可用", result.stderr)
|
||
|
||
def test_documented_array_index_query_ranks_basic_array_section_first(self):
|
||
for query in ("数组下标", "请帮我解释数组的下标"):
|
||
with self.subTest(query=query):
|
||
result = lookup.query_sections(query, "explain", limit=1)
|
||
self.assertEqual("03_values_and_literals.md", result.matches[0].section.page.name)
|
||
self.assertEqual("核心规则", result.matches[0].section.heading_path[-1])
|
||
|
||
def test_documented_write_and_diagnose_queries_rank_expected_sections(self):
|
||
write_result = lookup.query_sections("命名参数", "write", limit=1)
|
||
diagnose_result = lookup.query_sections(
|
||
"invalid statement 声明区",
|
||
"diagnose",
|
||
limit=1,
|
||
)
|
||
|
||
self.assertEqual(
|
||
["syntax-02-002", "syntax-01-002"],
|
||
[section.id for section in write_result.prelude],
|
||
)
|
||
self.assertEqual("syntax-05-008", write_result.matches[0].section.id)
|
||
self.assertEqual("syntax-02-006", diagnose_result.matches[0].section.id)
|
||
|
||
def test_object_and_class_queries_return_focused_sections(self):
|
||
cases = {
|
||
"成员访问可见性": "syntax-08-013",
|
||
"类外实现": "syntax-08-014",
|
||
"固定索引 property": "syntax-08-015",
|
||
"参数化 property": "syntax-08-016",
|
||
"方法隐藏 hide": "syntax-08-017",
|
||
"调用父类 inherited": "syntax-08-018",
|
||
"析构 destroy": "syntax-08-019",
|
||
}
|
||
for query, expected_id in cases.items():
|
||
with self.subTest(query=query):
|
||
result = lookup.query_sections(query, "explain", limit=1)
|
||
self.assertTrue(result.matches, query)
|
||
self.assertEqual(expected_id, result.matches[0].section.id)
|
||
section = run_lookup("--section", expected_id)
|
||
self.assertEqual(0, section.returncode, section.stderr)
|
||
self.assertLessEqual(len(section.stdout.splitlines()), 186)
|
||
|
||
def test_external_call_queries_retrieve_platform_and_abi_boundaries(self):
|
||
cases = {
|
||
"动态库常驻": "syntax-17-012",
|
||
"外部出参": "syntax-17-013",
|
||
"Linux 动态加载": "syntax-17-014",
|
||
"函数指针释放": "syntax-17-008",
|
||
}
|
||
for query, expected_id in cases.items():
|
||
with self.subTest(query=query):
|
||
result = lookup.query_sections(query, "write", limit=1)
|
||
self.assertEqual(expected_id, result.matches[0].section.id)
|
||
|
||
def test_index_origin_variants_rank_value_rules_first(self):
|
||
for query in ("下标从几开始", "下标是从几开始", "请问下标是从几开始"):
|
||
with self.subTest(query=query):
|
||
result = lookup.query_sections(query, "explain", limit=1)
|
||
self.assertEqual("03_values_and_literals.md", result.matches[0].section.page.name)
|
||
self.assertEqual("核心规则", result.matches[0].section.heading_path[-1])
|
||
|
||
def test_string_literal_queries_rank_generation_policy_first(self):
|
||
for query in ("普通中文字符串", "原始字符串 %%", "字符串 U L 前缀"):
|
||
with self.subTest(query=query):
|
||
result = lookup.query_sections(query, "explain", limit=1)
|
||
self.assertEqual("syntax-03-004", result.matches[0].section.id)
|
||
|
||
def test_string_literal_generation_policy_is_in_quickstart_and_detail(self):
|
||
quickstart = run_lookup("--section", "syntax-01-002")
|
||
detail = run_lookup("--section", "syntax-03-004")
|
||
|
||
self.assertEqual(0, quickstart.returncode, msg=quickstart.stderr)
|
||
self.assertEqual(0, detail.returncode, msg=detail.stderr)
|
||
for output in (quickstart.stdout, detail.stdout):
|
||
self.assertIn("普通单行文本默认使用", output)
|
||
self.assertIn("不得因为内容是中文、非 ASCII 或较长", output)
|
||
self.assertIn("不能因为内容是中文就自动添加", output)
|
||
self.assertIn('普通中文内容优先直接写成 `"中文内容"`', output)
|
||
|
||
def test_every_curated_page_alias_still_ranks_its_page_first(self):
|
||
for page_name, aliases in lookup.PAGE_INTENT_ALIASES.items():
|
||
for alias in aliases:
|
||
with self.subTest(page=page_name, alias=alias):
|
||
result = lookup.query_sections(alias, "explain", limit=1)
|
||
self.assertTrue(result.matches, alias)
|
||
self.assertEqual(page_name, result.matches[0].section.page.name)
|
||
|
||
def test_weak_only_query_remains_a_no_match(self):
|
||
result = run_lookup("--query", "只能确认", "--mode", "explain")
|
||
|
||
self.assertEqual(2, result.returncode)
|
||
self.assertIn("only weak candidates", result.stderr)
|
||
|
||
def test_strong_candidates_are_ranked_before_weak_diagnose_boosts(self):
|
||
result = lookup.query_sections("变量赋值", "diagnose", limit=1)
|
||
|
||
self.assertTrue(result.matches)
|
||
self.assertFalse(result.matches[0].weak)
|
||
self.assertNotEqual("syntax-04-008", result.matches[0].section.id)
|
||
|
||
def test_page_intent_aliases_score_once_per_page(self):
|
||
result = lookup.query_sections("高性能矩阵 fmarray", "explain", limit=3)
|
||
|
||
self.assertTrue(result.matches)
|
||
self.assertEqual("syntax-22-004", result.matches[0].section.id)
|
||
for match in result.matches:
|
||
self.assertNotIn("intent=160", match.reasons)
|
||
self.assertIn("intent=80", result.matches[0].reasons)
|
||
|
||
def test_program_is_a_searchable_language_keyword(self):
|
||
result = lookup.query_sections("PROGRAM", "explain", limit=1)
|
||
|
||
self.assertTrue(result.matches)
|
||
self.assertEqual("syntax-02-009", result.matches[0].section.id)
|
||
|
||
def test_maintenance_html_comments_do_not_leak_into_lookup_output(self):
|
||
result = run_lookup("--query", "PROGRAM", "--mode", "explain")
|
||
|
||
self.assertEqual(0, result.returncode)
|
||
self.assertNotIn("prettier-ignore", result.stdout)
|
||
|
||
def test_new_language_gaps_have_stable_retrieval_entries(self):
|
||
cases = {
|
||
"全局变量": "syntax-04-009",
|
||
"运行时常量": "syntax-04-010",
|
||
"静态计算": "syntax-06-016",
|
||
"只计算一次": "syntax-06-016",
|
||
"指定系统函数": "syntax-05-013",
|
||
"运算符优先级": "syntax-06-015",
|
||
"内存上限": "syntax-14-011",
|
||
"嵌套注释": "syntax-15-005",
|
||
"JOIN 第二张表当前行": "syntax-13-010",
|
||
"按行广播": "syntax-11-011",
|
||
}
|
||
for query, expected_id in cases.items():
|
||
with self.subTest(query=query):
|
||
result = lookup.query_sections(query, "explain", limit=1)
|
||
self.assertTrue(result.matches, query)
|
||
self.assertEqual(expected_id, result.matches[0].section.id)
|
||
|
||
def test_batch_section_retrieval_is_atomic(self):
|
||
ids = [section.id for section in lookup.load_sections()[:2]]
|
||
success = run_lookup("--section", *ids)
|
||
failure = run_lookup("--section", ids[0], "syntax-99-999")
|
||
|
||
self.assertEqual(0, success.returncode)
|
||
self.assertTrue(all(section_id in success.stdout for section_id in ids))
|
||
self.assertEqual(2, failure.returncode)
|
||
self.assertEqual("", failure.stdout)
|
||
self.assertIn("section not found: syntax-99-999", failure.stderr)
|
||
|
||
def test_structural_metadata_is_not_exposed_in_lookup_output(self):
|
||
sections = {section.id: section for section in lookup.load_sections()}
|
||
candidates = lookup.render_candidates(
|
||
lookup.query_sections("命名参数", "write", limit=1)
|
||
)
|
||
quickstart = lookup.render_section(sections["syntax-01-002"])
|
||
|
||
for output in (candidates, quickstart):
|
||
with self.subTest(output=output[:40]):
|
||
self.assertNotIn("<!-- section-id:", output)
|
||
self.assertNotIn("<!-- quickstart-rule:", output)
|
||
|
||
def test_skill_contract_uses_extracted_queries_and_deliverable_api_checks(self):
|
||
skill = SKILL_PATH.read_text(encoding="utf-8")
|
||
help_result = run_lookup("--help")
|
||
self.assertEqual(0, help_result.returncode, help_result.stderr)
|
||
help_text = help_result.stdout
|
||
|
||
self.assertNotIn("## 构造查询词", skill)
|
||
self.assertNotIn("用户怎么说就怎么传", skill)
|
||
self.assertIn("从用户原话提取", help_text)
|
||
self.assertIn("保留原写法但不传完整用户句", help_text)
|
||
self.assertIn("不传完整用户句", help_text)
|
||
self.assertIn("面向用户交付的 TSL/TSF 代码", skill)
|
||
self.assertIn("每个 builtin/API", skill)
|
||
self.assertIn("纯语法说明", skill)
|
||
self.assertIn("不得声称该占位调用的 API 行为或输出", skill)
|
||
|
||
def test_help_owns_cli_details_and_skill_defers_to_it(self):
|
||
skill = SKILL_PATH.read_text(encoding="utf-8")
|
||
result = run_lookup("--help")
|
||
|
||
self.assertEqual(0, result.returncode, result.stderr)
|
||
for text in (
|
||
"一次 --query 只覆盖一个语法要素",
|
||
"Required: yes",
|
||
"Owner Section",
|
||
"混合候选",
|
||
"180 行粒度上限",
|
||
"退出码",
|
||
):
|
||
with self.subTest(text=text):
|
||
self.assertIn(text, result.stdout)
|
||
self.assertIn("构造任一命令前先运行", skill)
|
||
self.assertIn("scripts/lookup.py --help", skill)
|
||
self.assertIn("不用于查询 API 签名", skill)
|
||
self.assertIn("不用于选择解释器或运行方式", skill)
|
||
|
||
def test_ci_runs_syntax_structure_and_format_gates(self):
|
||
workflow = CI_PATH.read_text(encoding="utf-8")
|
||
prepare = PREPARE_PATH.read_text(encoding="utf-8")
|
||
|
||
self.assertIn(
|
||
"npm ci --ignore-scripts --no-audit --no-fund --no-bin-links",
|
||
workflow,
|
||
)
|
||
self.assertIn("skills/tsl-syntax-reference/scripts/lookup.py --check", workflow)
|
||
self.assertIn(
|
||
"node_modules/prettier/bin/prettier.cjs --check skills/tsl-syntax-reference",
|
||
workflow,
|
||
)
|
||
self.assertIn("[unzip]=\"unzip\"", prepare)
|
||
self.assertIn('FNM_BIN: "/data/bin/fnm"', prepare)
|
||
self.assertIn('FNM_DIR: "/data/fnm"', prepare)
|
||
self.assertIn('"$FNM_BIN" install "$node_version"', prepare)
|
||
self.assertNotIn('[node]="nodejs"', prepare)
|
||
self.assertNotIn('[npm]="npm"', prepare)
|
||
self.assertIn('"$FNM_BIN" use --install-if-missing "$node_version"', workflow)
|
||
self.assertIn("node --version", workflow)
|
||
self.assertRegex(
|
||
NODE_VERSION_PATH.read_text(encoding="utf-8").strip(),
|
||
r"^\d+\.\d+\.\d+$",
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|