🐛 fix(tsl-syntax-reference): route derived rules and bound section size

This commit is contained in:
csh
2026-08-20 15:49:41 +08:00
parent ca6830af9f
commit 5a8d95ebcf
7 changed files with 420 additions and 145 deletions
+153 -29
View File
@@ -37,6 +37,9 @@ QUICKSTART_RULE_RE = re.compile(
QUICKSTART_RULE_PREFIX_RE = re.compile(
r"^<!--\s*quickstart-rule\s*:", re.IGNORECASE
)
OWNER_SECTION_RE = re.compile(
r"^\s+Owner Section`([a-z0-9][a-z0-9._-]*)`\s*$", re.IGNORECASE
)
STRUCTURAL_METADATA_RE = re.compile(
r"<!--\s*(?:section-id|quickstart-rule)\s*:.*?-->",
re.DOTALL | re.IGNORECASE,
@@ -83,23 +86,24 @@ WRITE_PRELUDE_SECTIONS = (
)
QUICKSTART_PAGE = "01_quickstart.md"
QUICKSTART_SUMMARY_HEADING = "语言核心事实速查"
QUICKSTART_RULE_OWNER_PAGES = {
"assignment": "06_expressions_and_operators.md",
"file-choice": "02_core_model.md",
"tsl-layout": "02_core_model.md",
"tsf-layout": "02_core_model.md",
"tsf-filename": "02_core_model.md",
"function-default": "05_functions_and_calls.md",
"procedure-explicit": "05_functions_and_calls.md",
"class-shape": "08_objects_and_classes.md",
"object-creation": "08_objects_and_classes.md",
"unit-shape": "09_units_and_scope.md",
"unit-default": "09_units_and_scope.md",
"named-arguments": "05_functions_and_calls.md",
"named-argument-order": "05_functions_and_calls.md",
"string-literal-default": "03_values_and_literals.md",
"string-prefix-by-type": "03_values_and_literals.md",
"index-origins": "03_values_and_literals.md",
MAX_LEAF_SECTION_LINES = 180
QUICKSTART_RULE_OWNERS = {
"assignment": ("06_expressions_and_operators.md", "syntax-06-004"),
"file-choice": ("02_core_model.md", "syntax-02-002"),
"tsl-layout": ("02_core_model.md", "syntax-02-002"),
"tsf-layout": ("02_core_model.md", "syntax-02-002"),
"tsf-filename": ("02_core_model.md", "syntax-02-002"),
"function-default": ("05_functions_and_calls.md", "syntax-05-002"),
"procedure-explicit": ("05_functions_and_calls.md", "syntax-05-002"),
"class-shape": ("08_objects_and_classes.md", "syntax-08-002"),
"object-creation": ("08_objects_and_classes.md", "syntax-08-002"),
"unit-shape": ("09_units_and_scope.md", "syntax-09-002"),
"unit-default": ("09_units_and_scope.md", "syntax-09-002"),
"named-arguments": ("05_functions_and_calls.md", "syntax-05-002"),
"named-argument-order": ("05_functions_and_calls.md", "syntax-05-002"),
"string-literal-default": ("03_values_and_literals.md", "syntax-03-004"),
"string-prefix-by-type": ("03_values_and_literals.md", "syntax-03-004"),
"index-origins": ("03_values_and_literals.md", "syntax-03-002"),
}
HEADING_TOKEN_SCORE = 12
@@ -232,6 +236,9 @@ class QuickstartRule:
bullet_line: int
key: str
text: str
section_id: str | None
owner_section: str | None
owner_line: int | None
class ReferenceInstallationError(RuntimeError):
@@ -724,6 +731,7 @@ def _quickstart_rule_records(
records: list[QuickstartRule] = []
problems: list[ValidationProblem] = []
in_fence = False
current_section_id: str | None = None
for index, line in enumerate(lines):
if FENCE_RE.match(line):
in_fence = not in_fence
@@ -731,6 +739,14 @@ def _quickstart_rule_records(
if in_fence:
continue
stripped = line.strip()
heading = HEADING_RE.match(line)
if heading is not None and len(heading.group(1)) in (2, 3, 4):
metadata_line = _next_nonblank_line(lines, index + 1)
metadata = lines[metadata_line].strip() if metadata_line is not None else ""
section_id_match = SECTION_ID_RE.fullmatch(metadata)
current_section_id = (
section_id_match.group(1) if section_id_match is not None else None
)
if not QUICKSTART_RULE_PREFIX_RE.match(stripped):
continue
marker = QUICKSTART_RULE_RE.fullmatch(stripped)
@@ -750,6 +766,8 @@ def _quickstart_rule_records(
)
continue
text_parts = [lines[bullet_line].strip()]
owner_section: str | None = None
owner_line: int | None = None
continuation = bullet_line + 1
while continuation < len(lines):
line = lines[continuation]
@@ -757,7 +775,21 @@ def _quickstart_rule_records(
break
if not line.startswith((" ", "\t")):
break
text_parts.append(line.strip())
owner_match = OWNER_SECTION_RE.fullmatch(line)
if owner_match is not None:
if owner_section is not None:
problems.append(
ValidationProblem(
page,
continuation + 1,
f"quickstart-rule {marker.group(1)!r} 重复 Owner Section",
)
)
else:
owner_section = owner_match.group(1)
owner_line = continuation + 1
else:
text_parts.append(line.strip())
continuation += 1
records.append(
QuickstartRule(
@@ -766,6 +798,9 @@ def _quickstart_rule_records(
bullet_line=bullet_line + 1,
key=marker.group(1),
text=" ".join(text_parts),
section_id=current_section_id,
owner_section=owner_section,
owner_line=owner_line,
)
)
return records, problems
@@ -829,7 +864,13 @@ def _quickstart_rule_problems(
by_key: dict[str, list[QuickstartRule]] = {}
for record in records:
by_key.setdefault(record.key, []).append(record)
expected_keys = set(QUICKSTART_RULE_OWNER_PAGES) if require_complete else set()
expected_keys = set(QUICKSTART_RULE_OWNERS) if require_complete else set()
declared_section_ids = {
match.group(1)
for lines in pages.values()
for line in lines
if (match := SECTION_ID_RE.fullmatch(line.strip())) is not None
}
for key in sorted(set(by_key) | expected_keys):
key_records = by_key.get(key, [])
summaries = [record for record in key_records if record.page == quickstart]
@@ -843,20 +884,57 @@ def _quickstart_rule_problems(
)
)
continue
expected_page = QUICKSTART_RULE_OWNER_PAGES.get(key)
if expected_page is not None and canonicals[0].page.name != expected_page:
summary = summaries[0]
canonical = canonicals[0]
expected_owner = QUICKSTART_RULE_OWNERS.get(key)
if expected_owner is not None and canonical.page.name != expected_owner[0]:
problems.append(
ValidationProblem(
canonicals[0].page,
canonicals[0].line,
f"quickstart-rule {key!r} 的专题 owner 应为 {expected_page}",
canonical.page,
canonical.line,
f"quickstart-rule {key!r} 的专题 owner 应为 {expected_owner[0]}",
)
)
if summaries[0].text != canonicals[0].text:
if expected_owner is not None and canonical.section_id != expected_owner[1]:
problems.append(
ValidationProblem(
canonical.page,
canonical.line,
f"quickstart-rule {key!r} 的专题 owner Section 应为 "
f"{expected_owner[1]}",
)
)
if summary.owner_section is None:
problems.append(
ValidationProblem(
quickstart,
summaries[0].bullet_line,
summary.bullet_line,
f"quickstart-rule {key!r} 缺少 Owner Section",
)
)
elif summary.owner_section not in declared_section_ids:
problems.append(
ValidationProblem(
quickstart,
summary.owner_line or summary.bullet_line,
f"quickstart-rule {key!r} 的 Owner Section 不存在:"
f"{summary.owner_section}",
)
)
elif canonical.section_id is not None and summary.owner_section != canonical.section_id:
problems.append(
ValidationProblem(
quickstart,
summary.owner_line or summary.bullet_line,
f"quickstart-rule {key!r} 的 Owner Section 与专题事实所在 Section "
f"不一致:{summary.owner_section} != {canonical.section_id}",
)
)
if summary.text != canonical.text:
problems.append(
ValidationProblem(
quickstart,
summary.bullet_line,
f"派生摘要规则与专题事实不一致:{key}",
)
)
@@ -867,6 +945,35 @@ def _quickstart_rule_problems(
return problems
def _leaf_section_granularity_problems(
sections: list[Section],
) -> list[ValidationProblem]:
"""Reject exact-retrieval units whose body has grown beyond the safe budget."""
problems: list[ValidationProblem] = []
for section in sections:
has_child = any(
other.page == section.page
and len(other.heading_path) > len(section.heading_path)
and other.heading_path[: len(section.heading_path)]
== section.heading_path
for other in sections
)
if has_child:
continue
line_count = len(section.body.splitlines())
if line_count <= MAX_LEAF_SECTION_LINES:
continue
problems.append(
ValidationProblem(
section.page,
1,
f"叶子 Section 正文超过 {MAX_LEAF_SECTION_LINES} 行:"
f"{section.id}{line_count} 行)",
)
)
return problems
def validate_references(
references_dir: Path = DEFAULT_REFERENCES_DIR,
) -> list[ValidationProblem]:
@@ -971,6 +1078,7 @@ def validate_references(
return problems
sections = load_sections(references_dir)
problems.extend(_tag_problems(sections))
problems.extend(_leaf_section_granularity_problems(sections))
ids: dict[str, Section] = {}
for section in sections:
if section.id in ids:
@@ -1534,9 +1642,24 @@ HELP_EPILOG = """\
选定的 Section ID 可以合并成一次取回
lookup.py --section "syntax-05-004" "syntax-02-002"
查询词无从下手时先 --map 把需求映射到 TSL 概念;改动参考页或 data/ 词表后用 --check 校验。
一次 --query 只覆盖一个语法要素;从用户原话提取错误原文、标识符或要素名,
保留原写法但不传完整用户句。多个要素分别查询,再批量 --section。
write 模式额外列出 Required: yes 前置章节,不占 --limit,同一任务只需取回一次。
syntax-01-002 是派生速查;每条规则后的 Owner Section 才是该事实的专题来源。
查询词无从下手时先 --map 把需求映射到 TSL 概念;地图不含可照写事实。
弱命中(候选标 Weak: yes)没有意图/标题/标识符/标签命中,只靠正文低分撞词;
全部候选皆弱时视同无匹配并返回 rc=2,应改进查询词重试而不是从弱候选里挑
混合候选只从强命中里选择;全部候选皆弱时视同无匹配并返回 rc=2。
没有 Weak 行也不保证相关,仍按 Summary 选定后取回正文核对。
--section 中任一 ID 不存在时整批不返回正文,并在 stderr 给出最近的 Section ID。
改动参考页或 data/ 词表后用 --check;它不校验事实语义、排序或示例运行结果。
退出码:
0 动作成功
1 参考目录/结构/安装错误,或 --check 发现问题
2 参数错误、无匹配、全弱命中或 Section ID 不存在
"""
@@ -1581,7 +1704,8 @@ def _parser() -> argparse.ArgumentParser:
"--check",
action="store_true",
help="只校验参考页结构、显式 Section ID、代码块身份、本地链接、quickstart "
"派生规则词表页覆盖;检索排序由测试套件校验。发现问题时退出码为 1",
"派生规则词表页覆盖和叶子 Section 180 行粒度上限;检索排序由测试套件校验。"
"发现问题时退出码为 1",
)
parser.add_argument(
"--mode",