✨ feat(tsl-syntax-reference): harden retrieval contracts
Add stable section IDs, structural and routing regressions, quickstart consistency checks, and CI enforcement. BREAKING CHANGE: replace heading-derived Section IDs with explicit syntax-NN-NNN identifiers.
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
import importlib.util
|
||||
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"
|
||||
|
||||
|
||||
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_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("11_matrix_and_collections.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_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_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_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_text = lookup._parser().format_help()
|
||||
|
||||
self.assertIn("保留这些词在用户原话中", skill)
|
||||
self.assertIn("不传完整句", skill)
|
||||
self.assertNotIn("用户怎么说就怎么传", skill)
|
||||
self.assertIn("不传完整用户句", help_text)
|
||||
self.assertIn("面向用户交付的 TSL/TSF 代码", skill)
|
||||
self.assertIn("每个 builtin/API", skill)
|
||||
self.assertIn("纯语法说明", skill)
|
||||
self.assertIn("不得声称该占位调用的 API 行为或输出", 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("[node]=\"nodejs\"", prepare)
|
||||
self.assertIn("[npm]=\"npm\"", prepare)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user