📦 deps(tsl): sync tsl-playbook from 13be5ea0

Source-Commit: 13be5ea0aa
This commit is contained in:
ci[bot]
2026-07-29 07:58:05 +00:00
parent 9d2b696f58
commit 80357f4bc5
280 changed files with 48658 additions and 13929 deletions
+332 -36
View File
@@ -1,17 +1,13 @@
#!/usr/bin/env python3
"""Lint TSL codegen function-doc markdown against the house standard.
"""Lint TSL codegen API Markdown against the house standard.
The standard lives in tools/tsl-codegen/STANDARD.md.
Each `## `sig`` / `### `sig`` heading starts one function entry. Rules split
into hard errors (CI-blocking) and soft warnings (style
convergence over the ~12k existing entries).
Each typed H2 starts one top-level declaration; typed H3/H4 headings describe
class or unit members. Rules split into hard errors (CI-blocking) and soft
warnings (style convergence over the ~12k existing entries).
Hard errors:
- missing/empty description (first prose line after the signature)
- missing `返回:类型`
- signature has parameters but the entry has no parameter table
- signature has no parameters but a parameter table is present
- parameter table header is not the fixed 参数 / 类型 / 说明 三列
Hard errors include incomplete descriptions/metadata/parameter tables and
invalid class/unit API headings, visibility, or child-heading levels.
Soft warnings:
- optional-parameter wording not starting with `可选。`
@@ -29,12 +25,26 @@ import re
import sys
from pathlib import Path
# Entry heading: `## `sig`` or `### `sig``. Matches the index generator's rule
# so the linter and the tsv agree on what a function entry is.
ENTRY_RE = re.compile(r"^(#{2,3})(?!#)\s+`(.+?)`\s*$")
RETURN_RE = re.compile(r"^返回[:]")
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
RETURN_LINE_RE = re.compile(r"^返回[:]")
RETURN_RE = re.compile(r"^返回[:]\s*\S")
TYPE_LINE_RE = re.compile(r"^类型[:]\s*(.*?)\s*$", re.IGNORECASE)
TYPE_RE = re.compile(r"^类型[:]\s*\S")
VALUE_LINE_RE = re.compile(r"^值[:]")
VALUE_RE = re.compile(r"^值[:]\s*(?:`[^`]+`|[^`\s])")
ACCESS_RE = re.compile(r"^访问[:]\s*(read|write|read\s*/\s*write)\s*$")
VISIBILITY_RE = re.compile(r"^可见性[:]\s*`?(public|protected|private)`?\s*$")
BASE_RE = re.compile(r"^父类[:]")
MODIFIERS_RE = re.compile(r"^修饰符[:]")
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
FENCE_RE = re.compile(r"^(```|~~~)")
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+(.+?)\s*$")
VALUE_HEADING_RE = re.compile(r"^`.+?`\s+取值$")
OPTIONAL_HINT_RE = re.compile(r"可选|可省略|省略")
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
@@ -43,20 +53,6 @@ SEP_CELL_RE = re.compile(r"^:?-+:?$")
PARAM_HEADER = ["参数", "类型", "说明"]
def iter_entries(lines):
"""Yield (start, end, signature): each entry spans one signature heading
to the next. Category headings without backticks fall to the tail of the
preceding entry (harmless — checks anchor on the entry's head)."""
starts = [
(idx, m.group(2))
for idx, line in enumerate(lines)
if (m := ENTRY_RE.match(line))
]
for i, (start, sig) in enumerate(starts):
end = starts[i + 1][0] if i + 1 < len(starts) else len(lines)
yield start, end, sig
def scan_body(lines, start, end):
"""Return [(lineno, raw, in_fence)] for the entry body (excludes the
signature line). Fence delimiter lines are marked in_fence so callers
@@ -82,6 +78,16 @@ def has_params(sig):
return bool(sig[left + 1:right].strip())
def signature_params(sig):
"""Return the parameter names carried by a normalized API signature."""
left = sig.find("(")
right = sig.rfind(")")
if left == -1 or right == -1 or right < left:
return []
raw = sig[left + 1:right].strip()
return [item.strip() for item in raw.split(",")] if raw else []
def split_row(text):
"""Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
parts = CELL_SPLIT_RE.split(text.strip())
@@ -130,16 +136,38 @@ def find_description(body):
stripped = raw.strip()
if not stripped or in_fence:
continue
if stripped.startswith("<!--"): # tags or other comment: skip
if DECLARATION_LINE_RE.fullmatch(stripped):
continue
if stripped.startswith("|") or stripped.startswith("#") \
or RETURN_RE.match(stripped):
if (
stripped.startswith("<!--")
or stripped.startswith("声明:")
or stripped.startswith("|")
or stripped.startswith("#")
or RETURN_LINE_RE.match(stripped)
or TYPE_LINE_RE.match(stripped)
or VALUE_LINE_RE.match(stripped)
or ACCESS_RE.match(stripped)
or VISIBILITY_RE.match(stripped)
or BASE_RE.match(stripped)
or MODIFIERS_RE.match(stripped)
):
return False, lineno
return True, lineno
return False, None
def check_entry(md_display, lines, start, end, sig, findings):
def check_entry(
md_display,
lines,
start,
end,
sig,
findings,
*,
returns_required=True,
visibility_required=False,
validate_parameter_rows=False,
):
entry_line = start + 1 # 1-based signature line, used for entry-level errors
body = scan_body(lines, start, end)
@@ -155,10 +183,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
RETURN_RE.match(raw.strip())
for _, raw, in_fence in body if not in_fence
)
if not has_return:
if returns_required and not has_return:
findings.append((md_display, entry_line, "error", "return",
f"`{sig}` 缺少 `返回:类型` 行"))
visibilities = []
for lineno, raw, in_fence in body:
if in_fence:
continue
match = VISIBILITY_RE.match(raw.strip())
if match:
visibilities.append((lineno, match.group(1)))
if visibility_required and not visibilities:
findings.append(
(
md_display,
entry_line,
"error",
"visibility",
f"`{sig}` 缺少 `可见性:public|protected` 行",
)
)
for lineno, visibility in visibilities:
if visibility == "private":
findings.append(
(
md_display,
lineno + 1,
"error",
"visibility",
"private API 不得进入文档",
)
)
# parameter table ------------------------------------------------------
table = find_table(body)
wants_params = has_params(sig)
@@ -176,6 +233,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
"param-header",
f"参数表表头须为 {' / '.join(PARAM_HEADER)}"
f"实为 {' / '.join(header_cells) or '(空)'}"))
elif validate_parameter_rows:
actual_names = []
rows_valid = True
for lineno, cells in data_rows:
if len(cells) != 3 or any(not cell.strip() for cell in cells):
rows_valid = False
findings.append(
(
md_display,
lineno + 1,
"error",
"param-row",
"参数表每行都必须包含非空的参数名、类型和说明",
)
)
continue
name_cell = cells[0]
if name_cell.startswith("`") and name_cell.endswith("`"):
name_cell = name_cell[1:-1]
actual_names.append(name_cell.strip())
expected_names = signature_params(sig)
if rows_valid and [name.casefold() for name in actual_names] != [
name.casefold() for name in expected_names
]:
findings.append(
(
md_display,
header_lineno + 1,
"error",
"param-names",
f"参数表名称/顺序必须与 `{sig}` 一致",
)
)
# soft: optional-parameter wording
for lineno, cells in data_rows:
if len(cells) < 3:
@@ -195,14 +285,211 @@ def check_entry(md_display, lines, start, end, sig, findings):
"空的 tags 行;填入关键词或删除"))
def check_api_subheadings(md_display, lines, entry, findings):
"""Enforce the fixed API child-heading levels and labels."""
heading = entry.heading
allowed_titles = set()
if heading.level == 2 and heading.root_kind == "function":
allowed_titles.add("示例")
allow_values = False
elif heading.kind in {"function", "method"}:
allowed_titles.add("示例")
allow_values = True
elif heading.kind == "property":
allow_values = True
else:
allow_values = False
expected_level = heading.level + 1
for lineno, raw, in_fence in scan_body(lines, entry.start, entry.end):
if in_fence:
continue
match = HEADING_RE.match(raw.strip())
if not match:
continue
level = len(match.group(1))
title = match.group(2)
title_allowed = title in allowed_titles or bool(
allow_values and VALUE_HEADING_RE.match(title)
)
if level == expected_level and title_allowed:
continue
if not allow_values and not allowed_titles:
message = f"{heading.kind} `{heading.signature}` 不得包含子标题"
else:
message = (
f"{heading.kind} `{heading.signature}` 的子标题必须位于 "
f"H{expected_level},且只能使用参数取值"
+ ("或示例" if "示例" in allowed_titles else "")
)
findings.append(
(md_display, lineno + 1, "error", "subheading", message)
)
def lint_file(md, root, findings):
try:
display = md.relative_to(root).as_posix()
except ValueError:
display = str(md)
lines = md.read_text(encoding="utf-8").splitlines()
for start, end, sig in iter_entries(lines):
check_entry(display, lines, start, end, sig, findings)
for entry in iter_api_entries(lines):
heading = entry.heading
if not heading.valid:
findings.append(
(
display,
entry.start + 1,
"error",
"heading",
heading.error,
)
)
body = scan_body(lines, entry.start, entry.end)
for lineno, raw, in_fence in body:
if in_fence:
continue
visibility = VISIBILITY_RE.match(raw.strip())
if visibility and visibility.group(1) == "private":
findings.append(
(
display,
lineno + 1,
"error",
"visibility",
"private API 不得进入文档",
)
)
continue
kind = heading.kind
check_api_subheadings(display, lines, entry, findings)
if kind == "function":
check_entry(
display,
lines,
entry.start,
entry.end,
heading.signature,
findings,
validate_parameter_rows=heading.root_kind != "function",
)
continue
if kind == "method":
check_entry(
display,
lines,
entry.start,
entry.end,
heading.signature,
findings,
returns_required=False,
visibility_required=True,
validate_parameter_rows=True,
)
continue
if kind == "property":
check_entry(
display,
lines,
entry.start,
entry.end,
heading.signature,
findings,
returns_required=False,
visibility_required=True,
validate_parameter_rows=True,
)
body = scan_body(lines, entry.start, entry.end)
empty_types = [
lineno
for lineno, raw, fenced in body
if not fenced
and (match := TYPE_LINE_RE.fullmatch(raw.strip()))
and not match.group(1).strip()
]
for lineno in empty_types:
findings.append(
(
display,
lineno + 1,
"error",
"type",
f"`{heading.signature}` 的类型不能为空",
)
)
if not any(
ACCESS_RE.match(raw.strip())
for _, raw, fenced in body
if not fenced
):
findings.append(
(
display,
entry.start + 1,
"error",
"access",
f"`{heading.signature}` 缺少访问方式",
)
)
continue
body = scan_body(lines, entry.start, entry.end)
found, offending = find_description(body)
if not found:
line = offending + 1 if offending is not None else entry.start + 1
findings.append(
(
display,
line,
"error",
"description",
f"`{heading.signature}` 缺少描述",
)
)
if kind in {"class", "unit"}:
continue
visibility_required = heading.binding != "unit"
visibilities = [
(lineno, match.group(1))
for lineno, raw, fenced in body
if not fenced and (match := VISIBILITY_RE.match(raw.strip()))
]
if visibility_required and not visibilities:
findings.append(
(
display,
entry.start + 1,
"error",
"visibility",
f"`{heading.signature}` 缺少 `可见性:public|protected` 行",
)
)
for lineno, visibility in visibilities:
if visibility == "private":
findings.append(
(
display,
lineno + 1,
"error",
"visibility",
"private API 不得进入文档",
)
)
has_type = any(TYPE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
has_value = any(VALUE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
if kind in {"field", "variable"} and not has_type:
findings.append(
(display, entry.start + 1, "error", "type", f"`{heading.signature}` 缺少类型")
)
if kind == "constant" and not has_value:
findings.append(
(display, entry.start + 1, "error", "value", f"`{heading.signature}` 缺少值")
)
def gather_targets(paths, root):
@@ -219,7 +506,16 @@ def gather_targets(paths, root):
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="校验 Markdown 文件或目录")
parser = argparse.ArgumentParser(
description="校验 Markdown 文件或目录",
add_help=False,
allow_abbrev=False,
)
parser.add_argument(
"--help",
action="help",
help="显示本帮助并退出(不提供 -h 短选项)",
)
target_group = parser.add_mutually_exclusive_group(required=True)
target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
target_group.add_argument("--dir", help="要递归校验的目录")